diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 40813892..d6f17a3a 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -481,6 +481,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 @@ -491,7 +492,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 6e30053b..dbe40371 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -1,12 +1,13 @@ """Pi coding agent: writes a ucode-private 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-oss` (api: openai-completions) → /ai-gateway/mlflow/v1 Per-provider `compat` flags work around fields the gateway translators reject: @@ -15,11 +16,10 @@ 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. +- oss: `supportsStore: false` and `supportsStrictMode: false` make Pi omit + fields the MLflow chat-completions gateway rejects. Known per-model token + limits are written into Pi's model entries so requests stay within the + gateway's output caps. Each provider's `apiKey` is pi's `!command` config value rather than a baked bearer, so pi mints one per request via `ug auth-token` and nothing that @@ -48,6 +48,7 @@ build_pi_base_urls, classify_model_family, get_databricks_token, + model_token_limits, ) from ucode.state import mark_tool_managed, save_state from ucode.telemetry import agent_version, ug_version @@ -73,13 +74,18 @@ "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-oss", ) PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES] # Old provider names earlier ucode versions wrote; cleaned up on each write so # users don't end up with stale entries pointing at routes that 400. -LEGACY_PROVIDER_NAMES = ("databricks-anthropic", "databricks-codex", "databricks-oss") +LEGACY_PROVIDER_NAMES = ( + "databricks-anthropic", + "databricks-codex", + "databricks-kimi", +) def _resolve_model_selector( @@ -87,6 +93,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: @@ -98,9 +105,21 @@ 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-oss/{model}" return model +def _oss_model_entry(model: str) -> dict: + """Return a Pi model entry with known MLflow route limits.""" + entry: dict = {"id": model} + limits = model_token_limits(model) + if limits is not None: + entry["contextWindow"] = limits["context"] + entry["maxTokens"] = limits["output"] + return entry + + def render_overlay( model: str, api_key: str, @@ -108,6 +127,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's private agent config. @@ -154,8 +174,21 @@ def render_overlay( "models": [{"id": m} for m in gemini_models], } keys.append(["providers", "databricks-gemini"]) + if oss_models: + providers["databricks-oss"] = { + "baseUrl": pi_base_urls["oss"], + "api": "openai-completions", + "apiKey": api_key, + "authHeader": True, + "compat": {"supportsStore": False, "supportsStrictMode": False}, + "headers": ua_headers, + "models": [_oss_model_entry(m) for m in oss_models], + } + keys.append(["providers", "databricks-oss"]) 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 @@ -189,10 +222,11 @@ def write_tool_config( token = get_databricks_token(state["workspace"], state.get("profile")) pi_base_urls = state.get("base_urls", {}).get("pi") or build_pi_base_urls(state["workspace"]) managed_families = _managed_model_families(state) - claude_models, codex_models, gemini_models = managed_families or ( + claude_models, codex_models, gemini_models, oss_models = managed_families or ( state.get("claude_models") or {}, state.get("codex_models") or [], state.get("gemini_models") or [], + state.get("oss_models") or [], ) overlay, managed_keys = render_overlay( model, @@ -201,6 +235,7 @@ def write_tool_config( claude_models, codex_models, gemini_models, + oss_models, ) existing = read_json_safe(PI_CONFIG_PATH) providers = existing.get("providers") @@ -228,7 +263,9 @@ def _write_settings(model_selector: str) -> None: write_json_file(PI_SETTINGS_PATH, merged) -def _managed_model_families(state: dict) -> tuple[dict[str, str], list[str], list[str]] | None: +def _managed_model_families( + state: dict, +) -> tuple[dict[str, str], list[str], list[str], list[str]] | None: """Split a managed config's ``pi_models`` into the per-family inputs Pi's providers need. Pi builds one provider block per family, so a flat list has to be classified back out. Returns @@ -241,6 +278,7 @@ def _managed_model_families(state: dict) -> tuple[dict[str, str], list[str], lis claude: dict[str, str] = {} codex: list[str] = [] gemini: list[str] = [] + oss: list[str] = [] for model in managed: if not isinstance(model, str) or not model.strip(): continue @@ -251,13 +289,15 @@ def _managed_model_families(state: dict) -> tuple[dict[str, str], list[str], lis codex.append(model) elif family == "gemini": gemini.append(model) - if not (claude or codex or gemini): + elif family == "oss": + oss.append(model) + if not (claude or codex or gemini or oss): return None - return claude, codex, gemini + return claude, codex, gemini, oss 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, OSS. A managed config's ``pi_default_model`` and ``pi_models`` both win outright: the former is the admin's chosen session start, the latter their allowlist. Workspace-wide discovery falls back. @@ -275,7 +315,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 next(iter(claude_models.values()), None) + if gemini_models: + return gemini_models[0] + oss_models = state.get("oss_models") or [] + return oss_models[0] if oss_models else next(iter(claude_models.values()), None) def _configure_launch(state: dict) -> str: diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 2e1addb2..506b6001 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -151,7 +151,7 @@ "claude": ("claude", "opencode", "copilot", "pi"), "codex": ("codex", "copilot", "pi"), "gemini": ("gemini", "opencode", "pi"), - "oss": ("opencode",), + "oss": ("opencode", "pi"), } @@ -554,7 +554,7 @@ def configure_shared_state( want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools # Codex smart routing can select OSS models such as GLM, so a Codex-only # configure must persist that discovered family too. - want_oss = fetch_all or "opencode" in tools or "codex" in tools + want_oss = fetch_all or "opencode" in tools or "codex" 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 10be7bf8..5745bcb3 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -3128,6 +3128,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/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index 81f1d029..7f8c3758 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -103,9 +103,9 @@ def managed_unservable_models(managed: dict, tool: str) -> list[str]: """The models the manifest names for ``tool`` when it has no provider to serve any of them. Only non-empty when *every* named model is unservable, which is when the translation yields - nothing and the developer's own models stand — so the caller can say why the admin's list had no - effect. opencode has no OpenAI provider and pi has no OSS provider, so each can be handed a - valid model FQN it cannot route. + nothing and the developer's own models stand, so the caller can say why the admin's list had no + effect. OpenCode has no OpenAI provider, and either tool can be handed an unrecognized model FQN + it cannot route. """ if tool not in ("opencode", "pi"): return [] @@ -118,7 +118,7 @@ def managed_unservable_models(managed: dict, tool: str) -> list[str]: else [ m for m in models - if classify_model_family(m) in (*ANTHROPIC_FAMILIES, "codex", "gemini") + if classify_model_family(m) in (*ANTHROPIC_FAMILIES, "codex", "gemini", "oss") ] ) return [] if servable else models diff --git a/tests/test_agent_pi.py b/tests/test_agent_pi.py index a6dc7e76..75bf65dd 100644 --- a/tests/test_agent_pi.py +++ b/tests/test_agent_pi.py @@ -16,6 +16,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", } @@ -25,6 +26,7 @@ def _empty() -> dict: "claude_models": {}, "codex_models": [], "gemini_models": [], + "oss_models": [], } @@ -38,6 +40,7 @@ def _overlay(model: str, token: str = "tok", **kwargs): bundle["claude_models"], bundle["codex_models"], bundle["gemini_models"], + bundle["oss_models"], ) @@ -80,17 +83,28 @@ 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_oss_provider_uses_openai_completions(self): + overlay, _ = _overlay( + "system.ai.kimi-k2-7-code", + oss_models=["system.ai.kimi-k2-7-code"], + ) + provider = overlay["providers"]["databricks-oss"] + assert provider["api"] == "openai-completions" + assert provider["baseUrl"] == f"{WS}/ai-gateway/mlflow/v1" + + 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.kimi-k2-7-code"], ) assert set(overlay["providers"].keys()) == { "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-oss", } @@ -128,6 +142,16 @@ def test_openai_and_gemini_have_no_compat_flags(self): assert "compat" not in overlay["providers"]["databricks-openai"] assert "compat" not in overlay["providers"]["databricks-gemini"] + def test_oss_disables_store_and_strict_mode(self): + overlay, _ = _overlay( + "system.ai.kimi-k2-7-code", + oss_models=["system.ai.kimi-k2-7-code"], + ) + assert overlay["providers"]["databricks-oss"]["compat"] == { + "supportsStore": False, + "supportsStrictMode": False, + } + class TestRenderOverlayAuthAndModels: def test_api_key_config_value_embedded_verbatim(self): @@ -143,8 +167,14 @@ def test_auth_header_flag_set_on_all_providers(self): claude_models={"sonnet": "claude-sonnet"}, codex_models=["gpt-5"], gemini_models=["gemini-2"], + oss_models=["system.ai.kimi-k2-7-code"], ) - for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + for name in ( + "databricks-claude", + "databricks-openai", + "databricks-gemini", + "databricks-oss", + ): assert overlay["providers"][name]["authHeader"] is True def test_claude_models_listed(self): @@ -163,6 +193,18 @@ def test_gemini_models_listed(self): ids = {m["id"] for m in overlay["providers"]["databricks-gemini"]["models"]} assert ids == {"gemini-2", "gemini-2-pro"} + def test_oss_models_listed_with_known_limits(self): + models = ["system.ai.kimi-k2-7-code", "system.ai.glm-5-2"] + overlay, _ = _overlay(models[0], oss_models=models) + entries = {m["id"]: m for m in overlay["providers"]["databricks-oss"]["models"]} + assert set(entries) == set(models) + assert entries["system.ai.glm-5-2"] == { + "id": "system.ai.glm-5-2", + "contextWindow": 200_000, + "maxTokens": 25_000, + } + assert entries["system.ai.kimi-k2-7-code"] == {"id": "system.ai.kimi-k2-7-code"} + class TestRenderOverlayManagedKeys: def test_managed_keys_include_model(self): @@ -175,8 +217,14 @@ def test_managed_keys_include_each_provider_present(self): claude_models={"sonnet": "claude-sonnet"}, codex_models=["gpt-5"], gemini_models=["gemini-2"], + oss_models=["system.ai.kimi-k2-7-code"], ) - for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + for name in ( + "databricks-claude", + "databricks-openai", + "databricks-gemini", + "databricks-oss", + ): assert ["providers", name] in keys @@ -193,6 +241,11 @@ 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): + model = "system.ai.kimi-k2-7-code" + overlay, _ = _overlay(model, oss_models=[model]) + assert overlay["model"] == f"databricks-oss/{model}" + def test_preserves_already_prefixed_model(self): overlay, _ = _overlay( "databricks-claude/claude-sonnet", @@ -228,10 +281,22 @@ 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(self): + state = { + "claude_models": {}, + "codex_models": [], + "gemini_models": [], + "oss_models": ["system.ai.kimi-k2-7-code"], + } + assert pi.default_model(state) == "system.ai.kimi-k2-7-code" + def test_returns_none_when_empty(self): assert pi.default_model({}) is None assert ( - pi.default_model({"claude_models": {}, "codex_models": [], "gemini_models": []}) is None + pi.default_model( + {"claude_models": {}, "codex_models": [], "gemini_models": [], "oss_models": []} + ) + is None ) @@ -272,6 +337,7 @@ def _state(self, **overrides) -> dict: "claude_models": {"sonnet": "claude-sonnet"}, "codex_models": [], "gemini_models": [], + "oss_models": [], "managed_configs": {}, } state.update(overrides) @@ -285,6 +351,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-oss": {"old": True}, "user-provider": {"keep": True}, } } @@ -300,12 +367,11 @@ 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-oss" not in providers assert providers.get("user-provider") == {"keep": True} def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch): - """Earlier ucode versions wrote `databricks-anthropic`, `databricks-codex`, - and `databricks-oss` providers. They must be stripped on the next write - so users don't end up with stale entries pointing at routes that 400.""" + """Old provider names are stripped on the next write.""" pi_mod, config_file, _, _ = self._setup(tmp_path, monkeypatch) config_file.write_text( @@ -314,7 +380,7 @@ def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch): "providers": { "databricks-anthropic": {"api": "anthropic-messages"}, "databricks-codex": {"api": "openai-responses"}, - "databricks-oss": {"api": "openai-completions"}, + "databricks-kimi": {"api": "openai-responses"}, } } ), @@ -328,7 +394,7 @@ def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch): pi_mod.write_tool_config(self._state(), "claude-sonnet", token="tok") written_providers = json.loads(config_file.read_text()).get("providers", {}) - for legacy in ("databricks-anthropic", "databricks-codex", "databricks-oss"): + for legacy in ("databricks-anthropic", "databricks-codex", "databricks-kimi"): assert legacy not in written_providers assert "databricks-claude" in written_providers @@ -362,7 +428,11 @@ def test_bearer_never_written_to_the_config(self, tmp_path, monkeypatch): def test_every_provider_gets_the_auth_command(self, tmp_path, monkeypatch): pi_mod, config_file, _, _ = self._setup(tmp_path, monkeypatch) - state = self._state(codex_models=["gpt-5"], gemini_models=["gemini-2"]) + state = self._state( + codex_models=["gpt-5"], + gemini_models=["gemini-2"], + oss_models=["system.ai.kimi-k2-7-code"], + ) with ( patch("ucode.agents.pi.get_databricks_token", return_value="tok"), @@ -371,7 +441,12 @@ def test_every_provider_gets_the_auth_command(self, tmp_path, monkeypatch): pi_mod.write_tool_config(state, "claude-sonnet", token="tok") providers = json.loads(config_file.read_text())["providers"] - for name in ("databricks-claude", "databricks-openai", "databricks-gemini"): + for name in ( + "databricks-claude", + "databricks-openai", + "databricks-gemini", + "databricks-oss", + ): assert providers[name]["apiKey"].startswith("!"), name def test_settings_pins_default_provider_and_model(self, tmp_path, monkeypatch): @@ -436,21 +511,30 @@ def test_managed_models_split_into_pis_per_provider_inputs(self): {"opus": "system.ai.claude-opus-4-8"}, ["system.ai.gpt-5"], ["system.ai.gemini-3-flash"], + [], ) def test_no_split_without_managed_models(self): assert pi._managed_model_families({"claude_models": {"opus": "x"}}) is None - def test_none_when_no_managed_model_is_servable(self): - # Pi has no OSS provider, so an oss-only list yields no families. Returning an all-empty - # tuple would be truthy and suppress the fallback, writing a config with zero providers. - assert pi._managed_model_families({"pi_models": ["system.ai.kimi-k2-7-code"]}) is None + def test_oss_only_allowlist_is_servable(self): + assert pi._managed_model_families({"pi_models": ["system.ai.kimi-k2-7-code"]}) == ( + {}, + [], + [], + ["system.ai.kimi-k2-7-code"], + ) def test_partially_servable_list_still_splits(self): families = pi._managed_model_families( {"pi_models": ["system.ai.kimi-k2-7-code", "system.ai.claude-opus-4-8"]} ) - assert families == ({"opus": "system.ai.claude-opus-4-8"}, [], []) + assert families == ( + {"opus": "system.ai.claude-opus-4-8"}, + [], + [], + ["system.ai.kimi-k2-7-code"], + ) class TestManagedDefaultModel: diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 8c85d429..f069a0bb 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -246,6 +246,9 @@ 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_unavailable_when_no_models(self): assert check_gateway_endpoint({}, "pi") is False diff --git a/tests/test_cli.py b/tests/test_cli.py index 03d88ece..9ed323f4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3332,6 +3332,31 @@ def test_codex_only_configure_persists_discovered_oss_models(self, monkeypatch): assert state["codex_models"] == ["system.ai.gpt-5-6-sol"] assert state["oss_models"] == ["system.ai.glm-5-2"] + def test_pi_only_configure_persists_discovered_oss_models(self, monkeypatch): + cli_mod, *_ = self._stub_deps(monkeypatch, pat_token="dapi-pat") + monkeypatch.setattr( + cli_mod, + "discover_model_services", + lambda w, t: ( + {}, + [], + [], + ["system.ai.kimi-k2-7-code", "system.ai.glm-5-2"], + None, + ), + ) + + state = cli_mod.configure_shared_state( + self.WS, + profile="DEFAULT", + tools=["pi"], + ) + + assert state["oss_models"] == [ + "system.ai.kimi-k2-7-code", + "system.ai.glm-5-2", + ] + def _stub_with_fable(self, monkeypatch): cli_mod, *_ = self._stub_deps(monkeypatch, pat_token="dapi-pat") monkeypatch.setattr( diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 3e286b3e..15b370bd 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -27,6 +27,7 @@ build_otel_headers_argv, build_otel_headers_shell_command, build_otel_traces_endpoint, + build_pi_base_urls, build_shared_base_urls, build_skills_mcp_url, build_tool_base_url, @@ -246,6 +247,15 @@ def test_returns_anthropic_gemini_and_oss(self): assert urls["oss"] == f"{WS}/ai-gateway/mlflow/v1" +class TestBuildPiBaseUrls: + def test_returns_all_supported_model_families(self): + urls = build_pi_base_urls(WS) + assert urls["claude"] == f"{WS}/ai-gateway/anthropic" + assert urls["openai"] == f"{WS}/ai-gateway/codex/v1" + assert urls["gemini"] == f"{WS}/ai-gateway/gemini/v1beta" + assert urls["oss"] == f"{WS}/ai-gateway/mlflow/v1" + + class TestBuildSharedBaseUrls: def test_contains_all_tools(self): urls = build_shared_base_urls(WS) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 10e61214..2dbff058 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1221,18 +1221,32 @@ def _all_models(self, e2e_state: dict) -> list[tuple[str, str]]: out.append(("codex", model)) for model in e2e_state.get("gemini_models") or []: out.append(("gemini", model)) + for model in e2e_state.get("oss_models") or []: + if not _model_is_skipped(model, "pi"): + out.append(("oss", model)) return out def test_astra_is_skipped(self): state = {"codex_models": ["databricks-gpt-6-astra", "databricks-gpt-5-4"]} assert self._all_models(state) == [("codex", "databricks-gpt-5-4")] + def test_oss_models_are_included(self): + state = {"oss_models": ["system.ai.kimi-k3", "system.ai.glm-5-3"]} + assert self._all_models(state) == [ + ("oss", "system.ai.kimi-k3"), + ("oss", "system.ai.glm-5-3"), + ] + def test_launch_pi_per_model(self, tmp_path, monkeypatch, e2e_state, e2e_workspace, e2e_token): import ucode.config_io as config_io_mod from ucode.agents import pi _require_binary("pi") - models = self._all_models(e2e_state) + _, _, _, oss_models, oss_reason = discover_model_services(e2e_workspace, e2e_token) + assert oss_reason is None, oss_reason + assert oss_models, "No Pi OSS models were discovered on the E2E workspace" + pi_state = {**e2e_state, "oss_models": oss_models} + models = self._all_models(pi_state) if not models: pytest.skip("No Pi-compatible models available on this workspace") @@ -1262,7 +1276,7 @@ def test_launch_pi_per_model(self, tmp_path, monkeypatch, e2e_state, e2e_workspa lambda ws, profile=None, **kwargs: e2e_token, ) pi.write_tool_config( - {**e2e_state, "workspace": e2e_workspace}, + pi_state, model, token=e2e_token, ) diff --git a/tests/test_e2e_user_agent.py b/tests/test_e2e_user_agent.py index d440d969..6262682d 100644 --- a/tests/test_e2e_user_agent.py +++ b/tests/test_e2e_user_agent.py @@ -314,7 +314,22 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv class TestPiUserAgent: - def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_server): + @pytest.mark.parametrize( + ("model_family", "model", "request_path"), + [ + ("claude", "test-claude-model", "/ai-gateway/anthropic"), + ("oss", "system.ai.kimi-k2-7-code", "/ai-gateway/mlflow/v1/chat/completions"), + ], + ) + def test_user_agent_arrives_at_gateway( + self, + tmp_path, + monkeypatch, + capture_server, + model_family, + model, + request_path, + ): import ucode.config_io as config_io_mod from ucode.agents import pi @@ -333,14 +348,16 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv state = { "workspace": capture_server.base_url, - "claude_models": {"sonnet": "test-claude-model"}, + "claude_models": {"sonnet": model} if model_family == "claude" else {}, "codex_models": [], "gemini_models": [], + "oss_models": [model] if model_family == "oss" else [], "base_urls": { "pi": { "claude": f"{capture_server.base_url}/ai-gateway/anthropic", "openai": f"{capture_server.base_url}/ai-gateway/codex/v1", "gemini": f"{capture_server.base_url}/ai-gateway/gemini/v1beta", + "oss": f"{capture_server.base_url}/ai-gateway/mlflow/v1", }, }, } @@ -350,7 +367,7 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv "ucode.agents.pi.get_databricks_token", lambda ws, profile=None, **kwargs: "test-token", ) - pi.write_tool_config(state, "test-claude-model", token="test-token") + pi.write_tool_config(state, model, token="test-token") env = pi.build_runtime_env("test-token") # Pi now resolves its apiKey by running `ucode auth-token`. The static @@ -359,6 +376,6 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv env["DATABRICKS_BEARER"] = "test-token" result = _run_until_first_request(pi.validate_cmd("pi"), env) - req = capture_server.first_request_with_path_prefix("/ai-gateway/anthropic") + req = capture_server.first_request_with_path_prefix(request_path) assert req is not None, _no_request_msg(capture_server, result) _assert_ua(req, _expected_ua("pi", "pi")) diff --git a/tests/test_managed_resolve.py b/tests/test_managed_resolve.py index c4a5ef13..17cb96ea 100644 --- a/tests/test_managed_resolve.py +++ b/tests/test_managed_resolve.py @@ -557,11 +557,10 @@ class TestManagedUnservableModels: def _managed(tool, models): return {"enabled_agents": {tool: {"model_config": {"models": models}}}} - def test_pi_oss_only_is_unservable(self): - # Pi has no OSS provider block. - assert managed_unservable_models( - self._managed("pi", ["system.ai.kimi-k2-7-code"]), "pi" - ) == ["system.ai.kimi-k2-7-code"] + def test_pi_oss_only_is_servable(self): + assert ( + managed_unservable_models(self._managed("pi", ["system.ai.kimi-k2-7-code"]), "pi") == [] + ) def test_opencode_gpt_only_is_unservable(self): # OpenCode has no OpenAI provider block.