From 3601277955ef35addb4f5d826ab91a1ba321466f Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 10:54:48 -0500 Subject: [PATCH 1/9] feat(codex): support amazon_bedrock Model Provider Services Codex speaks the OpenAI-compatible API, which Bedrock also exposes. `_TOOL_PROVIDER_TYPES` previously restricted codex to `openai` only, so `ucode codex --provider ` always failed with "which codex can't route to (supported: openai)." Three changes in databricks.py: - Add `amazon_bedrock` to codex's allowed provider types in `_TOOL_PROVIDER_TYPES`. - Gate the "exposes no Claude models" check in `resolve_provider_service` on `tool == "claude"` so a Bedrock MPS with OpenAI-compatible (non-Claude) targets isn't rejected when codex selects it. - Apply the same `tool == "claude"` guard in `service_usable_for_tool` so Bedrock services without Claude targets appear in the list when codex is the active tool. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/databricks.py | 13 ++++++++----- tests/test_databricks.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index f875a5c7..daa633ed 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -2096,7 +2096,7 @@ 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"), } # Provider types that expose Bedrock-style model ids (e.g. @@ -2324,12 +2324,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 +2371,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 — " 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") From 800b1bcb952965ca3986484231235c574a2c840c Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 11:03:00 -0500 Subject: [PATCH 2/9] feat: add `ucode providers list/show` commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new subcommands under `ucode providers` to inspect Model Provider Services on the workspace: - `ucode providers list [--tool TOOL]` — lists all MPS services with name, provider type, and declared targets. `--tool claude|codex` filters to services the given tool can actually route through. - `ucode providers show ` — shows full detail for one service: provider type, relay flag, allow_all_targets, and the complete targets list. Motivation: after `ucode codex --provider eng_dev.ai_gateway.amazonbedrock` launched without showing expected Bedrock models, there was no CLI to inspect what targets an MPS exposes. Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/cli.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 6a1662ae..94c699b1 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, @@ -136,6 +140,7 @@ prompt_for_tools, prompt_for_workspace, prompt_yes_no, + render_box_table, set_verbosity, spinner, status_badge, @@ -1159,6 +1164,8 @@ 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, @@ -3256,6 +3263,82 @@ 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() From dc7d63478896be4e0320e81aeda3b3b84a6dcb16 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 16:41:08 -0500 Subject: [PATCH 3/9] feat: add Pi Bedrock provider support via correct gateway base URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `ucode pi --provider ` end-to-end: - `build_pi_base_urls`: add "bedrock" key pointing at `{workspace}/ai-gateway` (NOT `/ai-gateway/amazonbedrock` — that path maps to the Bedrock control plane; the standard path routes to the runtime via the MPS header) - `pi.render_overlay`: add `databricks-bedrock` provider block when `bedrock_targets` is supplied; defaults the session to the first target - `pi.write_tool_config`: accept `provider` and `bedrock_targets` kwargs - `agents.__init__.configure_tool`: pass `bedrock_targets` to Pi; allow Pi to launch without a model when a Bedrock provider + targets cover it - `cli.py`: fetch MPS targets for Pi in the provider launch path; handle `allow_all_targets` with a text prompt; thread `bedrock_targets` through to `configure_tool` Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/agents/__init__.py | 13 ++++++++--- src/ucode/agents/pi.py | 32 ++++++++++++++++++++++----- src/ucode/cli.py | 24 ++++++++++++++++++++- src/ucode/databricks.py | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) 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..2c8785eb 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -69,6 +69,7 @@ "databricks-claude", "databricks-openai", "databricks-gemini", + "databricks-bedrock", ) PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES] @@ -98,12 +99,15 @@ def _resolve_model_selector( 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 +151,23 @@ 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, + "headers": {**ua_headers, "Databricks-Model-Provider-Service": provider}, + "models": [{"id": t} for t in bedrock_targets], + } + keys.append(["providers", "databricks-bedrock"]) + resolved = _resolve_model_selector(model or "", claude_models, codex_models, gemini_models) + # When launching with a Bedrock provider, default to the first target. + if not resolved and "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 +175,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 +201,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") diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 94c699b1..a722d09b 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -37,7 +37,7 @@ ) from ucode.agents.codex import revert_legacy_shared_config from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH -from ucode.config_io import is_dry_run, restore_file, set_dry_run +from ucode.config_io import is_dry_run, read_toml_safe, restore_file, set_dry_run from ucode.databricks import ( apply_pat_environment, build_shared_base_urls, @@ -56,6 +56,7 @@ is_model_provider_feature_unavailable, is_workspace_admin, list_model_provider_services, + list_mps_codex_models, list_profile_entries, list_tool_provider_services, normalize_workspace_url, @@ -137,6 +138,7 @@ print_success, print_warning, prompt_for_selection, + prompt_for_text, prompt_for_tools, prompt_for_workspace, prompt_yes_no, @@ -2049,6 +2051,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 @@ -2058,6 +2061,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 @@ -2094,6 +2115,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: diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index daa633ed..7d545329 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -3049,6 +3049,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" @@ -3367,6 +3405,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", } From 60a3bcdf3607b011b15cd8998870c6fdfd374ed8 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 16:45:32 -0500 Subject: [PATCH 4/9] fix: add --provider option to ucode pi command Without it, --provider fell into ctx.args and was forwarded to Pi itself rather than being parsed by ucode, so the Bedrock target-fetching branch never ran. Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/cli.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index a722d09b..20041299 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2529,12 +2529,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}) From 975906c5ad5b462d9e57770af1e8820b2eb42846 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 16:47:15 -0500 Subject: [PATCH 5/9] fix: add pi to _TOOL_PROVIDER_TYPES for amazon_bedrock support Without this entry, ucode pi --provider rejects any Bedrock MPS with "pi can't route to (supported: none)" before ever fetching targets. Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/databricks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 7d545329..6b98e353 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -2097,6 +2097,7 @@ def build_skills_mcp_url(workspace: str, locations: list[str]) -> str: _TOOL_PROVIDER_TYPES: dict[str, tuple[str, ...]] = { "claude": ("anthropic", "amazon_bedrock"), "codex": ("openai", "amazon_bedrock"), + "pi": ("anthropic", "amazon_bedrock"), } # Provider types that expose Bedrock-style model ids (e.g. From 743b156be5ae4105ae184cf50163f66b8e95513c Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Wed, 2 Sep 2026 21:10:40 -0500 Subject: [PATCH 6/9] fix: always prefix Bedrock selector with databricks-bedrock/ in Pi config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_model_selector` returns Bedrock model IDs (e.g. `anthropic.claude-3-haiku-20240307-v1:0`) unprefixed because they contain no `/`. The old `if not resolved` guard never fired since the ID is truthy. `_write_settings` then gets an empty model half from `partition("/")` and exits early — defaultProvider stays on databricks-claude instead of databricks-bedrock. Fix: unconditionally set `resolved = f"databricks-bedrock/{targets[0]}"` when the Bedrock provider block is present. Co-Authored-By: Claude Sonnet 4.6 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/agents/pi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ucode/agents/pi.py b/src/ucode/agents/pi.py index 2c8785eb..c0765c36 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -164,8 +164,11 @@ def render_overlay( } keys.append(["providers", "databricks-bedrock"]) resolved = _resolve_model_selector(model or "", claude_models, codex_models, gemini_models) - # When launching with a Bedrock provider, default to the first target. - if not resolved and "databricks-bedrock" in providers and bedrock_targets: + # 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: From 3bf054d8d8b36b263f05f55c887651f377d23e22 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Thu, 3 Sep 2026 11:28:10 -0500 Subject: [PATCH 7/9] fix(pi): keep Bedrock provider across token refresh and drop duplicate UA The launch-time and 30-minute token refresh re-rendered Pi's models.json without the Bedrock provider, dropping the databricks-bedrock block and falling back to a system-hosted model. _refresh_token_once now reads the existing config and preserves a databricks-bedrock block, re-applying it with a freshly refreshed token. Also stop sending ucode's User-Agent on the Bedrock block: Pi's bedrock-converse-stream client sets its own, and two values made the gateway reject the request ("Header field 'user-agent' must only have a single value"). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/agents/pi.py | 33 ++++++++++- tests/test_agent_pi.py | 124 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/ucode/agents/pi.py b/src/ucode/agents/pi.py index c0765c36..9e6223ec 100644 --- a/src/ucode/agents/pi.py +++ b/src/ucode/agents/pi.py @@ -159,7 +159,11 @@ def render_overlay( "api": "bedrock-converse-stream", "apiKey": token, "authHeader": True, - "headers": {**ua_headers, "Databricks-Model-Provider-Service": provider}, + # 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": [{"id": t} for t in bedrock_targets], } keys.append(["providers", "databricks-bedrock"]) @@ -284,6 +288,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/tests/test_agent_pi.py b/tests/test_agent_pi.py index ff7f172d..30290972 100644 --- a/tests/test_agent_pi.py +++ b/tests/test_agent_pi.py @@ -475,3 +475,127 @@ 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 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" From 77aad726c3fd27ac825d3b17c8c3e5dc99cc9188 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Thu, 3 Sep 2026 12:26:07 -0500 Subject: [PATCH 8/9] fix(pi): resolve provider-support test and lint after dropping codex path Reflect that Pi now supports anthropic/amazon_bedrock provider services, and clean up imports left unused once the codex Bedrock launch branch is excluded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/cli.py | 19 +++++++++++++------ tests/test_managed_setup.py | 6 +++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 20041299..5c1d44c8 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -37,7 +37,7 @@ ) from ucode.agents.codex import revert_legacy_shared_config from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH -from ucode.config_io import is_dry_run, read_toml_safe, restore_file, set_dry_run +from ucode.config_io import is_dry_run, restore_file, set_dry_run from ucode.databricks import ( apply_pat_environment, build_shared_base_urls, @@ -56,7 +56,6 @@ is_model_provider_feature_unavailable, is_workspace_admin, list_model_provider_services, - list_mps_codex_models, list_profile_entries, list_tool_provider_services, normalize_workspace_url, @@ -1167,7 +1166,9 @@ def revert() -> int: 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.") +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, @@ -3297,7 +3298,9 @@ def upgrade_cmd() -> None: def providers_list_cmd( tool: Annotated[ str | None, - typer.Option("--tool", help="Filter to services usable by a specific tool (claude, codex)."), + typer.Option( + "--tool", help="Filter to services usable by a specific tool (claude, codex)." + ), ] = None, ) -> None: """List Model Provider Services on the workspace.""" @@ -3322,12 +3325,16 @@ def providers_list_cmd( [ s["name"], s["provider_type"], - ", ".join(s["targets"]) if s["targets"] else ("(all)" if s["allow_all_targets"] else "—"), + ", ".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])) + 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}.")) 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 From dc04083523cc0b3269cf1914da56060bbbc255d4 Mon Sep 17 00:00:00 2001 From: Billy Janssen Date: Thu, 3 Sep 2026 13:29:15 -0500 Subject: [PATCH 9/9] feat(opencode): Amazon Bedrock Model Provider Service support Route OpenCode to an Amazon Bedrock MPS through the Databricks AI Gateway, mirroring the Pi support. OpenCode uses the @ai-sdk/amazon-bedrock provider with a bearer apiKey (no SigV4, no region) against {workspace}/ai-gateway, which the SDK turns into /model/{id}/converse-stream. The Databricks-Model-Provider-Service header rides per-model, since OpenCode clobbers provider-level headers. _refresh_token_once now preserves an existing databricks-bedrock block across the launch-time and 30-minute token refresh (reading the saved MPS name and target ids back out of the per-model headers), so the session keeps routing to Bedrock instead of dropping to a system-hosted model. Verified end to end against the live gateway: the generated config survives a refresh and a real `opencode run` returns Bedrock output. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017G9kjrbhqvWucH26GwykSn --- src/ucode/agents/__init__.py | 6 +- src/ucode/agents/opencode.py | 66 +++++++++- src/ucode/cli.py | 16 ++- src/ucode/databricks.py | 4 + tests/test_agent_opencode.py | 248 +++++++++++++++++++++++++++++++++++ tests/test_managed_wizard.py | 3 +- 6 files changed, 333 insertions(+), 10 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index edbcd1dc..45e39778 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -373,7 +373,7 @@ def configure_tool( else: # 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): + if not model and not (tool in ("pi", "opencode") and provider and bedrock_targets): raise RuntimeError(f"A {tool} model must be selected before configuration.") if tool == "gemini": assert model is not None @@ -385,6 +385,10 @@ def configure_tool( result = pi.write_tool_config( state, model, provider=provider, bedrock_targets=bedrock_targets ) + elif tool == "opencode": + result = opencode.write_tool_config( + state, model, provider=provider, bedrock_targets=bedrock_targets + ) else: assert model is not None result = opencode.write_tool_config(state, model) diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index b7803d66..46159ff3 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -41,12 +41,15 @@ ["provider", "databricks-anthropic"], ["provider", "databricks-google"], ["provider", "databricks-oss"], + ["provider", "databricks-bedrock"], ] def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str: """Return an OpenCode model selector in provider/model form when possible.""" - if model.startswith(("databricks-anthropic/", "databricks-google/", "databricks-oss/")): + if model.startswith( + ("databricks-anthropic/", "databricks-google/", "databricks-oss/", "databricks-bedrock/") + ): return model anthropic_models = opencode_models.get("anthropic") or [] @@ -79,10 +82,13 @@ def _oss_model_overlay(model: str, ua_header: dict[str, str]) -> dict: def render_overlay( - model: str, + model: str | None, token: str, opencode_base_urls: dict[str, str], opencode_models: dict[str, list[str]], + *, + provider: str | None = None, + bedrock_targets: list[str] | None = None, ) -> tuple[dict, list[list[str]]]: """Return (overlay, managed_key_paths) for opencode.json.""" auth_headers = {"Authorization": f"Bearer {token}"} @@ -100,6 +106,23 @@ def render_overlay( providers: dict = {} keys: list[list[str]] = [["model"]] + if provider and bedrock_targets: + # Bedrock routes through Databricks AI Gateway using bearer auth only + # (no AWS SigV4, no region). MPS and UA headers must be per-model because + # OpenCode clobbers provider-level headers in session/llm.ts. + bedrock_model_header = { + "User-Agent": ua_header["User-Agent"], + "Databricks-Model-Provider-Service": provider, + } + providers["databricks-bedrock"] = { + "npm": "@ai-sdk/amazon-bedrock", + "options": { + "baseURL": opencode_base_urls["bedrock"], + "apiKey": token, + }, + "models": {t: {"headers": bedrock_model_header} for t in bedrock_targets}, + } + keys.append(["provider", "databricks-bedrock"]) if anthropic_models: # @ai-sdk/anthropic injects `eager_input_streaming: true` on tool defs; # the Databricks gateway's strict validator rejects it. opencode's @@ -143,7 +166,12 @@ def render_overlay( } keys.append(["provider", "databricks-oss"]) - overlay: dict = {"model": _resolve_model_selector(model, opencode_models)} + if provider and bedrock_targets: + model_selector = f"databricks-bedrock/{bedrock_targets[0]}" + else: + assert model is not None + model_selector = _resolve_model_selector(model, opencode_models) + overlay: dict = {"model": model_selector} if providers: overlay["provider"] = providers return overlay, keys @@ -151,9 +179,11 @@ def render_overlay( def write_tool_config( state: dict, - model: str, + model: str | None, token: str | None = None, *, + provider: str | None = None, + bedrock_targets: list[str] | None = None, force_refresh: bool = False, ) -> tuple[dict, str]: backup_existing_file(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH) @@ -169,12 +199,15 @@ def write_tool_config( token, opencode_base_urls, state.get("opencode_models") or {}, + provider=provider, + bedrock_targets=bedrock_targets, ) existing = read_json_safe(OPENCODE_CONFIG_PATH) providers = existing.get("provider") if isinstance(providers, dict): for stale in ( "databricks-anthropic", + "databricks-bedrock", "databricks-google", "databricks-openai", "databricks-oss", @@ -237,6 +270,31 @@ def default_model(state: dict) -> str | None: def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str: + # Preserve an existing databricks-bedrock provider block written by a + # --provider launch, so token refresh does not silently drop it. The MPS + # name lives in each model entry's headers (per-model, not provider-level). + existing = read_json_safe(OPENCODE_CONFIG_PATH) + bedrock = (existing.get("provider") or {}).get("databricks-bedrock") + if isinstance(bedrock, dict): + models_dict = bedrock.get("models") or {} + saved_targets = list(models_dict.keys()) if models_dict else None + saved_provider: str | None = None + for entry in models_dict.values(): + if isinstance(entry, dict): + saved_provider = (entry.get("headers") or {}).get( + "Databricks-Model-Provider-Service" + ) + if saved_provider: + break + if saved_targets and saved_provider: + _, token = write_tool_config( + state, + None, + force_refresh=force_refresh, + provider=saved_provider, + bedrock_targets=saved_targets, + ) + return token model = default_model(state) if not model: raise RuntimeError("No OpenCode model is configured.") diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 5c1d44c8..20b8f769 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2062,9 +2062,9 @@ 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. + elif tool in ("pi", "opencode"): + # Pi and OpenCode receive the MPS targets as their 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) @@ -2508,12 +2508,20 @@ def gemini_cmd( ) def opencode_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 OpenCode via Databricks.""" _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("opencode", ctx, skip_preflight=skip_preflight) + _launch_tool("opencode", ctx, provider=provider, skip_preflight=skip_preflight) @app.command("copilot", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 6b98e353..d8eb89ab 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -2097,6 +2097,7 @@ def build_skills_mcp_url(workspace: str, locations: list[str]) -> str: _TOOL_PROVIDER_TYPES: dict[str, tuple[str, ...]] = { "claude": ("anthropic", "amazon_bedrock"), "codex": ("openai", "amazon_bedrock"), + "opencode": ("amazon_bedrock",), "pi": ("anthropic", "amazon_bedrock"), } @@ -3387,6 +3388,9 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]: "anthropic": build_tool_base_url("claude", workspace) + "/v1", "gemini": build_tool_base_url("gemini", workspace) + "/v1beta", "oss": f"{workspace}/ai-gateway/mlflow/v1", + # Bedrock routes through the standard gateway; MPS header selects the + # provider. Do NOT include the MPS name in the path. + "bedrock": f"{workspace}/ai-gateway", } diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index c83e8458..409faff5 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -423,3 +423,251 @@ def test_config_written_with_correct_model(self, tmp_path, monkeypatch): written = json.loads(config_file.read_text()) assert written["model"] == "databricks-anthropic/claude-sonnet" + + +def _bedrock_base_urls() -> dict[str, str]: + return { + **_base_urls(), + "bedrock": f"{WS}/ai-gateway", + } + + +class TestRenderOverlayBedrock: + def test_bedrock_provider_added_when_provider_and_targets(self): + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert "databricks-bedrock" in overlay["provider"] + + def test_bedrock_uses_amazon_bedrock_npm_package(self): + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert overlay["provider"]["databricks-bedrock"]["npm"] == "@ai-sdk/amazon-bedrock" + + def test_bedrock_uses_gateway_base_url(self): + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + options = overlay["provider"]["databricks-bedrock"]["options"] + assert options["baseURL"] == f"{WS}/ai-gateway" + + def test_bedrock_uses_token_as_api_key(self): + overlay, _ = opencode.render_overlay( + None, + "mytoken", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert overlay["provider"]["databricks-bedrock"]["options"]["apiKey"] == "mytoken" + + def test_bedrock_no_region_in_options(self): + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert "region" not in overlay["provider"]["databricks-bedrock"]["options"] + + def test_bedrock_mps_header_is_per_model(self): + target = "anthropic.claude-3-haiku-20240307-v1:0" + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=[target], + ) + model_entry = overlay["provider"]["databricks-bedrock"]["models"][target] + assert model_entry["headers"]["Databricks-Model-Provider-Service"] == "main.ai.my-mps" + + def test_bedrock_ua_header_is_per_model(self, monkeypatch): + monkeypatch.setattr("ucode.agents.opencode.ucode_version", lambda: "1.0.0") + monkeypatch.setattr("ucode.agents.opencode.agent_version", lambda _: "2.0.0") + target = "anthropic.claude-3-haiku-20240307-v1:0" + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=[target], + ) + ua = overlay["provider"]["databricks-bedrock"]["models"][target]["headers"]["User-Agent"] + assert ua == "ucode/1.0.0 opencode/2.0.0" + + def test_bedrock_no_authorization_header_at_provider_level(self): + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert "headers" not in overlay["provider"]["databricks-bedrock"]["options"] + + def test_bedrock_model_selector_prefixed(self): + target = "anthropic.claude-3-haiku-20240307-v1:0" + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=[target], + ) + assert overlay["model"] == f"databricks-bedrock/{target}" + + def test_bedrock_all_targets_listed_as_models(self): + targets = [ + "anthropic.claude-3-haiku-20240307-v1:0", + "anthropic.claude-3-sonnet-20240229-v1:0", + ] + overlay, _ = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=targets, + ) + models = overlay["provider"]["databricks-bedrock"]["models"] + assert set(models.keys()) == set(targets) + + def test_bedrock_managed_key_tracked(self): + _, keys = opencode.render_overlay( + None, + "tok", + _bedrock_base_urls(), + {}, + provider="main.ai.my-mps", + bedrock_targets=["anthropic.claude-3-haiku-20240307-v1:0"], + ) + assert ["provider", "databricks-bedrock"] in keys + + +class TestRefreshTokenOnceBedrockPreservation: + """_refresh_token_once must preserve an existing databricks-bedrock provider block.""" + + def _setup(self, tmp_path, monkeypatch): + import ucode.agents.opencode as oc_mod + import ucode.config_io as config_io_mod + + monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) + config_file = tmp_path / "opencode.json" + backup_file = tmp_path / "opencode-backup.json" + monkeypatch.setattr(oc_mod, "OPENCODE_CONFIG_PATH", config_file) + monkeypatch.setattr(oc_mod, "OPENCODE_BACKUP_PATH", backup_file) + return oc_mod, config_file + + def _state(self) -> dict: + return { + "workspace": WS, + "base_urls": {"opencode": _bedrock_base_urls()}, + "opencode_models": {"anthropic": ["claude-sonnet"]}, + "managed_configs": {}, + } + + def test_bedrock_block_survives_token_refresh(self, tmp_path, monkeypatch): + """Regression: token refresh must not clobber the databricks-bedrock provider block.""" + oc_mod, config_file = self._setup(tmp_path, monkeypatch) + + bedrock_config = { + "model": "databricks-bedrock/anthropic.claude-3-haiku-20240307-v1:0", + "provider": { + "databricks-bedrock": { + "npm": "@ai-sdk/amazon-bedrock", + "options": { + "baseURL": f"{WS}/ai-gateway", + "apiKey": "old-token", + }, + "models": { + "anthropic.claude-3-haiku-20240307-v1:0": { + "headers": { + "User-Agent": "ucode/0.1.0 opencode/0.74.0", + "Databricks-Model-Provider-Service": "my-mps-provider", + } + }, + "anthropic.claude-3-sonnet-20240229-v1:0": { + "headers": { + "User-Agent": "ucode/0.1.0 opencode/0.74.0", + "Databricks-Model-Provider-Service": "my-mps-provider", + } + }, + }, + } + }, + } + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(bedrock_config), encoding="utf-8") + + with ( + patch("ucode.agents.opencode.get_databricks_token", return_value="new-token"), + patch("ucode.agents.opencode.save_state"), + ): + token = oc_mod._refresh_token_once(self._state()) + + assert token == "new-token" + + written = json.loads(config_file.read_text()) + providers = written.get("provider", {}) + + assert "databricks-bedrock" in providers + bedrock = providers["databricks-bedrock"] + assert bedrock["options"]["apiKey"] == "new-token" + + model_ids = list(bedrock["models"].keys()) + assert "anthropic.claude-3-haiku-20240307-v1:0" in model_ids + assert "anthropic.claude-3-sonnet-20240229-v1:0" in model_ids + + for entry in bedrock["models"].values(): + assert entry["headers"]["Databricks-Model-Provider-Service"] == "my-mps-provider" + + 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.""" + oc_mod, config_file = self._setup(tmp_path, monkeypatch) + + existing_config = { + "model": "databricks-anthropic/claude-sonnet", + "provider": { + "databricks-anthropic": { + "npm": "@ai-sdk/anthropic", + "options": {"baseURL": f"{WS}/ai-gateway/anthropic/v1", "apiKey": "old-token"}, + "models": {"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.opencode.get_databricks_token", return_value="new-token"), + patch("ucode.agents.opencode.save_state"), + ): + token = oc_mod._refresh_token_once(self._state()) + + assert token == "new-token" + written = json.loads(config_file.read_text()) + assert "databricks-anthropic" in written.get("provider", {}) diff --git a/tests/test_managed_wizard.py b/tests/test_managed_wizard.py index 013cdc9d..bd249216 100644 --- a/tests/test_managed_wizard.py +++ b/tests/test_managed_wizard.py @@ -1297,8 +1297,9 @@ def fake_spinner(message): class TestProviderServiceSelection: def test_agents_without_provider_support_skip_the_prompt(self): + # gemini has no provider type support; the prompt must be skipped entirely. with patch.object(wizard, "list_model_provider_services") as listing: - assert wizard._select_provider_service("opencode", WORKSPACE, "token") is None + assert wizard._select_provider_service("gemini", WORKSPACE, "token") is None assert not listing.called def test_feature_disabled_is_silent(self):