diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 578aa208..edbcd1dc 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -350,6 +350,7 @@ def configure_tool( relayed: bool = False, route_root_model: str | None = None, custom_model: str | None = None, + bedrock_targets: list[str] | None = None, ) -> dict: result: dict | tuple[dict, str] if tool == "codex": @@ -370,16 +371,22 @@ def configure_tool( custom_model=custom_model, ) else: - # provider routing is claude/codex-only; every other tool needs a model. - if not model: + # provider routing is claude/codex-only; every other tool needs a model — + # except pi with a Bedrock provider, where targets replace the model list. + if not model and not (tool == "pi" and provider and bedrock_targets): raise RuntimeError(f"A {tool} model must be selected before configuration.") if tool == "gemini": + assert model is not None result = gemini.write_tool_config(state, model) elif tool == "copilot": + assert model is not None result = copilot.write_tool_config(state, model) elif tool == "pi": - result = pi.write_tool_config(state, model) + result = pi.write_tool_config( + state, model, provider=provider, bedrock_targets=bedrock_targets + ) else: + assert model is not None result = opencode.write_tool_config(state, model) # gemini/opencode/copilot/pi return (state, token); codex/claude return state if isinstance(result, tuple): diff --git a/src/ucode/agents/pi.py b/src/ucode/agents/pi.py index a673a548..22658144 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -46,6 +46,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, ucode_version @@ -69,6 +70,7 @@ "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-bedrock", ) PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES] @@ -97,13 +99,31 @@ def _resolve_model_selector( return model +def _bedrock_model_entry(model_id: str) -> dict: + """A Pi model entry for a Bedrock target, pinning known token limits. + + Some Bedrock models cap output well below what Pi requests by default (e.g. + Nova rejects a `maxTokens` of 10k or more), so pin `maxTokens`/`contextWindow` + when the model has a known limit. Models with no known limit are left unbounded. + """ + entry: dict = {"id": model_id} + limits = model_token_limits(model_id) + if limits is not None: + entry["contextWindow"] = limits["context"] + entry["maxTokens"] = limits["output"] + return entry + + def render_overlay( - model: str, + model: str | None, token: str, pi_base_urls: dict[str, str], claude_models: dict[str, str], codex_models: list[str], gemini_models: list[str], + *, + provider: str | None = None, + bedrock_targets: list[str] | None = None, ) -> tuple[dict, list[list[str]]]: """Return (overlay, managed_key_paths) for Pi's private agent config.""" providers: dict = {} @@ -147,9 +167,30 @@ def render_overlay( "models": [{"id": m} for m in gemini_models], } keys.append(["providers", "databricks-gemini"]) - overlay: dict = { - "model": _resolve_model_selector(model, claude_models, codex_models, gemini_models), - } + if provider and bedrock_targets: + providers["databricks-bedrock"] = { + "baseUrl": pi_base_urls.get( + "bedrock", f"{pi_base_urls['claude'].rsplit('/ai-gateway', 1)[0]}/ai-gateway" + ), + "api": "bedrock-converse-stream", + "apiKey": token, + "authHeader": True, + # Pi's bedrock-converse-stream client (AWS SDK style) sets its own + # User-Agent; adding ours produces two `user-agent` values and the + # gateway rejects the request ("Header field ... must only have a + # single value"). Send only the MPS selector header here. + "headers": {"Databricks-Model-Provider-Service": provider}, + "models": [_bedrock_model_entry(t) for t in bedrock_targets], + } + keys.append(["providers", "databricks-bedrock"]) + resolved = _resolve_model_selector(model or "", claude_models, codex_models, gemini_models) + # Bedrock model IDs contain no `/` (e.g. `anthropic.claude-3-haiku-20240307-v1:0`), so + # _resolve_model_selector returns them unprefixed. _write_settings splits on `/` to get + # provider/model — without the prefix it gets an empty model_id and skips defaultProvider. + # Always force the `databricks-bedrock/` prefix when the Bedrock provider is active. + if "databricks-bedrock" in providers and bedrock_targets: + resolved = f"databricks-bedrock/{bedrock_targets[0]}" + overlay: dict = {"model": resolved} if providers: overlay["providers"] = providers return overlay, keys @@ -157,10 +198,12 @@ def render_overlay( def write_tool_config( state: dict, - model: str, + model: str | None, token: str | None = None, *, force_refresh: bool = False, + provider: str | None = None, + bedrock_targets: list[str] | None = None, ) -> tuple[dict, str]: backup_existing_file(PI_CONFIG_PATH, PI_BACKUP_PATH) if token is None: @@ -181,6 +224,8 @@ def write_tool_config( claude_models, codex_models, gemini_models, + provider=provider, + bedrock_targets=bedrock_targets, ) existing = read_json_safe(PI_CONFIG_PATH) providers = existing.get("providers") @@ -259,6 +304,33 @@ def default_model(state: dict) -> str | None: def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str: + # Preserve a Bedrock provider block across token refreshes. The block is + # self-describing: its MPS header + model ids are enough to re-render it, + # so a refresh keeps routing through Bedrock instead of dropping to a + # system-hosted model. When the config has no Bedrock block (a non-Bedrock + # session, or after a non-Bedrock reconfigure overwrote it), fall through + # to the normal path. + existing = read_json_safe(PI_CONFIG_PATH) + bedrock = (existing.get("providers") or {}).get("databricks-bedrock") + provider: str | None = None + bedrock_targets: list[str] | None = None + if isinstance(bedrock, dict): + headers = bedrock.get("headers") or {} + provider = headers.get("Databricks-Model-Provider-Service") + bedrock_targets = [ + m["id"] + for m in (bedrock.get("models") or []) + if isinstance(m, dict) and isinstance(m.get("id"), str) + ] or None + if provider and bedrock_targets: + _, token = write_tool_config( + state, + bedrock_targets[0], + force_refresh=force_refresh, + provider=provider, + bedrock_targets=bedrock_targets, + ) + return token model = default_model(state) if not model: raise RuntimeError("No Pi model is available on this workspace.") diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 6a1662ae..5c1d44c8 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -51,15 +51,18 @@ find_profile_name_for_host, get_databricks_profiles, get_databricks_token, + get_model_provider_service, install_databricks_cli, is_model_provider_feature_unavailable, is_workspace_admin, + list_model_provider_services, list_profile_entries, list_tool_provider_services, normalize_workspace_url, resolve_pat_token, resolve_provider_launch_model, run_databricks_login, + service_usable_for_tool, ) from ucode.managed_budget import ( budget_usage_percent, @@ -125,6 +128,7 @@ from ucode.ui import ( console, heading, + muted, print_err, print_heading, print_kv, @@ -133,9 +137,11 @@ print_success, print_warning, prompt_for_selection, + prompt_for_text, prompt_for_tools, prompt_for_workspace, prompt_yes_no, + render_box_table, set_verbosity, spinner, status_badge, @@ -1159,6 +1165,10 @@ def revert() -> int: app.add_typer(configure_app, name="configure", help="Configure workspace and tool settings.") mcp_app = typer.Typer(add_completion=False, no_args_is_help=True) app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ucode.") +providers_app = typer.Typer(add_completion=False, no_args_is_help=True) +app.add_typer( + providers_app, name="providers", help="Inspect Model Provider Services on the workspace." +) setup_app = typer.Typer(add_completion=False, no_args_is_help=False) app.add_typer( setup_app, @@ -2042,6 +2052,7 @@ def _launch_tool( # The router's per-launch pick for the root session. Codex pins it as the # resolved model; claude pins it via ANTHROPIC_MODEL (route_root_model). route_root_model = None + bedrock_targets: list[str] | None = None if provider: # Routing through a Model Provider Service pins no Databricks model; # the agent uses its own canonical model names (header selects the @@ -2051,6 +2062,24 @@ def _launch_tool( # Relayed services forward --model to Claude Code's own flag at launch (below), not env. if tool == "claude" and not relayed and (model or provider_models): route_root_model = resolve_provider_launch_model(model, provider_models or {}) + elif tool == "pi": + # Pi receives the MPS targets as its databricks-bedrock model list; + # a single model is also set as the default for the session. + _pi_token = get_databricks_token(state["workspace"], state.get("profile")) + with spinner("Fetching provider model targets..."): + _pi_svc, _ = get_model_provider_service(provider, state["workspace"], _pi_token) + if _pi_svc: + bedrock_targets = _pi_svc.get("targets") or [] + if bedrock_targets: + resolved_model = bedrock_targets[0] + elif _pi_svc.get("allow_all_targets"): + _pi_entered = prompt_for_text( + f"Enter a Bedrock model ID to use with '{provider}'", + required=True, + ) + if _pi_entered: + bedrock_targets = [_pi_entered] + resolved_model = _pi_entered else: # A managed default_model is the model the admin wants sessions to start on, so it goes # in as the explicit model rather than being applied afterwards: for codex the proto has @@ -2087,6 +2116,7 @@ def _launch_tool( # the latter pins a raw id into every family alias, which would clobber the service's # per-family target pins. custom_model=model if (tool == "claude" and not provider) else None, + bedrock_targets=bedrock_targets, ) # Relayed = a Claude subscription: forward --model to Claude Code's own flag, like `-- --model X`. if tool == "claude" and provider and relayed and model and not forwarded_model: @@ -2500,12 +2530,20 @@ def copilot_cmd( @app.command("pi", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def pi_cmd( ctx: typer.Context, + provider: Annotated[ + str | None, + typer.Option( + "--provider", + help="Route through a Unity Catalog Model Provider Service " + "(..). Pass before any `--` separator.", + ), + ] = None, skip_preflight: SkipPreflightOption = False, skip_managed_config: SkipManagedConfigOption = False, ) -> None: """Launch Pi coding agent via Databricks.""" _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("pi", ctx, skip_preflight=skip_preflight) + _launch_tool("pi", ctx, provider=provider, skip_preflight=skip_preflight) @app.command("cursor", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) @@ -3256,6 +3294,88 @@ def upgrade_cmd() -> None: print_success("ucode upgraded") +@providers_app.command("list") +def providers_list_cmd( + tool: Annotated[ + str | None, + typer.Option( + "--tool", help="Filter to services usable by a specific tool (claude, codex)." + ), + ] = None, +) -> None: + """List Model Provider Services on the workspace.""" + state = load_state() + workspace = state.get("workspace") + if not workspace: + print_err("No workspace configured. Run `ucode configure` first.") + raise typer.Exit(1) from None + token = get_databricks_token(workspace, state.get("profile")) + with spinner("Fetching model provider services..."): + services, reason = list_model_provider_services(workspace, token) + if reason is not None: + print_err(f"Could not list model provider services: {reason}") + raise typer.Exit(1) from None + if tool: + services = [s for s in services if service_usable_for_tool(tool, s)] + if not services: + msg = "No model provider services found" + (f" for {tool}" if tool else "") + "." + print_note(msg) + return + rows = [ + [ + s["name"], + s["provider_type"], + ", ".join(s["targets"]) + if s["targets"] + else ("(all)" if s["allow_all_targets"] else "—"), + ] + for s in services + ] + print_section("Model Provider Services") + console.print( + render_box_table(["Service", "Provider", "Targets"], rows, max_widths=[60, 20, 60]) + ) + if tool: + console.print(muted(f" Filtered to services usable by {tool}.")) + + +@providers_app.command("show") +def providers_show_cmd( + service_name: Annotated[ + str, + typer.Argument(help="Fully qualified service name (catalog.schema.service)."), + ], +) -> None: + """Show targets and configuration for a Model Provider Service.""" + state = load_state() + workspace = state.get("workspace") + if not workspace: + print_err("No workspace configured. Run `ucode configure` first.") + raise typer.Exit(1) from None + token = get_databricks_token(workspace, state.get("profile")) + with spinner(f"Fetching {service_name}..."): + service, reason = get_model_provider_service(service_name, workspace, token) + if reason is not None: + print_err(f"Could not fetch '{service_name}': {reason}") + raise typer.Exit(1) from None + if service is None: + print_err(f"Model provider service '{service_name}' not found.") + raise typer.Exit(1) from None + print_section(service["name"]) + print_kv("Provider type", service["provider_type"]) + if service["relayed"]: + print_kv("Relay", "yes (subscription-backed, no credential stored)") + if service["allow_all_targets"]: + print_kv("Allow all targets", "yes") + targets = service["targets"] + if targets: + print_kv("Targets", targets[0]) + for t in targets[1:]: + print_kv("", t) + else: + print_kv("Targets", "none declared") + + def main() -> None: app() diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index f875a5c7..f75f9f12 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -1530,6 +1530,10 @@ def classify_model_family(model_id: str) -> str | None: # GLM-4.6: 200k context, but the gateway caps output well below the model's # native 128k — pin 25k so requests aren't rejected. "glm": {"context": 200_000, "output": 25_000}, + # Amazon Bedrock Nova (Micro/Lite/Pro), served over Converse: the gateway + # rejects an output cap of 10k or more ("model limit of 10000"), so pin a + # value safely under it. Claude/others have no known low cap and stay unset. + "nova": {"context": 300_000, "output": 8_192}, } @@ -2096,7 +2100,8 @@ def build_skills_mcp_url(workspace: str, locations: list[str]) -> str: # form produced by `_provider_type_tag` (e.g. `amazon_bedrock`). _TOOL_PROVIDER_TYPES: dict[str, tuple[str, ...]] = { "claude": ("anthropic", "amazon_bedrock"), - "codex": ("openai",), + "codex": ("openai", "amazon_bedrock"), + "pi": ("anthropic", "amazon_bedrock"), } # Provider types that expose Bedrock-style model ids (e.g. @@ -2324,12 +2329,13 @@ def service_usable_for_tool(tool: str, service: dict) -> bool: Beyond the provider-type match, a Bedrock service is only usable for claude if it exposes at least one Claude model in its targets — otherwise there's no routable model id to pin. (Anthropic services use canonical names, so any - match is usable.) + match is usable.) Codex uses the OpenAI-compatible Bedrock endpoint, so any + Bedrock service is usable for it regardless of declared targets. """ provider_type = service.get("provider_type", "") if not tool_supports_provider_type(tool, provider_type): return False - if provider_type in BEDROCK_PROVIDER_TYPES: + if tool == "claude" and provider_type in BEDROCK_PROVIDER_TYPES: return bool(map_claude_family_models(service.get("targets") or [])) return True @@ -2370,8 +2376,10 @@ def resolve_provider_service( f"Model provider service '{service_name}' is a '{provider_type}' provider, " f"which {tool} can't route to (supported: {supported})." ) - if provider_type in BEDROCK_PROVIDER_TYPES and not map_claude_family_models( - match.get("targets") or [] + if ( + tool == "claude" + and provider_type in BEDROCK_PROVIDER_TYPES + and not map_claude_family_models(match.get("targets") or []) ): return None, ( f"Model provider service '{service_name}' exposes no Claude models — " @@ -3046,6 +3054,44 @@ def fetch_codex_models(workspace: str, token: str) -> list[str]: return models +def list_mps_codex_models( + service_name: str, workspace: str, token: str +) -> tuple[list[str], str | None]: + """List models available through a Bedrock MPS's OpenAI-compatible endpoint. + + Queries ``{workspace}/ai-gateway/codex/v1/models`` with the + ``Databricks-Model-Provider-Service`` header so the gateway asks the MPS + what models it exposes. Used when a service has ``allow_all_targets`` set + and no explicit targets are declared. + + Returns ``(model_ids, reason)`` where ``reason`` is non-None on failure. + """ + url = f"{build_tool_base_url('codex', workspace)}/models" + req = urllib_request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Databricks-Model-Provider-Service": service_name, + }, + ) + try: + with urllib_request.urlopen(req, timeout=15) as resp: + body = resp.read().decode("utf-8") + payload = json.loads(body) + except urllib_error.HTTPError as exc: + return [], f"HTTP {exc.code}" + except Exception as exc: + return [], str(exc) + if not isinstance(payload, dict): + return [], "unexpected response shape" + data = payload.get("data") or [] + models = sorted( + str(m["id"]) for m in data if isinstance(m, dict) and isinstance(m.get("id"), str) + ) + return models, None + + def _probe_ai_gateway_v2(workspace: str, token: str) -> tuple[bool, str | None]: hostname = workspace_hostname(workspace) url = f"https://{hostname}/api/ai-gateway/v2/endpoints?page_size=1" @@ -3364,6 +3410,10 @@ 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", + # Bedrock routes through the standard gateway; MPS header selects the provider. + # Do NOT include the MPS name in the path — /ai-gateway/amazonbedrock/ maps to + # the control plane (bedrock.amazonaws.com), not the runtime. + "bedrock": f"{workspace}/ai-gateway", } diff --git a/tests/test_agent_pi.py b/tests/test_agent_pi.py index ff7f172d..081c0154 100644 --- a/tests/test_agent_pi.py +++ b/tests/test_agent_pi.py @@ -475,3 +475,148 @@ def test_pi_default_model_wins_over_allowlist(self): def test_falls_back_to_pi_models_without_default(self): state = {"pi_models": ["system.ai.claude-opus-4-8"]} assert pi.default_model(state) == "system.ai.claude-opus-4-8" + + +class TestRenderOverlayBedrockLimits: + """Bedrock model entries pin known per-model token caps, and only those.""" + + def _bedrock_models(self, targets): + overlay, _ = pi.render_overlay( + targets[0], "tok", _base_urls(), {}, [], [], + provider="cat.sch.mps", bedrock_targets=targets, + ) + return overlay["providers"]["databricks-bedrock"]["models"] + + def test_nova_target_pins_max_tokens(self): + entry = self._bedrock_models(["us.amazon.nova-lite-v1:0"])[0] + assert entry["id"] == "us.amazon.nova-lite-v1:0" + assert entry["maxTokens"] == 8192 + assert entry["contextWindow"] == 300_000 + + def test_claude_target_has_no_cap(self): + entry = self._bedrock_models(["us.anthropic.claude-sonnet-4-20250514-v1:0"])[0] + assert entry == {"id": "us.anthropic.claude-sonnet-4-20250514-v1:0"} + + +class TestRefreshTokenOnceBedrockPreservation: + """_refresh_token_once must preserve an existing databricks-bedrock provider block.""" + + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.pi as pi_mod + import ucode.config_io as config_io_mod + + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + config_file = tmp_path / "models.json" + settings_file = tmp_path / "settings.json" + monkeypatch.setattr(pi_mod, "PI_CONFIG_PATH", config_file) + monkeypatch.setattr(pi_mod, "PI_SETTINGS_PATH", settings_file) + monkeypatch.setattr(pi_mod, "PI_BACKUP_PATH", tmp_path / "pi-backup.json") + monkeypatch.setattr(pi_mod, "PI_SETTINGS_BACKUP_PATH", tmp_path / "pi-settings-backup.json") + return pi_mod, config_file, settings_file + + def _state(self) -> dict: + return { + "workspace": WS, + "base_urls": {"pi": _base_urls()}, + "claude_models": {"sonnet": "claude-sonnet"}, + "codex_models": [], + "gemini_models": [], + "managed_configs": {}, + } + + def test_bedrock_block_survives_token_refresh(self, tmp_path, monkeypatch): + """Regression: token refresh must not clobber the databricks-bedrock provider block.""" + pi_mod, config_file, settings_file = self._setup(tmp_path, monkeypatch) + + # Pre-write a models.json that already has a bedrock provider block, + # as written by write_tool_config(..., provider=..., bedrock_targets=[...]). + bedrock_config = { + "model": "databricks-bedrock/anthropic.claude-3-haiku-20240307-v1:0", + "providers": { + "databricks-bedrock": { + "baseUrl": f"{WS}/ai-gateway", + "api": "bedrock-converse-stream", + "apiKey": "old-token", + "authHeader": True, + "headers": { + "User-Agent": "ucode/0.1.0 pi/0.74.0", + "Databricks-Model-Provider-Service": "my-mps-provider", + }, + "models": [ + {"id": "anthropic.claude-3-haiku-20240307-v1:0"}, + {"id": "anthropic.claude-3-sonnet-20240229-v1:0"}, + ], + } + }, + } + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(bedrock_config), encoding="utf-8") + + with ( + patch("ucode.agents.pi.get_databricks_token", return_value="new-token"), + patch("ucode.agents.pi.save_state"), + ): + token = pi_mod._refresh_token_once(self._state()) + + assert token == "new-token" + + written = json.loads(config_file.read_text()) + providers = written.get("providers", {}) + + # The bedrock provider block must still be present. + assert "databricks-bedrock" in providers + + bedrock = providers["databricks-bedrock"] + # MPS header preserved. + assert bedrock["headers"]["Databricks-Model-Provider-Service"] == "my-mps-provider" + # Model ids preserved. + model_ids = [m["id"] for m in bedrock.get("models", [])] + assert "anthropic.claude-3-haiku-20240307-v1:0" in model_ids + assert "anthropic.claude-3-sonnet-20240229-v1:0" in model_ids + # Token refreshed. + assert bedrock["apiKey"] == "new-token" + + # settings.json must pin defaultProvider to databricks-bedrock. + settings = json.loads(settings_file.read_text()) + assert settings["defaultProvider"] == "databricks-bedrock" + + def test_no_bedrock_block_uses_default_model(self, tmp_path, monkeypatch): + """Without a bedrock block, _refresh_token_once falls back to the normal path.""" + pi_mod, config_file, settings_file = self._setup(tmp_path, monkeypatch) + + # Config has only a Claude provider — no bedrock. + existing_config = { + "model": "databricks-claude/claude-sonnet", + "providers": { + "databricks-claude": { + "baseUrl": f"{WS}/ai-gateway/anthropic", + "api": "anthropic-messages", + "apiKey": "old-token", + "authHeader": True, + "headers": {}, + "models": [{"id": "claude-sonnet"}], + } + }, + } + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(existing_config), encoding="utf-8") + + with ( + patch("ucode.agents.pi.get_databricks_token", return_value="new-token"), + patch("ucode.agents.pi.save_state"), + ): + token = pi_mod._refresh_token_once(self._state()) + + assert token == "new-token" + + written = json.loads(config_file.read_text()) + providers = written.get("providers", {}) + + # No bedrock block should be written. + assert "databricks-bedrock" in providers is False or "databricks-bedrock" not in providers + # Claude provider still present. + assert "databricks-claude" in providers + + # settings.json must pin defaultProvider to databricks-claude (normal path). + settings = json.loads(settings_file.read_text()) + assert settings["defaultProvider"] == "databricks-claude" diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 2d9f61a5..a43e1eb1 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -605,12 +605,16 @@ def test_claude_includes_anthropic_and_usable_bedrock(self, monkeypatch): "main.schema2.bedrock-svc", ] - def test_codex_filters_to_openai(self, monkeypatch): + def test_codex_filters_to_openai_and_bedrock(self, monkeypatch): + # codex supports both openai and amazon_bedrock provider types. monkeypatch.setattr( db_mod, "_http_get_json", lambda url, token, timeout=30: (self._PAYLOAD, None) ) names, _ = db_mod.list_tool_provider_services("codex", WS, "token") - assert names == ["main.schema1.openai-svc"] + assert "main.schema1.openai-svc" in names + assert "main.schema2.bedrock-svc" in names + assert "main.schema2.bedrock-titan-svc" in names + assert "main.schema1.anthropic-svc" not in names class TestMapClaudeFamilyModels: @@ -872,6 +876,33 @@ def test_bedrock_without_claude_rejected(self, monkeypatch): assert service is None assert "no Claude models" in error + def test_codex_bedrock_openai_compat_ok(self, monkeypatch): + # Bedrock MPS exposing non-Claude (OpenAI-compatible) models must work for codex. + self._patch(monkeypatch) + service, error = db_mod.resolve_provider_service( + "codex", "main.schema2.bedrock-titan-svc", WS, "token" + ) + assert error is None + assert service["provider_type"] == "amazon_bedrock" + + def test_codex_bedrock_with_claude_targets_ok(self, monkeypatch): + # Bedrock MPS that happens to expose Claude targets is also valid for codex. + self._patch(monkeypatch) + service, error = db_mod.resolve_provider_service( + "codex", "main.schema2.bedrock-svc", WS, "token" + ) + assert error is None + assert service["provider_type"] == "amazon_bedrock" + + def test_codex_anthropic_rejected(self, monkeypatch): + # codex does not speak the Anthropic Messages API. + self._patch(monkeypatch) + service, error = db_mod.resolve_provider_service( + "codex", "main.schema1.anthropic-svc", WS, "token" + ) + assert service is None + assert "can't route to" in error + def test_not_found_lists_usable(self, monkeypatch): self._patch(monkeypatch) service, error = db_mod.resolve_provider_service("claude", "main.x.missing", WS, "token") diff --git a/tests/test_managed_setup.py b/tests/test_managed_setup.py index 140bc3d9..07ef6f14 100644 --- a/tests/test_managed_setup.py +++ b/tests/test_managed_setup.py @@ -362,8 +362,12 @@ def test_codex_supports_openai(self): def test_claude_does_not_support_openai(self): assert not supports_provider_service("claude", "openai") + def test_pi_supports_anthropic_and_bedrock(self): + assert supports_provider_service("pi", "anthropic") + assert supports_provider_service("pi", "amazon_bedrock") + def test_other_agents_have_no_provider_support(self): - for tool in ("gemini", "opencode", "pi", "copilot"): + for tool in ("gemini", "opencode", "copilot"): assert not supports_provider_service(tool, "anthropic"), tool