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)