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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ dist/

# OS clutter
.DS_Store
MAC_OSX/
__MACOSX/
2 changes: 1 addition & 1 deletion DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Set options in `.env` or pass environment variables:
- `CHATGPT_LOCAL_REASONING_COMPAT`: legacy|o3|think-tags|current
- `CHATGPT_LOCAL_FAST_MODE`: `true|false` to enable fast mode by default for supported models
- `CHATGPT_LOCAL_CLIENT_ID`: OAuth client id override (rarely needed)
- `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS`: `true|false` to add reasoning model variants to `/v1/models`
- `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS`: `true|false` to add reasoning variants for all models to `/v1/models`; `gpt-6-astra` variants are always listed
- `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`)
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ 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-astra-high`
- `gpt-6-astra-low`
- `gpt-6-astra-medium`
- `gpt-6-astra-xhigh`
- `gpt-5.6-sol`
- `gpt-5.6-terra`
- `gpt-5.6-luna`
Expand All @@ -98,6 +103,15 @@ account. The current catalog commonly includes:
- `gpt-5.4-mini`
- `gpt-5.3-codex-spark`

`gpt-6-astra` exposes reasoning efforts as separate
model IDs, such as `gpt-6-astra-low`, `gpt-6-astra-medium`, and
`gpt-6-astra-xhigh`. The signed-in account catalog determines the exact set;
the static fallback also includes `high` and `max`.
`gpt-6-astra-ultra` is excluded from both model lists, including cached or
remote catalog entries, because its upstream support has not been verified
and requests have been reported to fail. This removes discovery only: manually
specified requests are not remapped to another effort or blocked by this change.

<br>

## Features
Expand All @@ -110,7 +124,7 @@ account. The current catalog commonly includes:
- Web search tool
- OpenAI-compatible `/v1/responses` (HTTP + WebSocket)
- Ollama-compatible endpoints
- Reasoning effort exposed as separate models (optional)
- Reasoning effort exposed as separate models (`gpt-6-astra` always; other models optional)

<br>

Expand All @@ -125,7 +139,7 @@ All flags go after `chatmock serve`. These can also be set as environment variab
| `--reasoning-compat` | `CHATGPT_LOCAL_REASONING_COMPAT` | legacy, o3, think-tags | think-tags | How reasoning is returned to the client |
| `--fast-mode` | `CHATGPT_LOCAL_FAST_MODE` | true/false | false | Priority processing for supported models |
| `--enable-web-search` | `CHATGPT_LOCAL_ENABLE_WEB_SEARCH` | true/false | false | Allow the model to search the web |
| `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level as its own model |
| `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level for all models; `gpt-6-astra` variants are always listed |
| `--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 |

Expand Down
2 changes: 1 addition & 1 deletion chatmock/fast_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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 supported GPT priority-processing model 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.153.4"


@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
49 changes: 38 additions & 11 deletions chatmock/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

ALL_REASONING_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
DEFAULT_REASONING_EFFORTS = frozenset(ALL_REASONING_EFFORTS)
ALWAYS_EXPOSE_REASONING_VARIANTS = frozenset(("gpt-6-astra",))
# Do not advertise this unverified upstream model/effort combination, even
# when it appears in a cached or remote catalog.
HIDDEN_MODEL_IDS = frozenset(("gpt-6-astra-ultra",))


@dataclass(frozen=True)
Expand Down Expand Up @@ -83,6 +87,13 @@ 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"),
),
ModelSpec(
public_id="gpt-5.3-codex",
upstream_id="gpt-5.3-codex",
Expand Down Expand Up @@ -245,22 +256,38 @@ def extract_reasoning_from_model_name(model: str | None) -> dict[str, str] | Non


def list_public_models(expose_reasoning_models: bool = False) -> list[str]:
model_ids: list[str] = []
seen_ids: set[str] = set()

def append_model(model_id: str) -> None:
if model_id in HIDDEN_MODEL_IDS:
return
if model_id not in seen_ids:
seen_ids.add(model_id)
model_ids.append(model_id)

def expose_variants_for(model_id: str) -> bool:
return expose_reasoning_models or model_id in ALWAYS_EXPOSE_REASONING_VARIANTS

catalog = current_model_catalog()
if catalog is not None:
remote_models = catalog.visible_models(wait_for_refresh=True)
if remote_models:
model_ids: list[str] = []
for model in remote_models:
model_ids.append(model.slug)
if expose_reasoning_models:
model_ids.extend(f"{model.slug}-{effort}" for effort in model.reasoning_efforts)
return model_ids
for model in remote_models:
append_model(model.slug)
if expose_variants_for(model.slug):
for effort in model.reasoning_efforts:
append_model(f"{model.slug}-{effort}")

model_ids: list[str] = []
for spec in _MODEL_SPECS:
model_ids.append(spec.public_id)
if expose_reasoning_models:
model_ids.extend(f"{spec.public_id}-{effort}" for effort in spec.variant_efforts)
base_was_seen = spec.public_id in seen_ids
append_model(spec.public_id)
if expose_variants_for(spec.public_id):
# Prefer account-specific reasoning metadata when the remote catalog
# contains this model; static variants are only a compatibility fallback.
if base_was_seen:
continue
for effort in spec.variant_efforts:
append_model(f"{spec.public_id}-{effort}")
return model_ids


Expand Down
35 changes: 28 additions & 7 deletions chatmock/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ def _gen():
return _gen()


def _upstream_error_message(upstream: Any) -> str:
raw = getattr(upstream, "content", b"") or b""
text = getattr(upstream, "text", "") or ""
try:
body = json.loads(raw.decode("utf-8", errors="replace")) if raw else None
except (TypeError, ValueError):
body = None

if isinstance(body, dict):
error = body.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
elif isinstance(error, str) and error.strip():
return error.strip()
for key in ("message", "detail", "title"):
value = body.get(key)
if isinstance(value, str) and value.strip():
return value.strip()

if isinstance(text, str) and text.strip():
return text.strip()
return "Upstream error"


def _service_tier_from_payload(
model: str,
payload: Dict[str, Any],
Expand Down Expand Up @@ -237,11 +263,6 @@ def chat_completions() -> Response:

created = int(time.time())
if upstream.status_code >= 400:
try:
raw = upstream.content
err_body = json.loads(raw.decode("utf-8", errors="ignore")) if raw else {"raw": upstream.text}
except Exception:
err_body = {"raw": upstream.text}
if had_responses_tools:
if verbose:
print("[Passthrough] Upstream rejected tools; retrying without extra tools (args redacted)")
Expand All @@ -262,7 +283,7 @@ def chat_completions() -> Response:
else:
err = {
"error": {
"message": (err_body.get("error", {}) or {}).get("message", "Upstream error"),
"message": _upstream_error_message(upstream2 or upstream),
"code": "RESPONSES_TOOLS_REJECTED",
}
}
Expand All @@ -272,7 +293,7 @@ def chat_completions() -> Response:
else:
if verbose:
print("Upstream error status=", upstream.status_code)
err = {"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error")}}
err = {"error": {"message": _upstream_error_message(upstream)}}
if verbose:
_log_json("OUT POST /v1/chat/completions", err)
return jsonify(err), upstream.status_code
Expand Down
Loading