diff --git a/.env.example b/.env.example index a6478c9..33a9bd9 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ GEMINI_MODELS={} # --- Optional --- # DEEPSEEK_PROFILE_DIR=session/profile # reuse an existing signed-in Chrome profile -# HOST=127.0.0.1 +# HOST=0.0.0.0 # all interfaces; use 127.0.0.1 for loopback-only # PORT=8000 # RATE_LIMIT_PER_MINUTE=30 # per-client-IP limit on /v1 endpoints diff --git a/app.py b/app.py index 8e41e80..7ad594a 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ """Entry point — run the OpenAI-compatible DeepSeek/Qwen server. - python app.py # serves on http://localhost:8000 + python app.py # serves on http://0.0.0.0:8000 (localhost + LAN IP) On first use (no saved session) a browser window opens for you to sign in by hand; run `python -m deepseek.auth` to do that ahead of time. Set @@ -17,7 +17,9 @@ if __name__ == "__main__": uvicorn.run( "server.api:app", - host=os.getenv("HOST", "127.0.0.1"), + # 0.0.0.0: localhost and this machine's LAN IP. Set HOST=127.0.0.1 to + # keep the API loopback-only (recommended on untrusted networks). + host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", "8000")), reload=False, proxy_headers=False, 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/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()