diff --git a/.gitignore b/.gitignore
index cc9f3177..02fba157 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,5 @@ dist/
# OS clutter
.DS_Store
+MAC_OSX/
+__MACOSX/
diff --git a/DOCKER.md b/DOCKER.md
index caf2b22f..332387b0 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -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`)
diff --git a/README.md b/README.md
index 0df3503a..87120eb8 100644
--- a/README.md
+++ b/README.md
@@ -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`
@@ -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.
+
## Features
@@ -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)
@@ -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 |
diff --git a/chatmock/fast_mode.py b/chatmock/fast_mode.py
index 498b70b8..56adb332 100644
--- a/chatmock/fast_mode.py
+++ b/chatmock/fast_mode.py
@@ -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(
diff --git a/chatmock/model_catalog.py b/chatmock/model_catalog.py
index 89459608..d157b2ac 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.153.4"
@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..9b5e49f0 100644
--- a/chatmock/model_registry.py
+++ b/chatmock/model_registry.py
@@ -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)
@@ -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",
@@ -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
diff --git a/chatmock/routes_openai.py b/chatmock/routes_openai.py
index 673e22fb..8258a651 100644
--- a/chatmock/routes_openai.py
+++ b/chatmock/routes_openai.py
@@ -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],
@@ -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)")
@@ -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",
}
}
@@ -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
diff --git a/tests/test_routes.py b/tests/test_routes.py
index a4906705..9349d286 100644
--- a/tests/test_routes.py
+++ b/tests/test_routes.py
@@ -2,12 +2,15 @@
import json
import socket
+import tempfile
import threading
import time
import unittest
+from pathlib import Path
from unittest.mock import patch
from chatmock.app import create_app
+from chatmock.model_catalog import CODEX_MODELS_CLIENT_VERSION, CatalogModel, ModelCatalog
from chatmock.session import reset_session_state
from websockets.sync.client import connect as ws_connect
@@ -65,6 +68,11 @@ 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)
+ self.assertIn("gpt-6-astra", model_ids)
+ for effort in ("low", "medium", "high", "xhigh", "max"):
+ self.assertIn(f"gpt-6-astra-{effort}", model_ids)
+ self.assertNotIn("gpt-6-astra-ultra", model_ids)
+ self.assertNotIn("gpt-5.6-sol-low", model_ids)
def test_ollama_tags_list(self) -> None:
response = self.client.get("/api/tags")
@@ -76,6 +84,206 @@ 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)
+ self.assertIn("gpt-6-astra", model_names)
+ for effort in ("low", "medium", "high", "xhigh", "max"):
+ self.assertIn(f"gpt-6-astra-{effort}", model_names)
+ self.assertNotIn("gpt-6-astra-ultra", model_names)
+ self.assertNotIn("gpt-5.6-sol-low", model_names)
+
+ def test_remote_catalog_is_merged_with_static_models(self) -> None:
+ app = create_app(model_sync=False)
+ catalog = ModelCatalog(enabled=False)
+ remote_slugs = (
+ "gpt-5.3-codex-spark",
+ "gpt-5.4-mini",
+ "gpt-5.5",
+ "gpt-5.6-luna",
+ "gpt-5.6-sol",
+ "gpt-5.6-terra",
+ )
+ catalog._models = tuple(
+ CatalogModel(
+ slug=slug,
+ reasoning_efforts=("low", "medium", "high"),
+ service_tiers=frozenset(),
+ priority=index,
+ visibility="list",
+ supported_in_api=True,
+ )
+ for index, slug in enumerate(remote_slugs)
+ )
+ app.extensions["chatmock_model_catalog"] = catalog
+ client = app.test_client()
+
+ openai_response = client.get("/v1/models")
+ ollama_response = client.get("/api/tags")
+ openai_ids = [item["id"] for item in openai_response.get_json()["data"]]
+ ollama_ids = [item["name"] for item in ollama_response.get_json()["models"]]
+
+ self.assertEqual(openai_response.status_code, 200)
+ self.assertEqual(ollama_response.status_code, 200)
+ self.assertEqual(openai_ids, ollama_ids)
+ self.assertEqual(openai_ids[: len(remote_slugs)], list(remote_slugs))
+ self.assertEqual(len(openai_ids), len(set(openai_ids)))
+ self.assertIn("gpt-5", openai_ids)
+ self.assertIn("codex-mini", openai_ids)
+
+ def test_gpt_6_is_listed_when_remote_catalog_provides_it(self) -> None:
+ app = create_app(expose_reasoning_models=True, model_sync=False)
+ catalog = ModelCatalog(enabled=False)
+ catalog._models = (
+ CatalogModel(
+ slug="gpt-6",
+ reasoning_efforts=("low", "high"),
+ service_tiers=frozenset(("priority",)),
+ priority=0,
+ visibility="list",
+ supported_in_api=True,
+ ),
+ )
+ app.extensions["chatmock_model_catalog"] = catalog
+
+ response = app.test_client().get("/v1/models")
+ model_ids = [item["id"] for item in response.get_json()["data"]]
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(model_ids[:3], ["gpt-6", "gpt-6-low", "gpt-6-high"])
+
+ def test_remote_reasoning_variants_override_static_variants(self) -> None:
+ app = create_app(expose_reasoning_models=True, model_sync=False)
+ catalog = ModelCatalog(enabled=False)
+ catalog._models = (
+ CatalogModel(
+ slug="gpt-5.5",
+ reasoning_efforts=("medium",),
+ service_tiers=frozenset(),
+ priority=0,
+ visibility="list",
+ supported_in_api=True,
+ ),
+ )
+ app.extensions["chatmock_model_catalog"] = catalog
+
+ response = app.test_client().get("/v1/models")
+ model_ids = [item["id"] for item in response.get_json()["data"]]
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(model_ids.count("gpt-5.5"), 1)
+ self.assertIn("gpt-5.5-medium", model_ids)
+ self.assertNotIn("gpt-5.5-high", model_ids)
+
+ def test_remote_astra_reasoning_variants_override_static_variants(self) -> None:
+ app = create_app(model_sync=False)
+ catalog = ModelCatalog(enabled=False)
+ catalog._models = (
+ CatalogModel(
+ slug="gpt-6-astra",
+ reasoning_efforts=("low", "ultra"),
+ service_tiers=frozenset(("priority",)),
+ priority=0,
+ visibility="list",
+ supported_in_api=True,
+ ),
+ )
+ app.extensions["chatmock_model_catalog"] = catalog
+
+ response = app.test_client().get("/v1/models")
+ model_ids = [item["id"] for item in response.get_json()["data"]]
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(model_ids.count("gpt-6-astra"), 1)
+ self.assertIn("gpt-6-astra-low", model_ids)
+ self.assertNotIn("gpt-6-astra-ultra", model_ids)
+ self.assertNotIn("gpt-6-astra-medium", model_ids)
+ self.assertNotIn("gpt-5.5-medium", model_ids)
+
+ def test_astra_ultra_is_hidden_from_both_catalog_endpoints(self) -> None:
+ for expose in (False, True):
+ with self.subTest(expose_reasoning_models=expose):
+ app = create_app(expose_reasoning_models=expose, model_sync=False)
+ catalog = ModelCatalog(enabled=False)
+ catalog._models = tuple(
+ CatalogModel(
+ slug=slug,
+ reasoning_efforts=efforts,
+ service_tiers=frozenset(),
+ priority=index,
+ visibility="list",
+ supported_in_api=True,
+ )
+ for index, (slug, efforts) in enumerate((
+ ("gpt-6-astra", ("low", "xhigh", "ultra")),
+ ("gpt-6-astra-ultra", ()),
+ ("gpt-5.6-sol", ("ultra",)),
+ ))
+ )
+ app.extensions["chatmock_model_catalog"] = catalog
+ client = app.test_client()
+ openai = client.get("/v1/models")
+ ollama = client.get("/api/tags")
+ self.assertEqual(openai.status_code, 200)
+ self.assertEqual(ollama.status_code, 200)
+ ids = [item["id"] for item in openai.get_json()["data"]]
+ self.assertEqual(ids, [item["name"] for item in ollama.get_json()["models"]])
+ self.assertNotIn("gpt-6-astra-ultra", ids)
+ self.assertIn("gpt-6-astra-low", ids)
+ self.assertIn("gpt-6-astra-xhigh", ids)
+ self.assertEqual(len(ids), len(set(ids)))
+ self.assertEqual("gpt-5.6-sol-ultra" in ids, expose)
+
+ @patch("chatmock.model_catalog._account_id_from_auth_file", return_value="acct")
+ def test_model_catalog_ignores_cache_from_old_client_version(self, _mock_account) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ cache_path = Path(directory) / "models.json"
+ cache_path.write_text(
+ json.dumps(
+ {
+ "client_version": "0.146.0",
+ "account_id": "acct",
+ "fetched_at": "2026-01-01T00:00:00Z",
+ "models": [
+ {
+ "slug": "stale-model",
+ "visibility": "list",
+ "supported_in_api": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ catalog = ModelCatalog(cache_path=cache_path)
+
+ self.assertEqual(CODEX_MODELS_CLIENT_VERSION, "0.153.4")
+ self.assertEqual(catalog._models, ())
+
+ @patch("chatmock.model_catalog._account_id_from_auth_file", return_value="acct")
+ def test_model_catalog_loads_current_client_version_cache(self, _mock_account) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ cache_path = Path(directory) / "models.json"
+ cache_path.write_text(
+ json.dumps(
+ {
+ "client_version": CODEX_MODELS_CLIENT_VERSION,
+ "account_id": "acct",
+ "fetched_at": "2026-01-01T00:00:00Z",
+ "models": [
+ {
+ "slug": "gpt-6-astra",
+ "supported_reasoning_levels": [{"effort": "ultra"}],
+ "visibility": "list",
+ "supported_in_api": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ catalog = ModelCatalog(cache_path=cache_path)
+
+ self.assertEqual([model.slug for model in catalog._models], ["gpt-6-astra"])
@patch("chatmock.routes_openai.start_upstream_request")
def test_chat_completions(self, mock_start) -> None:
@@ -97,6 +305,48 @@ def test_chat_completions(self, mock_start) -> None:
self.assertEqual(body["choices"][0]["message"]["content"], "hello")
self.assertEqual(body["model"], "gpt5.4-mini")
+ @patch("chatmock.routes_openai.start_upstream_request")
+ def test_chat_completions_maps_astra_alias_and_reasoning_effort(self, mock_start) -> None:
+ mock_start.return_value = (
+ FakeUpstream(
+ [
+ {"type": "response.output_text.delta", "delta": "hello"},
+ {"type": "response.completed", "response": {"id": "resp-astra"}},
+ ]
+ ),
+ None,
+ )
+
+ response = self.client.post(
+ "/v1/chat/completions",
+ json={"model": "gpt6-astra-xhigh", "messages": [{"role": "user", "content": "hi"}]},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(mock_start.call_args.args[0], "gpt-6-astra")
+ self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["effort"], "xhigh")
+
+ @patch("chatmock.routes_openai.start_upstream_request")
+ def test_chat_completions_preserves_upstream_error_message(self, mock_start) -> None:
+ mock_start.return_value = (
+ FakeUpstream(
+ status_code=400,
+ content=json.dumps({"detail": "The requested model is not available"}).encode("utf-8"),
+ ),
+ None,
+ )
+
+ response = self.client.post(
+ "/v1/chat/completions",
+ json={"model": "unavailable-model", "messages": [{"role": "user", "content": "hi"}]},
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(
+ response.get_json()["error"]["message"],
+ "The requested model is not available",
+ )
+
@patch("chatmock.routes_openai.start_upstream_request")
def test_chat_completions_honors_debug_model_override(self, mock_start) -> None:
app = create_app(debug_model="gpt-5.4", model_sync=False)