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..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`
@@ -128,6 +131,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 +161,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/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/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()
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: