diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 27b0458..065220d 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -363,6 +363,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: bool(state.get("claude_models")) or bool(state.get("codex_models")) or bool(state.get("gemini_models")) + or bool(state.get("oss_models")) ) return False @@ -373,7 +374,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool: "codex": ("codex",), "gemini": ("gemini",), "copilot": ("claude", "codex"), - "pi": ("claude", "codex", "gemini"), + "pi": ("claude", "codex", "gemini", "oss"), } diff --git a/src/ucode/agents/pi.py b/src/ucode/agents/pi.py index e7c1760..7f78293 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -1,12 +1,13 @@ """Pi coding agent: writes ~/.pi/agent/models.json with Databricks-backed providers. -Pi (https://pi.dev) is a multi-provider coding agent. We register three +Pi (https://pi.dev) is a multi-provider coding agent. We register four providers in its `models.json`, each speaking the API dialect best suited to that family's gateway path: - `databricks-claude` (api: anthropic-messages) → /ai-gateway/anthropic - `databricks-openai` (api: openai-responses) → /ai-gateway/codex/v1 - `databricks-gemini` (api: google-generative-ai) → /ai-gateway/gemini/v1beta +- `databricks-mlflow` (api: openai-completions) → /ai-gateway/mlflow/v1 Per-provider `compat` flags work around fields the gateway translators reject: @@ -15,11 +16,17 @@ pi uses for every request. With this flag pi omits the per-tool field and sends the legacy `anthropic-beta: fine-grained-tool-streaming-...` header instead, which the gateway accepts. - -OSS / Databricks-foundation models (Llama, Qwen, etc.) are not exposed via -pi today — they live behind /ai-gateway/mlflow/v1 with per-model -`max_tokens` caps that pi has no global way to honor without per-model -config we don't currently maintain. +- mlflow: `supportsStore: false` and `supportsStrictMode: false` — the MLflow + chat-completions gateway rejects OpenAI's `store` field and + `tools[].function.strict`. + +The `databricks-mlflow` provider carries the validated OSS coding models +(GLM and Kimi) discovered upstream. Per model it sets +`contextWindow`/`maxTokens` from `databricks.model_token_limits` and +`reasoning` from `databricks.model_is_reasoning` (so Pi renders the gateway's +streamed reasoning_content as thinking). Inkling is intentionally not offered +until the gateway emits a terminal `finish_reason` on natural completion +(issue #215). The bearer token is baked into the file and refreshed by a background thread while the session runs (same pattern as OpenCode/Copilot). @@ -45,6 +52,8 @@ TOKEN_REFRESH_INTERVAL_SECONDS, build_pi_base_urls, get_databricks_token, + model_is_reasoning, + model_token_limits, ) from ucode.state import mark_tool_managed, save_state from ucode.telemetry import agent_version, ucode_version @@ -68,6 +77,7 @@ "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-mlflow", ) PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES] @@ -86,6 +96,7 @@ def _resolve_model_selector( claude_models: dict[str, str], codex_models: list[str], gemini_models: list[str], + oss_models: list[str], ) -> str: """Return a Pi model selector in `/` form when possible.""" for name in PROVIDER_NAMES: @@ -97,9 +108,29 @@ def _resolve_model_selector( return f"databricks-openai/{model}" if model in gemini_models: return f"databricks-gemini/{model}" + if model in oss_models: + return f"databricks-mlflow/{model}" return model +def _pi_oss_model_entry(model_id: str) -> dict: + """Build a Pi mlflow model entry enriched from the shared limits/reasoning + tables: `reasoning:true` for reasoning models (Pi renders their streamed + reasoning_content as thinking), and `contextWindow`/`maxTokens` from + `model_token_limits`. Fields are omitted when unknown so Pi keeps its + default.""" + entry: dict = {"id": model_id} + if model_is_reasoning(model_id): + entry["reasoning"] = True + limits = model_token_limits(model_id) + if limits: + if limits.get("context"): + entry["contextWindow"] = limits["context"] + if limits.get("output"): + entry["maxTokens"] = limits["output"] + return entry + + def render_overlay( model: str, token: str, @@ -107,6 +138,7 @@ def render_overlay( claude_models: dict[str, str], codex_models: list[str], gemini_models: list[str], + oss_models: list[str], ) -> tuple[dict, list[list[str]]]: """Return (overlay, managed_key_paths) for ~/.pi/agent/models.json.""" providers: dict = {} @@ -150,8 +182,23 @@ def render_overlay( "models": [{"id": m} for m in gemini_models], } keys.append(["providers", "databricks-gemini"]) + if oss_models: + providers["databricks-mlflow"] = { + "baseUrl": pi_base_urls["oss"], + "api": "openai-completions", + "apiKey": token, + "authHeader": True, + # MLflow chat-completions gateway rejects OpenAI's `store` field + # and per-tool `strict`. Pi omits both when these are false. + "compat": {"supportsStore": False, "supportsStrictMode": False}, + "headers": ua_headers, + "models": [_pi_oss_model_entry(m) for m in oss_models], + } + keys.append(["providers", "databricks-mlflow"]) overlay: dict = { - "model": _resolve_model_selector(model, claude_models, codex_models, gemini_models), + "model": _resolve_model_selector( + model, claude_models, codex_models, gemini_models, oss_models + ), } if providers: overlay["providers"] = providers @@ -178,6 +225,7 @@ def write_tool_config( state.get("claude_models") or {}, state.get("codex_models") or [], state.get("gemini_models") or [], + state.get("oss_models") or [], ) existing = read_json_safe(PI_CONFIG_PATH) providers = existing.get("providers") @@ -206,7 +254,7 @@ def _write_settings(model_selector: str) -> None: def default_model(state: dict) -> str | None: - """Prefer Claude opus → sonnet → haiku; fall back to codex, gemini.""" + """Prefer Claude opus → sonnet → haiku; fall back to codex, Gemini, then OSS.""" claude_models = state.get("claude_models") or {} for family in ("opus", "sonnet", "haiku"): if claude_models.get(family): @@ -215,7 +263,10 @@ def default_model(state: dict) -> str | None: if codex_models: return codex_models[0] gemini_models = state.get("gemini_models") or [] - return gemini_models[0] if gemini_models else None + if gemini_models: + return gemini_models[0] + oss_models = state.get("oss_models") or [] + return oss_models[0] if oss_models else None def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str: diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 1b42b4b..6037d4c 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -97,7 +97,7 @@ "claude": ("claude", "opencode", "copilot", "pi"), "codex": ("codex", "copilot", "pi"), "gemini": ("gemini", "opencode", "pi"), - "oss": ("opencode",), + "oss": ("opencode", "pi"), } @@ -371,7 +371,7 @@ def configure_shared_state( ) want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools - want_oss = fetch_all or "opencode" in tools + want_oss = fetch_all or "opencode" in tools or "pi" in tools claude_reason: str | None = None gemini_reason: str | None = None diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 1d32f31..58e3621 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -1194,10 +1194,24 @@ def build_auth_shell_command( # Databricks-managed foundation models under `system.ai`. _MODEL_SERVICE_REQUIRED_PREFIX = "system.ai." -# Supported OSS chat families, matched by name substring. Add an entry to -# support a new family. +# OSS families validated as coding models in ucode, matched by name substring. +# Keep this as an explicit product allowlist rather than exposing every model on +# the chat-completions route. Inkling remains excluded until gateway issue #215 +# is fixed; other families require coding-harness validation before inclusion. _OSS_MODEL_FAMILIES = ("kimi-", "glm-") +# Non-chat services must never be offered to a chat agent if a future supported +# family also uses one of these substrings. +_OSS_NON_CHAT_SUBSTRINGS = ("embedding", "embed", "rerank") + + +def _is_oss_chat_model(model_id: str) -> bool: + """True if the id matches an OSS chat family and isn't a non-chat service.""" + if any(bad in model_id for bad in _OSS_NON_CHAT_SUBSTRINGS): + return False + return any(family in model_id for family in _OSS_MODEL_FAMILIES) + + # Claude model families ucode buckets, newest tier first. Each maps to a # Claude Code family alias (ANTHROPIC_DEFAULT__MODEL). Add an entry to # support a new family in both discovery paths (`claude--*` via the @@ -1211,21 +1225,46 @@ def build_auth_shell_command( # config dialect. Both fields are provided because agents like OpenCode require # context and output together. Keyed by family substring; add an entry to bound # a new model. +# +# Output caps probed from the gateway 2026-07-16 (it 400s with "max_tokens (N) +# cannot exceed "); context windows from each model's docs/description +# (conservative when unstated). If the gateway raises a cap or ships a new +# model, update this table. _MODEL_TOKEN_LIMITS: dict[str, dict[str, int]] = { - # GLM-4.6: 200k context, but the gateway caps output well below the model's - # native 128k — pin 25k so requests aren't rejected. + # Keep the version-specific entry before the family fallback: GLM 5.2 has + # materially higher probed gateway limits than earlier/unknown variants. + "glm-5-2": {"context": 1_000_000, "output": 65_536}, "glm": {"context": 200_000, "output": 25_000}, + "kimi": {"context": 128_000, "output": 65_536}, } +# Conservative fallback for a future variant that matches a validated family +# but has no specific entry. Pinning a low output ceiling risks truncation, not +# a gateway 400, so it is the safe failure direction. +_OSS_FALLBACK_LIMITS = {"context": 128_000, "output": 8_192} + +# Validated families that emit reasoning. Pi renders their streamed +# reasoning_content as thinking when the model entry sets reasoning:true. +_OSS_REASONING_FAMILIES = ("glm", "kimi") + + +def model_is_reasoning(model_id: str) -> bool: + """True if the OSS model reports reasoning output (family-matched).""" + return any(family in model_id for family in _OSS_REASONING_FAMILIES) + def model_token_limits(model_id: str) -> dict[str, int] | None: """Return ``{"context": ..., "output": ...}`` limits for ``model_id``, or None. - Matches by family substring (e.g. any ``*glm*`` id). None means the model - has no known limits and the agent should not pin any.""" + Prefers a specific `_MODEL_TOKEN_LIMITS` family entry (e.g. any ``*glm*`` + id). Any other OSS chat model falls back to a conservative floor so it is + never offered uncapped (which would 400). None only for non-OSS ids, where + the agent should not pin any limit.""" for family, limits in _MODEL_TOKEN_LIMITS.items(): if family in model_id: return dict(limits) + if _is_oss_chat_model(model_id): + return dict(_OSS_FALLBACK_LIMITS) return None @@ -1354,10 +1393,13 @@ def discover_model_services( if candidates: claude_models[family] = candidates[0] - codex_models = [m for m in ids if "gpt-" in m] + # `gpt-oss-*` also contains "gpt-" but is a chat-completions-only OSS model + # (served via /ai-gateway/mlflow/v1), NOT an openai-responses codex model — + # exclude it here so it isn't offered under the codex provider (which 400s). + codex_models = [m for m in ids if "gpt-" in m and "gpt-oss" not in m] gemini_models = sorted([m for m in ids if "gemini-" in m], key=model_version_sort_key) - oss_models = [m for m in ids if any(family in m for family in _OSS_MODEL_FAMILIES)] + oss_models = [m for m in ids if _is_oss_chat_model(m)] if not (claude_models or codex_models or gemini_models or oss_models): sample = ", ".join(ids[:5]) @@ -2391,6 +2433,7 @@ def build_pi_base_urls(workspace: str) -> dict[str, str]: "claude": build_tool_base_url("claude", workspace), "openai": build_tool_base_url("codex", workspace), "gemini": build_tool_base_url("gemini", workspace) + "/v1beta", + "oss": f"{workspace}/ai-gateway/mlflow/v1", } diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index 71ca2bc..f63d078 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -98,15 +98,24 @@ def test_glm_gets_token_limits(self): overlay, _ = opencode.render_overlay("system.ai.glm-5-2", "tok", _base_urls(), models) glm = overlay["provider"]["databricks-oss"]["models"]["system.ai.glm-5-2"] # OpenCode's schema requires both context and output on `limit`. - assert glm["limit"] == {"context": 200000, "output": 25000} + # Probed 2026-07-16: glm-5-2 is 1M context / 65536 output. + assert glm["limit"] == {"context": 1_000_000, "output": 65_536} - def test_non_glm_oss_model_has_no_output_cap(self): + def test_kimi_gets_token_limits(self): + # kimi is now a capped OSS family (128k context / 65536 output). models = {"oss": ["system.ai.kimi-k2-7-code"]} overlay, _ = opencode.render_overlay( "system.ai.kimi-k2-7-code", "tok", _base_urls(), models ) kimi = overlay["provider"]["databricks-oss"]["models"]["system.ai.kimi-k2-7-code"] - assert "limit" not in kimi + assert kimi["limit"] == {"context": 128_000, "output": 65_536} + + def test_uncapped_oss_model_has_no_limit(self): + # A model outside the limits table gets no `limit` (client default). + models = {"oss": ["system.ai.mystery-7b"]} + overlay, _ = opencode.render_overlay("system.ai.mystery-7b", "tok", _base_urls(), models) + entry = overlay["provider"]["databricks-oss"]["models"]["system.ai.mystery-7b"] + assert "limit" not in entry def test_token_in_api_key(self): models = {"anthropic": ["claude-sonnet"]} diff --git a/tests/test_agent_pi.py b/tests/test_agent_pi.py index 0afc5fb..4c5444e 100644 --- a/tests/test_agent_pi.py +++ b/tests/test_agent_pi.py @@ -17,6 +17,7 @@ def _base_urls() -> dict[str, str]: "claude": f"{WS}/ai-gateway/anthropic", "openai": f"{WS}/ai-gateway/codex/v1", "gemini": f"{WS}/ai-gateway/gemini/v1beta", + "oss": f"{WS}/ai-gateway/mlflow/v1", } @@ -26,6 +27,7 @@ def _empty() -> dict: "claude_models": {}, "codex_models": [], "gemini_models": [], + "oss_models": [], } @@ -39,6 +41,7 @@ def _overlay(model: str, token: str = "tok", **kwargs): bundle["claude_models"], bundle["codex_models"], bundle["gemini_models"], + bundle["oss_models"], ) @@ -81,17 +84,57 @@ def test_gemini_provider_uses_google_generative_ai(self): assert provider["api"] == "google-generative-ai" assert provider["baseUrl"] == f"{WS}/ai-gateway/gemini/v1beta" - def test_all_three_providers_when_all_present(self): + def test_mlflow_provider_uses_openai_completions(self): + overlay, _ = _overlay("system.ai.glm-5-2", oss_models=["system.ai.glm-5-2"]) + provider = overlay["providers"]["databricks-mlflow"] + assert provider["api"] == "openai-completions" + assert provider["baseUrl"] == f"{WS}/ai-gateway/mlflow/v1" + assert provider["compat"] == {"supportsStore": False, "supportsStrictMode": False} + + def test_no_mlflow_provider_when_no_oss_models(self): + overlay, _ = _overlay("gpt-5", codex_models=["gpt-5"]) + assert "databricks-mlflow" not in overlay.get("providers", {}) + + def test_all_four_providers_when_all_present(self): overlay, _ = _overlay( "claude-sonnet", claude_models={"sonnet": "claude-sonnet"}, codex_models=["gpt-5"], gemini_models=["gemini-2"], + oss_models=["system.ai.glm-5-2"], ) assert set(overlay["providers"].keys()) == { "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-mlflow", + } + + +class TestRenderOverlayOssEnrichment: + """OSS mlflow model entries carry reasoning + contextWindow + maxTokens + from the shared databricks.model_token_limits / model_is_reasoning tables.""" + + def test_reasoning_model_enriched(self): + overlay, _ = _overlay("system.ai.glm-5-2", oss_models=["system.ai.glm-5-2"]) + entry = overlay["providers"]["databricks-mlflow"]["models"][0] + assert entry["id"] == "system.ai.glm-5-2" + assert entry["reasoning"] is True + assert entry["contextWindow"] == 1_000_000 + assert entry["maxTokens"] == 65_536 + + def test_unvalidated_model_has_no_inferred_metadata(self): + # Discovery does not offer this model; even if supplied directly, Pi + # must not infer capabilities for an unvalidated coding model. + overlay, _ = _overlay("system.ai.inkling", oss_models=["system.ai.inkling"]) + entry = overlay["providers"]["databricks-mlflow"]["models"][0] + assert entry == {"id": "system.ai.inkling"} + + def test_unknown_oss_model_bare(self): + # No limits/reasoning table entry -> only id, client keeps defaults. + overlay, _ = _overlay("system.ai.mystery-7b", oss_models=["system.ai.mystery-7b"]) + assert overlay["providers"]["databricks-mlflow"]["models"][0] == { + "id": "system.ai.mystery-7b" } @@ -193,6 +236,10 @@ def test_prefixes_gemini_model(self): overlay, _ = _overlay("gemini-2", gemini_models=["gemini-2"]) assert overlay["model"] == "databricks-gemini/gemini-2" + def test_prefixes_oss_model(self): + overlay, _ = _overlay("system.ai.glm-5-2", oss_models=["system.ai.glm-5-2"]) + assert overlay["model"] == "databricks-mlflow/system.ai.glm-5-2" + def test_preserves_already_prefixed_model(self): overlay, _ = _overlay( "databricks-claude/claude-sonnet", @@ -228,6 +275,15 @@ def test_falls_back_to_gemini(self): state = {"claude_models": {}, "codex_models": [], "gemini_models": ["gemini-2"]} assert pi.default_model(state) == "gemini-2" + def test_falls_back_to_oss_last(self): + state = { + "claude_models": {}, + "codex_models": [], + "gemini_models": [], + "oss_models": ["system.ai.glm-5-2"], + } + assert pi.default_model(state) == "system.ai.glm-5-2" + def test_returns_none_when_empty(self): assert pi.default_model({}) is None assert ( @@ -296,6 +352,7 @@ def test_stale_managed_providers_removed_before_merge(self, tmp_path, monkeypatc "databricks-claude": {"old": True}, "databricks-openai": {"old": True}, "databricks-gemini": {"old": True}, + "databricks-mlflow": {"old": True}, "user-provider": {"keep": True}, } } @@ -311,6 +368,7 @@ def test_stale_managed_providers_removed_before_merge(self, tmp_path, monkeypatc providers = written.get("providers", {}) assert providers.get("databricks-claude") != {"old": True} assert "old" not in providers.get("databricks-claude", {}) + assert "databricks-mlflow" not in providers assert providers.get("user-provider") == {"keep": True} def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch): diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 97acf30..91c3202 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -189,6 +189,14 @@ def test_pi_available_with_codex(self): def test_pi_available_with_gemini(self): assert check_gateway_endpoint({"gemini_models": ["gemini-2"]}, "pi") is True + def test_pi_available_with_oss(self): + assert check_gateway_endpoint({"oss_models": ["system.ai.glm-5-2"]}, "pi") is True + + def test_pi_oss_discovery_reason_is_reported(self): + state = {"_discovery_reasons": {"oss": "no validated OSS models"}} + detail = agents_mod._availability_failure_detail("pi", state) + assert detail == " (oss discovery: no validated OSS models)" + def test_pi_unavailable_when_no_models(self): assert check_gateway_endpoint({}, "pi") is False @@ -243,6 +251,15 @@ def test_pi_falls_back_to_gemini(self): state = {"claude_models": {}, "codex_models": [], "gemini_models": ["gemini-2"]} assert default_model_for_tool("pi", state) == "gemini-2" + def test_pi_falls_back_to_oss(self): + state = { + "claude_models": {}, + "codex_models": [], + "gemini_models": [], + "oss_models": ["system.ai.glm-5-2"], + } + assert default_model_for_tool("pi", state) == "system.ai.glm-5-2" + def test_pi_returns_none_when_no_models(self): assert default_model_for_tool("pi", {}) is None diff --git a/tests/test_cli.py b/tests/test_cli.py index 83ed6df..2c600d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,6 +27,17 @@ def _strip_ansi(text: str) -> str: TOOLS = ["codex", "claude", "gemini", "opencode"] +def test_oss_discovery_diagnostic_names_all_consumers(monkeypatch): + import ucode.cli as cli_mod + + notes = [] + monkeypatch.setattr(cli_mod, "print_note", notes.append) + + cli_mod._print_discovery_diagnostics({"_discovery_reasons": {"oss": "not found"}}) + + assert notes[0] == "OSS models (needed for: opencode, pi): not found" + + @pytest.fixture(autouse=True) def no_state_writes(): """Prevent any test from writing to the real state file on disk.""" diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 7e1a73a..86427ef 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -173,19 +173,52 @@ def _model_service(model_id: str) -> dict: class TestModelTokenLimits: def test_glm_is_capped(self): + # Probed 2026-07-16: glm-5-2 accepts 1M context / 65536 output. assert db_mod.model_token_limits("system.ai.glm-5-2") == { - "context": 200_000, - "output": 25_000, + "context": 1_000_000, + "output": 65_536, } - def test_glm_matches_any_version(self): - assert db_mod.model_token_limits("system.ai.glm-4-6-flash") == { + @pytest.mark.parametrize( + "model_id", + ["system.ai.glm-4-6-flash", "system.ai.glm-future"], + ) + def test_other_glm_versions_keep_conservative_limits(self, model_id): + assert db_mod.model_token_limits(model_id) == { "context": 200_000, "output": 25_000, } - def test_uncapped_model_returns_none(self): - assert db_mod.model_token_limits("system.ai.kimi-k2-7-code") is None + def test_kimi_is_capped(self): + assert db_mod.model_token_limits("system.ai.kimi-k2-7-code") == { + "context": 128_000, + "output": 65_536, + } + + def test_unvalidated_families_return_none(self): + for model_id in ( + "system.ai.inkling", + "system.ai.gpt-oss-120b", + "system.ai.llama-4-maverick", + "system.ai.qwen35-122b-a10b", + "system.ai.gemma-3-12b", + "system.ai.deepseek-v3", + ): + assert db_mod.model_token_limits(model_id) is None + + def test_embedding_model_returns_none_not_fallback(self): + assert db_mod.model_token_limits("system.ai.qwen3-embedding-0-6b") is None + + +class TestModelIsReasoning: + def test_reasoning_families(self): + assert db_mod.model_is_reasoning("system.ai.glm-5-2") is True + assert db_mod.model_is_reasoning("system.ai.kimi-k2-7-code") is True + + def test_unvalidated_families_are_not_marked_reasoning(self): + assert db_mod.model_is_reasoning("system.ai.inkling") is False + assert db_mod.model_is_reasoning("system.ai.qwen35-122b-a10b") is False + assert db_mod.model_is_reasoning("system.ai.gpt-oss-120b") is False class TestDiscoverModelServices: @@ -220,19 +253,20 @@ def test_buckets_families_by_name(self, monkeypatch): assert codex == ["system.ai.gpt-5"] # Gemini ordered newest-first via the shared sort key. assert gemini[0] == "system.ai.gemini-3-5-flash" - # kimi and glm are the allowlisted OSS families; llama is not. + # Only coding-harness-validated OSS families are offered. assert oss == ["system.ai.glm-5-2", "system.ai.kimi-k2-7-code"] - def test_oss_allowlist_drops_unsupported_families(self, monkeypatch): - # Only kimi/glm are allowlisted; other families are dropped. + def test_oss_allowlist_drops_unvalidated_families(self, monkeypatch): payload = { "model_services": [ _model_service("system.ai.glm-5-2"), _model_service("system.ai.kimi-k2-7-code"), - _model_service("system.ai.qwen-3-coder"), + _model_service("system.ai.qwen35-122b-a10b"), + _model_service("system.ai.inkling"), + _model_service("system.ai.llama-4-maverick"), + _model_service("system.ai.gemma-3-12b"), _model_service("system.ai.deepseek-v3"), - _model_service("system.ai.gte-large-embed"), - _model_service("system.ai.bge-reranker-v2"), + _model_service("system.ai.qwen3-embedding-0-6b"), ] } monkeypatch.setattr( @@ -245,6 +279,25 @@ def test_oss_allowlist_drops_unsupported_families(self, monkeypatch): assert (claude, codex, gemini) == ({}, [], []) assert oss == ["system.ai.glm-5-2", "system.ai.kimi-k2-7-code"] + def test_gpt_oss_is_neither_selectable_oss_nor_codex(self, monkeypatch): + # Keep the independent codex exclusion: gpt-oss contains "gpt-" but + # cannot use the Responses API, even though it is not an offered model. + payload = { + "model_services": [ + _model_service("system.ai.gpt-5"), + _model_service("system.ai.gpt-oss-120b"), + ] + } + monkeypatch.setattr( + db_mod, "_http_get_json", lambda url, token, timeout=10: (payload, None) + ) + + _, codex, _, oss, _ = db_mod.discover_model_services(WS, "token") + + assert codex == ["system.ai.gpt-5"] + assert "system.ai.gpt-oss-120b" not in oss + assert "system.ai.gpt-oss-120b" not in codex + def test_paginates_via_next_page_token(self, monkeypatch): pages = { None: { @@ -281,7 +334,8 @@ def test_http_failure_returns_reason(self, monkeypatch): assert reason == "HTTP 500 Server Error" def test_no_matching_families_reports_sample(self, monkeypatch): - payload = {"model_services": [_model_service("system.ai.llama-4-maverick")]} + # deepseek is outside every claude/gpt/gemini/oss family bucket. + payload = {"model_services": [_model_service("system.ai.deepseek-v3")]} monkeypatch.setattr( db_mod, "_http_get_json", lambda url, token, timeout=10: (payload, None) ) @@ -289,7 +343,7 @@ def test_no_matching_families_reports_sample(self, monkeypatch): claude, codex, gemini, oss, reason = db_mod.discover_model_services(WS, "token") assert (claude, codex, gemini, oss) == ({}, [], [], []) - assert reason is not None and "llama-4-maverick" in reason + assert reason is not None and "deepseek-v3" in reason def test_ignores_non_system_ai_schemas(self, monkeypatch): # The metastore listing returns services from every schema; only