From fad8b64391a64e4e64c492c5becafb9a0b21d2d0 Mon Sep 17 00:00:00 2001 From: philipp Date: Sat, 26 Sep 2026 10:45:37 +0200 Subject: [PATCH 1/2] Add opt-in fallback retry for upstream 429s --- .env.example | 4 ++ DOCKER.md | 2 + README.md | 14 ++++++ chatmock/app.py | 13 ++++++ chatmock/cli.py | 20 +++++++++ chatmock/upstream.py | 72 ++++++++++++++++++++++++++++-- tests/test_429_fallback.py | 90 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 tests/test_429_fallback.py diff --git a/.env.example b/.env.example index f90573ae..41e75586 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ CHATGPT_LOCAL_EXPOSE_REASONING_MODELS=false CHATGPT_LOCAL_MODEL_SYNC=true CHATGPT_LOCAL_MODEL_REFRESH_INTERVAL=3600 +# Retry one upstream 429 with this catalog-supported model (opt-in) +CHATGPT_LOCAL_FALLBACK_ON_429=false +CHATGPT_LOCAL_429_FALLBACK_MODEL=gpt-reserve + # Enable default web search tool CHATGPT_LOCAL_ENABLE_WEB_SEARCH=false diff --git a/DOCKER.md b/DOCKER.md index caf2b22f..7cc2ad63 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -34,6 +34,8 @@ Set options in `.env` or pass environment variables: - `CHATGPT_LOCAL_ENABLE_WEB_SEARCH`: `true|false` to enable default web search tool - `CHATGPT_LOCAL_MODEL_SYNC`: `true|false` to discover account models automatically (default `true`) - `CHATGPT_LOCAL_MODEL_REFRESH_INTERVAL`: model catalog refresh interval in seconds (default `3600`) +- `CHATGPT_LOCAL_FALLBACK_ON_429`: `true|false` to retry one upstream 429 with a catalog-supported fallback model (default `false`) +- `CHATGPT_LOCAL_429_FALLBACK_MODEL`: fallback model slug (default `gpt-reserve`) ## Logs Set `VERBOSE=true` to include extra logging for troubleshooting upstream or chat app requests. Please include and use these logs when submitting bug reports. diff --git a/README.md b/README.md index 0df3503a..156a15d1 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ All flags go after `chatmock serve`. These can also be set as environment variab | `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level as its own model | | `--model-sync` | `CHATGPT_LOCAL_MODEL_SYNC` | true/false | true | Discover account models automatically | | `--model-refresh-interval` | `CHATGPT_LOCAL_MODEL_REFRESH_INTERVAL` | seconds | 3600 | Refresh interval for model discovery | +| `--fallback-on-429` / `--no-fallback-on-429` | `CHATGPT_LOCAL_FALLBACK_ON_429` | true/false | false | Retry one upstream 429 with the configured catalog fallback model | +| `--429-fallback-model` | `CHATGPT_LOCAL_429_FALLBACK_MODEL` | model slug | `gpt-reserve` | Catalog model used for the opt-in 429 retry |
Web search in a request @@ -156,6 +158,18 @@ All flags go after `chatmock serve`. These can also be set as environment variab
+429 fallback is opt-in. When enabled, ChatMock retries an upstream HTTP 429 +once with the configured fallback model, but only when model sync reports that +model as API-supported. The original requested model remains in the local API +response. For example: + +```bash +chatmock serve --fallback-on-429 --429-fallback-model gpt-reserve +``` + +The retry happens before a streamed response starts. If the fallback request +also fails, ChatMock returns that upstream error without retrying again. +
## Important notice diff --git a/chatmock/app.py b/chatmock/app.py index f50eae02..ddc5ce86 100644 --- a/chatmock/app.py +++ b/chatmock/app.py @@ -24,6 +24,8 @@ def create_app( default_web_search: bool = False, model_sync: bool | None = None, model_refresh_interval: float | None = None, + fallback_on_429: bool | None = None, + fallback_model: str | None = None, ) -> Flask: app = Flask(__name__) if model_sync is None: @@ -40,6 +42,15 @@ def create_app( ) except (TypeError, ValueError): model_refresh_interval = DEFAULT_REFRESH_INTERVAL_SECONDS + if fallback_on_429 is None: + fallback_on_429 = (os.getenv("CHATGPT_LOCAL_FALLBACK_ON_429") or "false").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if fallback_model is None: + fallback_model = os.getenv("CHATGPT_LOCAL_429_FALLBACK_MODEL", "gpt-reserve").strip() app.config.update( VERBOSE=bool(verbose), @@ -53,6 +64,8 @@ def create_app( DEFAULT_WEB_SEARCH=bool(default_web_search), MODEL_SYNC=bool(model_sync), MODEL_REFRESH_INTERVAL=float(model_refresh_interval), + FALLBACK_ON_429=bool(fallback_on_429), + FALLBACK_MODEL=fallback_model, ) app.extensions["chatmock_model_catalog"] = ModelCatalog( enabled=bool(model_sync), diff --git a/chatmock/cli.py b/chatmock/cli.py index 1383bf92..609d7e0e 100644 --- a/chatmock/cli.py +++ b/chatmock/cli.py @@ -242,6 +242,8 @@ def cmd_serve( default_web_search: bool, model_sync: bool = True, model_refresh_interval: float = 3600, + fallback_on_429: bool = False, + fallback_model: str = "gpt-reserve", ) -> int: app = create_app( verbose=verbose, @@ -255,6 +257,8 @@ def cmd_serve( default_web_search=default_web_search, model_sync=model_sync, model_refresh_interval=model_refresh_interval, + fallback_on_429=fallback_on_429, + fallback_model=fallback_model, ) app.run(host=host, use_reloader=False, port=port, threaded=True) @@ -344,6 +348,20 @@ def main() -> None: metavar="SECONDS", help="Refresh the ChatGPT model catalog after this many seconds (default: 3600).", ) + p_serve.add_argument( + "--fallback-on-429", + action=argparse.BooleanOptionalAction, + default=(os.getenv("CHATGPT_LOCAL_FALLBACK_ON_429") or "false").strip().lower() + in ("1", "true", "yes", "on"), + help="Retry one upstream 429 with the configured fallback model (default: disabled).", + ) + p_serve.add_argument( + "--429-fallback-model", + dest="fallback_model", + default=os.getenv("CHATGPT_LOCAL_429_FALLBACK_MODEL", "gpt-reserve").strip(), + metavar="MODEL", + help="Catalog model to use for the opt-in 429 retry (default: gpt-reserve).", + ) p_info = sub.add_parser("info", help="Print current stored tokens and derived account id") p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents") @@ -368,6 +386,8 @@ def main() -> None: default_web_search=args.enable_web_search, model_sync=args.model_sync, model_refresh_interval=args.model_refresh_interval, + fallback_on_429=args.fallback_on_429, + fallback_model=args.fallback_model, ) ) elif args.command == "info": diff --git a/chatmock/upstream.py b/chatmock/upstream.py index 9ad7941e..90940adc 100644 --- a/chatmock/upstream.py +++ b/chatmock/upstream.py @@ -10,7 +10,7 @@ from .config import CHATGPT_RESPONSES_URL, ORIGINATOR from .http import build_cors_headers -from .model_registry import normalize_model_name +from .model_registry import current_model_catalog, model_supports_service_tier, normalize_model_name from .session import ensure_session_id from flask import request as flask_request from .utils import get_codex_user_agent, get_effective_chatgpt_auth, resolve_installation_id @@ -87,13 +87,65 @@ def start_upstream_request( if isinstance(service_tier, str) and service_tier.strip(): responses_payload["service_tier"] = service_tier.strip().lower() - return start_upstream_raw_request( + return start_upstream_with_429_fallback( responses_payload, session_id=session_id, stream=True, ) +def _fallback_model_for_429(requested_model: str | None) -> str | None: + if not current_app.config.get("FALLBACK_ON_429", False): + return None + candidate = str(current_app.config.get("FALLBACK_MODEL", "")).strip() + if not candidate or normalize_model_name(requested_model) == normalize_model_name(candidate): + return None + catalog = current_model_catalog() + if catalog is None: + return None + for entry in catalog.models(): + if entry.slug == candidate and entry.supported_in_api: + return candidate + return None + + +def start_upstream_with_429_fallback( + responses_payload: Dict[str, Any], + *, + session_id: str | None = None, + stream: bool = True, +): + """Make one upstream request, optionally retrying a 429 with a catalog model.""" + upstream, error_resp = _start_upstream_raw_request( + responses_payload, + session_id=session_id, + stream=stream, + ) + if error_resp is not None or upstream is None or upstream.status_code != 429: + return upstream, error_resp + + requested_model = responses_payload.get("model") + fallback_model = _fallback_model_for_429(requested_model) + if fallback_model is None: + return upstream, error_resp + + try: + upstream.close() + except Exception: + pass + fallback_payload = dict(responses_payload) + fallback_payload["model"] = fallback_model + service_tier = fallback_payload.get("service_tier") + if service_tier and not model_supports_service_tier(fallback_model, service_tier): + fallback_payload.pop("service_tier", None) + print(f"[429 fallback] {normalize_model_name(requested_model)} -> {fallback_model}", flush=True) + return _start_upstream_raw_request( + fallback_payload, + session_id=session_id, + stream=stream, + ) + + def build_upstream_headers( access_token: str, account_id: str, @@ -114,7 +166,7 @@ def build_upstream_headers( } -def start_upstream_raw_request( +def _start_upstream_raw_request( responses_payload: Dict[str, Any], *, session_id: str | None = None, @@ -219,6 +271,20 @@ def start_upstream_raw_request( return upstream, None +def start_upstream_raw_request( + responses_payload: Dict[str, Any], + *, + session_id: str | None = None, + stream: bool = True, +): + """Compatibility entry point with the optional 429 fallback applied.""" + return start_upstream_with_429_fallback( + responses_payload, + session_id=session_id, + stream=stream, + ) + + def build_upstream_websocket_url() -> str: parsed = urlparse(CHATGPT_RESPONSES_URL) scheme = parsed.scheme.lower() diff --git a/tests/test_429_fallback.py b/tests/test_429_fallback.py new file mode 100644 index 00000000..b31bf2f0 --- /dev/null +++ b/tests/test_429_fallback.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from chatmock.app import create_app +from chatmock.upstream import start_upstream_with_429_fallback + + +class FakeResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.closed = False + + def close(self) -> None: + self.closed = True + + +class FallbackTests(unittest.TestCase): + def setUp(self) -> None: + self.app = create_app(model_sync=False) + self.catalog = SimpleNamespace( + models=lambda: ( + SimpleNamespace(slug="gpt-reserve", supported_in_api=True), + ) + ) + + def test_disabled_fallback_preserves_429(self) -> None: + calls: list[dict[str, object]] = [] + first = FakeResponse(429) + + def fake_raw(payload, **kwargs): + calls.append(payload) + return first, None + + with self.app.app_context(), patch("chatmock.upstream._start_upstream_raw_request", side_effect=fake_raw), patch( + "chatmock.upstream.current_model_catalog", return_value=self.catalog + ): + result, error = start_upstream_with_429_fallback({"model": "gpt-5.6-luna", "input": []}) + + self.assertIs(result, first) + self.assertIsNone(error) + self.assertEqual(len(calls), 1) + + def test_enabled_fallback_retries_once_with_catalog_model(self) -> None: + calls: list[dict[str, object]] = [] + first = FakeResponse(429) + second = FakeResponse(200) + + def fake_raw(payload, **kwargs): + calls.append(payload) + return (first, None) if len(calls) == 1 else (second, None) + + self.app.config.update(FALLBACK_ON_429=True, FALLBACK_MODEL="gpt-reserve") + with self.app.app_context(), patch("chatmock.upstream._start_upstream_raw_request", side_effect=fake_raw), patch( + "chatmock.upstream.current_model_catalog", return_value=self.catalog + ): + result, error = start_upstream_with_429_fallback( + {"model": "gpt-5.6-luna", "input": [], "service_tier": "priority"} + ) + + self.assertIs(result, second) + self.assertIsNone(error) + self.assertTrue(first.closed) + self.assertEqual([call["model"] for call in calls], ["gpt-5.6-luna", "gpt-reserve"]) + self.assertNotIn("service_tier", calls[1]) + + def test_fallback_model_must_be_catalog_supported(self) -> None: + calls: list[dict[str, object]] = [] + first = FakeResponse(429) + + def fake_raw(payload, **kwargs): + calls.append(payload) + return first, None + + unsupported = SimpleNamespace(models=lambda: (SimpleNamespace(slug="gpt-reserve", supported_in_api=False),)) + self.app.config.update(FALLBACK_ON_429=True, FALLBACK_MODEL="gpt-reserve") + with self.app.app_context(), patch("chatmock.upstream._start_upstream_raw_request", side_effect=fake_raw), patch( + "chatmock.upstream.current_model_catalog", return_value=unsupported + ): + result, error = start_upstream_with_429_fallback({"model": "gpt-5.6-luna", "input": []}) + + self.assertIs(result, first) + self.assertIsNone(error) + self.assertEqual(len(calls), 1) + + +if __name__ == "__main__": + unittest.main() From 865f78d6e5b405770f634b290bedeaa21f8f4fdf Mon Sep 17 00:00:00 2001 From: Fabricio Malta Date: Tue, 22 Sep 2026 15:52:25 -0300 Subject: [PATCH 2/2] feat: support GPT-6 Astra, GPT-6 Sol, GPT-6 Luna --- README.md | 3 +++ chatmock/fast_mode.py | 5 ++++- chatmock/model_catalog.py | 4 +++- chatmock/model_registry.py | 21 +++++++++++++++++++++ tests/test_model_catalog.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_routes.py | 4 ++++ 6 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/test_model_catalog.py diff --git a/README.md b/README.md index 156a15d1..389566cf 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ The server runs at `http://127.0.0.1:8000` by default. Use `http://127.0.0.1:800 ChatMock automatically discovers the models available to the signed-in ChatGPT account. The current catalog commonly includes: +- `gpt-6-astra` +- `gpt-6-sol` +- `gpt-6-luna` - `gpt-5.6-sol` - `gpt-5.6-terra` - `gpt-5.6-luna` diff --git a/chatmock/fast_mode.py b/chatmock/fast_mode.py index 498b70b8..a3cf4d26 100644 --- a/chatmock/fast_mode.py +++ b/chatmock/fast_mode.py @@ -12,6 +12,9 @@ "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.2", "gpt-5.1", "gpt-5", @@ -78,7 +81,7 @@ def resolve_service_tier( normalized = normalize_model_name(model) message = ( f"Fast mode is not supported for model '{normalized}'. " - "Use a supported GPT-5 priority-processing model or disable fast mode for this request." + "Use a model with priority processing or disable fast mode for this request." ) if explicit_request: return ServiceTierResolution( diff --git a/chatmock/model_catalog.py b/chatmock/model_catalog.py index 89459608..f57453ec 100644 --- a/chatmock/model_catalog.py +++ b/chatmock/model_catalog.py @@ -26,7 +26,7 @@ FETCH_TIMEOUT_SECONDS = 5 MODEL_CACHE_FILE = "chatmock_models_cache.json" # Bump only after verifying ChatMock against a newer Codex catalog contract. -CODEX_MODELS_CLIENT_VERSION = "0.146.0" +CODEX_MODELS_CLIENT_VERSION = "0.160.0" @dataclass(frozen=True) @@ -248,6 +248,8 @@ def _load_cache(self) -> None: return if not isinstance(payload, dict): return + if payload.get("client_version") != CODEX_MODELS_CLIENT_VERSION: + return cached_account_id = payload.get("account_id") current_account_id = _account_id_from_auth_file() diff --git a/chatmock/model_registry.py b/chatmock/model_registry.py index 0768b5ee..25698e33 100644 --- a/chatmock/model_registry.py +++ b/chatmock/model_registry.py @@ -83,6 +83,27 @@ class ModelSpec: allowed_efforts=frozenset(("low", "medium", "high", "xhigh", "max")), variant_efforts=("low", "medium", "high", "xhigh", "max"), ), + ModelSpec( + public_id="gpt-6-astra", + upstream_id="gpt-6-astra", + aliases=("gpt6-astra", "gpt-6-astra-latest"), + allowed_efforts=frozenset(("low", "medium", "high", "xhigh", "max", "ultra")), + variant_efforts=("low", "medium", "high", "xhigh", "max", "ultra"), + ), + ModelSpec( + public_id="gpt-6-sol", + upstream_id="gpt-6-sol", + aliases=("gpt6-sol", "gpt-6-sol-latest"), + allowed_efforts=frozenset(("low", "medium", "high", "xhigh", "max", "ultra")), + variant_efforts=("low", "medium", "high", "xhigh", "max", "ultra"), + ), + ModelSpec( + public_id="gpt-6-luna", + upstream_id="gpt-6-luna", + aliases=("gpt6-luna", "gpt-6-luna-latest"), + allowed_efforts=frozenset(("low", "medium", "high", "xhigh", "max")), + variant_efforts=("low", "medium", "high", "xhigh", "max"), + ), ModelSpec( public_id="gpt-5.3-codex", upstream_id="gpt-5.3-codex", diff --git a/tests/test_model_catalog.py b/tests/test_model_catalog.py new file mode 100644 index 00000000..69d86e7f --- /dev/null +++ b/tests/test_model_catalog.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from chatmock.model_catalog import CODEX_MODELS_CLIENT_VERSION, ModelCatalog + + +class ModelCatalogCacheTests(unittest.TestCase): + def test_cache_from_older_client_version_is_ignored(self) -> None: + with tempfile.TemporaryDirectory() as directory: + cache_path = Path(directory) / "models.json" + payload = { + "account_id": "test-account", + "client_version": "0.146.0", + "fetched_at": "2026-09-22T00:00:00Z", + "models": [{"slug": "gpt-5.6-sol", "visibility": "list"}], + } + cache_path.write_text(json.dumps(payload), encoding="utf-8") + + with patch("chatmock.model_catalog._account_id_from_auth_file", return_value="test-account"): + old_catalog = ModelCatalog(cache_path=cache_path) + self.assertEqual(old_catalog._models, ()) + + payload["client_version"] = CODEX_MODELS_CLIENT_VERSION + cache_path.write_text(json.dumps(payload), encoding="utf-8") + current_catalog = ModelCatalog(cache_path=cache_path) + self.assertEqual([model.slug for model in current_catalog._models], ["gpt-5.6-sol"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_routes.py b/tests/test_routes.py index a4906705..6b9eaba1 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -65,6 +65,8 @@ def test_openai_models_list(self) -> None: self.assertIn("gpt-5.6-sol", model_ids) self.assertIn("gpt-5.6-terra", model_ids) self.assertIn("gpt-5.6-luna", model_ids) + for model in ("gpt-6-astra", "gpt-6-sol", "gpt-6-luna"): + self.assertIn(model, model_ids) def test_ollama_tags_list(self) -> None: response = self.client.get("/api/tags") @@ -76,6 +78,8 @@ def test_ollama_tags_list(self) -> None: self.assertIn("gpt-5.6-sol", model_names) self.assertIn("gpt-5.6-terra", model_names) self.assertIn("gpt-5.6-luna", model_names) + for model in ("gpt-6-astra", "gpt-6-sol", "gpt-6-luna"): + self.assertIn(model, model_names) @patch("chatmock.routes_openai.start_upstream_request") def test_chat_completions(self, mock_start) -> None: