Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 |

<details>
<summary><b>Web search in a request</b></summary>
Expand Down Expand Up @@ -156,6 +161,18 @@ All flags go after `chatmock serve`. These can also be set as environment variab

</details>

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.

<br>

## Important notice
Expand Down
13 changes: 13 additions & 0 deletions chatmock/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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),
Expand All @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions chatmock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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":
Expand Down
5 changes: 4 additions & 1 deletion chatmock/fast_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion chatmock/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions chatmock/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 69 additions & 3 deletions chatmock/upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading