From 64e6765e5c35acd29aff691579edd739e73e8f14 Mon Sep 17 00:00:00 2001 From: "oleg.simashkevich" Date: Sat, 10 Oct 2026 22:36:49 +0300 Subject: [PATCH 1/2] Accept owner-prefixed model ids in the catalog and chat API --- integrations/harness.py | 16 ++++++++++---- server/api.py | 5 +++-- server/config.py | 38 +++++++++++++++++++++++++++++--- server/conversation_cache.py | 6 ++++- tests/test_conversation_cache.py | 14 ++++++++++++ tests/test_harness.py | 17 ++++++++++++++ tests/test_qwen_api.py | 16 ++++++++++++++ 7 files changed, 102 insertions(+), 10 deletions(-) diff --git a/integrations/harness.py b/integrations/harness.py index 7dfdde1..9a7336a 100644 --- a/integrations/harness.py +++ b/integrations/harness.py @@ -64,7 +64,8 @@ def discover(base_url: str, without_browser=False) -> list: def catalog_models(catalog: dict, without_browser=False) -> list: rows = catalog.get("data") if isinstance(catalog, dict) else None - if not isinstance(rows, list) or not rows or len(rows) > 100: + # Bare ids plus owner/id aliases roughly double the catalog size. + if not isinstance(rows, list) or not rows or len(rows) > 200: raise ValueError("Bridge advertises no models or an invalid catalog; enable a provider first") models, seen = [], set() for row in rows: @@ -72,18 +73,25 @@ def catalog_models(catalog: dict, without_browser=False) -> list: raise ValueError("Invalid bridge model entry") model = row.get("id") owner = row.get("owned_by") - if (not isinstance(model, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", model) + # Bare ids and matching `owner/id` aliases from the bridge catalog. + if (not isinstance(model, str) + or not re.fullmatch( + r"(?:[a-z][a-z0-9-]{0,31}/)?[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}", model) or model in seen or not isinstance(owner, str) or owner not in PROVIDERS): raise ValueError("Invalid or duplicate bridge model identity") + bare = model.split("/", 1)[-1] + if "/" in model and not model.startswith(owner + "/"): + raise ValueError("Invalid or duplicate bridge model identity") seen.add(model) - models.append({"id": model, "name": LABELS.get(model, model + " · bridge"), + models.append({"id": model, "name": LABELS.get(bare, LABELS.get(model, model + " · bridge")), "input": ["text"], "reasoningEfforts": False, "contextWindow": 32768, "maxTokens": 4096}) # Validate the entire advertised catalog before excluding any provider. if without_browser: models = [model for row, model in zip(rows, models) if row["owned_by"] in BROWSERLESS_PROVIDERS - or (row['owned_by'] == 'kimi' and row['id'] in KIMI_PUBLIC_MODELS)] + or (row["owned_by"] == "kimi" + and row["id"].split("/", 1)[-1] in KIMI_PUBLIC_MODELS)] if not models: raise ValueError("Bridge advertises no browserless models for --without-browser") return models diff --git a/server/api.py b/server/api.py index f108b54..2dc69b7 100644 --- a/server/api.py +++ b/server/api.py @@ -60,6 +60,7 @@ SERVER_INTERACTIVE_LOGIN, SESSION_REFRESH_ENABLED, SESSION_REFRESH_INTERVAL, + advertised_model_ids, is_known_model, resolve_model_type, model_provider, @@ -604,7 +605,7 @@ def list_models(): "object": "list", "data": [ {"id": name, "object": "model", "created": created, "owned_by": model_provider(name)} - for name in MODEL_MAP + for name in advertised_model_ids() ], } @@ -617,7 +618,7 @@ async def chat_completions(req: ChatCompletionRequest): if not is_known_model(req.model): return _error( f"The model `{req.model}` does not exist. Available models: " - f"{', '.join(MODEL_MAP)}", + f"{', '.join(advertised_model_ids())}", status=404, err_type="model_not_found", ) diff --git a/server/config.py b/server/config.py index 55a6e5b..4cb0ecc 100644 --- a/server/config.py +++ b/server/config.py @@ -74,15 +74,37 @@ OPTIONAL_MODEL_PROVIDERS[name] = "gemini" -def model_provider(name: str) -> str: +def _bare_owner(name: str) -> str: return OPTIONAL_MODEL_PROVIDERS.get(name, "qwen" if name in QWEN_MODEL_MAP else "deepseek") + +def canonical_model(name: str): + """Bare public id for a known model, or None. + + Accepts both `qwen3.8-omni-flash` and `qwen/qwen3.8-omni-flash` when the + author segment matches the model's provider. + """ + if name in MODEL_MAP: + return name + if "/" not in name: + return None + owner, _, bare = name.partition("/") + if "/" in bare or bare not in MODEL_MAP or _bare_owner(bare) != owner: + return None + return bare + + +def model_provider(name: str) -> str: + bare = canonical_model(name) + return _bare_owner(bare if bare is not None else name) + + DEFAULT_MODEL = "qwen3.8-omni-flash" def is_known_model(name: str) -> bool: """Whether `name` is a model id we accept (used to 404 unknown models).""" - return name in MODEL_MAP + return canonical_model(name) is not None def resolve_model_type(name: str) -> str: @@ -90,4 +112,14 @@ def resolve_model_type(name: str) -> str: Caller must check `is_known_model` first; this raises KeyError otherwise. """ - return MODEL_MAP[name] + bare = canonical_model(name) + if bare is None: + raise KeyError(name) + return MODEL_MAP[bare] + + +def advertised_model_ids(): + """Bare ids plus matching `owner/id` aliases for OpenAI-compatible clients.""" + for name in MODEL_MAP: + yield name + yield f"{_bare_owner(name)}/{name}" diff --git a/server/conversation_cache.py b/server/conversation_cache.py index 06dfeb3..d0a5dc9 100644 --- a/server/conversation_cache.py +++ b/server/conversation_cache.py @@ -11,6 +11,7 @@ from pathlib import Path from settings import ROOT +from .config import canonical_model from .schemas import ChatMessage @@ -37,7 +38,10 @@ def normalized(message): def digest(req, messages): - payload = [req.model, req.user, req.thinking, req.search, req.tools, + # Owner-prefixed aliases (deepseek/deepseek-expert) must share history with + # the bare public id used when the link was stored. + model = canonical_model(req.model) or req.model + payload = [model, req.user, req.thinking, req.search, req.tools, [normalized(m) for m in messages]] return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest() diff --git a/tests/test_conversation_cache.py b/tests/test_conversation_cache.py index e3bd9d1..6afb002 100644 --- a/tests/test_conversation_cache.py +++ b/tests/test_conversation_cache.py @@ -43,6 +43,20 @@ def test_restart_reuses_chat_and_only_new_messages_once(self): self.assertNotIn('fixture result', self.cache.path.read_bytes().decode(errors='ignore')) self.assertEqual(self.cache.path.stat().st_mode & 0o777, 0o600) + def test_owner_prefixed_model_alias_reuses_the_same_history(self): + self.cache.remember(self.req, self.reply, '', self.calls, 0) + req = self.req.model_copy(update={ + 'model': 'deepseek/deepseek-chat', 'messages': self.history(), + }) + continuation, steps = self.cache.prepare(req) + self.assertEqual(continuation.conversation_id, 'owned:2') + self.assertEqual(steps, 1) + # A different bare model still must not share the link. + with self.assertRaises(ConversationStateError): + self.cache.prepare(self.req.model_copy(update={ + 'model': 'deepseek/deepseek-expert', 'messages': self.history(), + })) + def test_unknown_changed_scope_and_edited_histories_never_start_chat(self): self.cache.remember(self.req, self.reply, '', self.calls, 0) for update in ({'model': 'deepseek-expert'}, {'user': 'other'}, {'tools': []}, diff --git a/tests/test_harness.py b/tests/test_harness.py index 5b798b1..6477de6 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -98,6 +98,23 @@ def test_without_browser_keeps_only_the_explicit_direct_kimi_models(self): self.assertEqual([m['id'] for m in models], ['kimi-instant', 'kimi-k3', 'kimi-k2.8']) self.assertEqual(models[0]['name'], 'Kimi Instant · web') + def test_catalog_accepts_matching_owner_prefixed_aliases(self): + catalog = {"data": [ + {"id": "qwen3.8-omni-flash", "owned_by": "qwen"}, + {"id": "qwen/qwen3.8-omni-flash", "owned_by": "qwen"}, + {"id": "kimi/kimi-instant", "owned_by": "kimi"}, + ]} + models = harness.catalog_models(catalog) + self.assertEqual([model["id"] for model in models], + ["qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "kimi/kimi-instant"]) + self.assertEqual(models[1]["name"], "Qwen3.8 Omni Flash · web") + self.assertEqual( + [model["id"] for model in harness.catalog_models(catalog, without_browser=True)], + ["qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "kimi/kimi-instant"], + ) + with self.assertRaises(ValueError): + harness.catalog_models({"data": [{"id": "evil/qwen3.8-omni-flash", "owned_by": "qwen"}]}) + def run_cli_catalog(self, catalog, *args): requests = [] real_client = httpx.Client diff --git a/tests/test_qwen_api.py b/tests/test_qwen_api.py index ad42145..7c9e272 100644 --- a/tests/test_qwen_api.py +++ b/tests/test_qwen_api.py @@ -51,8 +51,24 @@ async def test_models_have_correct_provider_ownership(self): response = await self.http.get("/v1/models") models = {m["id"]: m["owned_by"] for m in response.json()["data"]} self.assertEqual(models["qwen3.8-omni-flash"], "qwen") + self.assertEqual(models["qwen/qwen3.8-omni-flash"], "qwen") self.assertEqual(models["qwen3.8-max"], "qwen") + self.assertEqual(models["qwen/qwen3.8-max"], "qwen") self.assertEqual(models["deepseek-chat"], "deepseek") + self.assertEqual(models["deepseek/deepseek-chat"], "deepseek") + + async def test_author_prefixed_model_ids_are_accepted(self): + fake = Mock() + fake.chat.return_value = Reply("QWEN_OK", CID) + with patch.object(api, "get_qwen_client", return_value=fake), patch.object(api, "get_client") as ds: + response = await self.http.post( + "/v1/chat/completions", + json={**self.body, "model": "qwen/qwen3.8-max"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["model"], "qwen/qwen3.8-max") + self.assertEqual(fake.chat.call_args.args[2], "qwen3.8-max") + ds.assert_not_called() async def test_qwen_requests_do_not_use_deepseek_credentials(self): fake = Mock() From 81420c58433eec9034339c8987e995fb29613c57 Mon Sep 17 00:00:00 2001 From: Dmitry Labintcev Date: Mon, 12 Oct 2026 11:22:36 +1000 Subject: [PATCH 2/2] docs: explain provider-prefixed model aliases --- README.en.md | 10 ++++++++++ README.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/README.en.md b/README.en.md index f9fa1e3..5ec2d4b 100644 --- a/README.en.md +++ b/README.en.md @@ -378,6 +378,16 @@ CI covers Python 3.9/3.12, PoW WASM, shell/JavaScript and isolated browser fixtu Locally: `python -m unittest discover -s tests -t . -v`. [Real Harness CLI fixtures with mocked providers](docs/HARNESS.en.md#offline-verification). +### Model identifiers + +`GET /v1/models` lists bare IDs and `provider/model` aliases, for example +`qwen3.8-max` and `qwen/qwen3.8-max`, or `deepseek-chat` and +`deepseek/deepseek-chat`. Both forms work with the API and Harness catalog. +The prefix must match the provider and does not enable disabled models. +DeepSeek aliases share the same history binding and continuation accounting: +changing the spelling neither creates a new chat nor resets replay protection. +Verified offline; this does not verify model access for a live account. + **MIT · unofficial project.** Based on [Tsuev/opencode-deepseek](https://github.com/Tsuev/opencode-deepseek) and [sums001/Deepseek-API](https://github.com/sums001/Deepseek-API). diff --git a/README.md b/README.md index 8bd547c..b5e1291 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,16 @@ CI проверяет Python 3.9/3.12, PoW WASM, shell/JavaScript и изоли сценарии. Локально: `python -m unittest discover -s tests -t . -v`. [Сценарии настоящего Harness CLI с подменённым провайдером](docs/HARNESS.ru.md#проверка-без-аккаунтов). +### Идентификаторы моделей + +`GET /v1/models` показывает короткие имена и алиасы `провайдер/модель`: например, +`qwen3.8-max` и `qwen/qwen3.8-max`, `deepseek-chat` и `deepseek/deepseek-chat`. +Оба варианта принимаются API и каталогом Harness. Префикс должен соответствовать +провайдеру; он не включает отключённую модель. Алиасы DeepSeek используют одну +привязку истории и один учёт продолжения: смена написания не создаёт новый чат +и не сбрасывает защиту от повторов. Проверено офлайн; это не проверка доступа +к моделям аккаунта. + **MIT · неофициальный проект.** Основан на [Tsuev/opencode-deepseek](https://github.com/Tsuev/opencode-deepseek) и [sums001/Deepseek-API](https://github.com/sums001/Deepseek-API).