Skip to content

Commit df27b52

Browse files
Add Model Provider Service routing for claude and codex (#174)
* Add Model Provider Service routing for claude and codex Route claude/codex through a Unity Catalog Model Provider Service (external Anthropic/OpenAI provider) via the Databricks-Model-Provider-Service header instead of pinning a Databricks model. - `ucode claude/codex --provider <catalog.schema.name>` routes per-invocation; verifies the MPS feature is enabled and fails with a clear message if not. - `ucode configure --model-provider` opt-in: lists matching services (anthropic for claude, openai for codex), persists the choice per tool; launches then use it automatically. Default configure path is unchanged. - Provider mode pins no Databricks model (the agent's own canonical names route through the header) and skips the heavy model discovery, fetching only a web-search model. - Friendlier USE CONNECTION permission error naming the service. Co-authored-by: Isaac * Trigger CI re-run after granting trace-table write access Co-authored-by: Isaac * Re-trigger CI after granting github-actions-sp UC schema access Co-authored-by: Isaac * Drop --model-provider flag; gate picker on interactive configure Show the Model Provider Service picker only on the fully interactive `ucode configure` path (no --agent/--agents). Naming agents signals the non-interactive flow and stays on Databricks. Also fall back to Databricks silently when the MPS feature isn't enabled on the workspace, instead of printing a per-tool note. * Rename provider picker options to Databricks Hosted / External Models * CI: dump Claude tracing hook log on e2e tracing failure * Force synchronous MLflow trace export in tracing hook The Claude Stop hook (mlflow autolog claude stop-hook) is a short-lived one-shot process. With MLflow's default async trace logging, the root claude_code_conversation span is queued and the hook's best-effort flush can lose it before the process exits — observed on CI runners, leaving an orphaned llm span and no queryable trace (e2e tracing test failure). Set MLFLOW_ENABLE_ASYNC_TRACE_LOGGING=false so export is synchronous. * CI: probe MLflow span export on tracing failure * CI: probe span export under mlflow 3.11.1 vs 3.12.0 * Add Amazon Bedrock model provider routing for claude Bedrock-backed Model Provider Services expose Claude under provider-side model ids (e.g. us.anthropic.claude-sonnet-4-6) rather than Claude Code's canonical names, so ucode pins them explicitly via the ANTHROPIC_DEFAULT_* env vars. Maps service targets to opus/sonnet/haiku families, preferring the highest version and broadest-routing region profile, and validates that a Bedrock service exposes at least one Claude model before routing to it. Co-authored-by: Isaac * Revert diagnostic CI probes in ci.yml Co-authored-by: Isaac
1 parent 1051b1a commit df27b52

16 files changed

Lines changed: 1240 additions & 85 deletions

src/ucode/agents/__init__.py

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818

1919
from ucode.config_io import ToolSpec
2020
from ucode.databricks import (
21+
BEDROCK_PROVIDER_TYPES,
22+
get_databricks_token,
2123
install_databricks_cli,
24+
map_bedrock_claude_models,
25+
resolve_provider_service,
2226
)
23-
from ucode.state import load_state, save_state
27+
from ucode.state import get_provider_service, load_state, save_state
2428
from ucode.telemetry import agent_version
2529
from ucode.ui import (
2630
console,
@@ -254,16 +258,52 @@ def resolve_launch_model(
254258
return state, model
255259

256260

257-
def configure_tool(tool: str, state: dict, model: str | None = None) -> dict:
261+
def resolve_provider_models(
262+
tool: str, state: dict, provider: str | None
263+
) -> tuple[dict | None, str | None]:
264+
"""Validate ``provider`` for ``tool`` and return the model ids to pin.
265+
266+
Returns ``(provider_models, error)``. ``provider_models`` is a
267+
``{family: model_id}`` dict for a Bedrock-backed claude service (whose
268+
provider-side ids must be pinned explicitly), or None for an Anthropic/
269+
canonical service or when ``provider`` is None. A non-None ``error`` means
270+
the provider is invalid for the tool (wrong type, missing, feature off, or a
271+
Bedrock service with no Claude models) and the caller should not launch.
272+
"""
273+
if not provider:
274+
return None, None
275+
token = get_databricks_token(state["workspace"], state.get("profile"))
276+
service, error = resolve_provider_service(tool, provider, state["workspace"], token)
277+
if error or service is None:
278+
return None, error
279+
if service["provider_type"] in BEDROCK_PROVIDER_TYPES:
280+
return map_bedrock_claude_models(service.get("targets") or []), None
281+
return None, None
282+
283+
284+
def configure_tool(
285+
tool: str,
286+
state: dict,
287+
model: str | None = None,
288+
provider: str | None = None,
289+
provider_models: dict[str, str] | None = None,
290+
) -> dict:
258291
result: dict | tuple[dict, str]
259292
if tool == "codex":
260-
result = codex.write_tool_config(state, model)
293+
result = codex.write_tool_config(state, model, provider=provider)
294+
elif tool == "claude":
295+
# A Model Provider Service routes by header and pins no Databricks
296+
# model, so the usual "model required" guard doesn't apply to claude.
297+
if not model and not provider:
298+
raise RuntimeError(f"A {tool} model must be selected before configuration.")
299+
result = claude.write_tool_config(
300+
state, model, provider=provider, provider_models=provider_models
301+
)
261302
else:
303+
# provider routing is claude/codex-only; every other tool needs a model.
262304
if not model:
263305
raise RuntimeError(f"A {tool} model must be selected before configuration.")
264-
if tool == "claude":
265-
result = claude.write_tool_config(state, model)
266-
elif tool == "gemini":
306+
if tool == "gemini":
267307
result = gemini.write_tool_config(state, model)
268308
elif tool == "copilot":
269309
result = copilot.write_tool_config(state, model)
@@ -325,24 +365,37 @@ def _availability_failure_detail(tool: str, state: dict) -> str:
325365

326366
def configure_single_tool(tool: str, state: dict) -> dict:
327367
"""Check availability, configure, and persist state for one tool only."""
328-
with spinner(f"Checking {TOOL_SPECS[tool]['display']} availability..."):
329-
ok = check_gateway_endpoint(state, tool)
330-
if not ok:
331-
detail = _availability_failure_detail(tool, state)
332-
raise RuntimeError(
333-
f"{TOOL_SPECS[tool]['display']} is not available on this workspace.{detail}"
334-
)
335-
if tool == "codex":
336-
state = configure_tool("codex", state)
337-
else:
338-
state, model = resolve_launch_model(tool, state, None)
339-
state = configure_tool(tool, state, model)
368+
provider = get_provider_service(state, tool)
369+
# A Model Provider Service routes through the same gateway and pins no
370+
# Databricks model, so the per-tool model availability check doesn't apply.
371+
if not provider:
372+
with spinner(f"Checking {TOOL_SPECS[tool]['display']} availability..."):
373+
ok = check_gateway_endpoint(state, tool)
374+
if not ok:
375+
detail = _availability_failure_detail(tool, state)
376+
raise RuntimeError(
377+
f"{TOOL_SPECS[tool]['display']} is not available on this workspace.{detail}"
378+
)
379+
state = _configure_one(tool, state, provider)
340380
available_tools = list(set((state.get("available_tools") or []) + [tool]))
341381
state["available_tools"] = available_tools
342382
save_state(state)
343383
return state
344384

345385

386+
def _configure_one(tool: str, state: dict, provider: str | None) -> dict:
387+
"""Write one tool's config, routing through ``provider`` when set."""
388+
if provider:
389+
provider_models, error = resolve_provider_models(tool, state, provider)
390+
if error:
391+
raise RuntimeError(error)
392+
return configure_tool(tool, state, None, provider=provider, provider_models=provider_models)
393+
if tool == "codex":
394+
return configure_tool("codex", state)
395+
state, model = resolve_launch_model(tool, state, None)
396+
return configure_tool(tool, state, model)
397+
398+
346399
def configure_selected_tools(state: dict, tools: list[str]) -> dict:
347400
"""Configure the given tools. Caller is responsible for ensuring each tool
348401
is available on the workspace.
@@ -352,11 +405,7 @@ def configure_selected_tools(state: dict, tools: list[str]) -> dict:
352405
run is preserved.
353406
"""
354407
for tool in tools:
355-
if tool == "codex":
356-
state = configure_tool("codex", state)
357-
else:
358-
state, model = resolve_launch_model(tool, state, None)
359-
state = configure_tool(tool, state, model)
408+
state = _configure_one(tool, state, get_provider_service(state, tool))
360409

361410
existing = state.get("available_tools") or []
362411
state["available_tools"] = sorted(set(existing) | set(tools))
@@ -440,6 +489,21 @@ def validate_tool(tool: str) -> tuple[bool, str]:
440489
return False, "timed out"
441490

442491

492+
def provider_permission_error(tool: str, state: dict, err: str) -> str:
493+
"""Rewrite the opaque gateway connection-permission failure into an
494+
actionable message naming the Model Provider Service the user must be
495+
granted access to. Returns ``err`` unchanged when it doesn't apply.
496+
"""
497+
provider = get_provider_service(state, tool)
498+
if provider and "USE CONNECTION on SCHEMA_CONNECTION" in err:
499+
return (
500+
f"You don't have EXECUTE permission on the model provider service "
501+
f"'{provider}'. Ask its owner to grant you access, then re-run "
502+
f"`ucode configure`."
503+
)
504+
return err
505+
506+
443507
def validate_all_tools(state: dict) -> None:
444508
from rich.panel import Panel # local to avoid bumping module-level deps
445509

@@ -470,7 +534,7 @@ def validate_all_tools(state: dict) -> None:
470534
if ok:
471535
print_success(f"{spec['display']} is working")
472536
else:
473-
print_err(f"{spec['display']}: {err}")
537+
print_err(f"{spec['display']}: {provider_permission_error(tool, state, err)}")
474538
managed = bool(state.get("managed_configs", {}).get(tool))
475539
restore_file(spec["config_path"], spec["backup_path"], managed)
476540
# Rollback settings.json for Pi

src/ucode/agents/claude.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,28 +125,39 @@ def _web_search_mcp_entry(workspace: str, search_model: str, profile: str | None
125125

126126
def render_overlay(
127127
workspace: str,
128-
model: str,
128+
model: str | None,
129129
claude_models: dict[str, str] | None = None,
130130
disable_web_search: bool = False,
131131
profile: str | None = None,
132132
use_pat: bool = False,
133+
provider: str | None = None,
134+
provider_models: dict[str, str] | None = None,
133135
) -> tuple[dict, list[list[str]]]:
134136
"""Return (overlay, managed_key_paths) for Claude settings.json.
135137
136138
NOTE: MCP servers are NOT written here. Claude Code reads `mcpServers`
137139
from `~/.claude.json`, not `~/.claude/settings.json` — registration goes
138-
through `claude mcp add-json` (see `_register_web_search_mcp`)."""
140+
through `claude mcp add-json` (see `_register_web_search_mcp`).
141+
142+
When `provider` is set (a `<catalog>.<schema>.<name>` Model Provider
143+
Service), the request is routed to that external provider via the
144+
`Databricks-Model-Provider-Service` header. An Anthropic-backed provider
145+
understands Claude Code's own canonical model names, so no model id is
146+
pinned. A Bedrock-backed provider exposes different model ids (e.g.
147+
`us.anthropic.claude-sonnet-4-6`), passed in `provider_models` by family —
148+
those get pinned via the `ANTHROPIC_DEFAULT_*_MODEL` env vars."""
139149
base_url = build_tool_base_url("claude", workspace)
140150
# ANTHROPIC_CUSTOM_HEADERS is parsed as `key: value` pairs separated by
141151
# newlines (Anthropic SDK convention). Setting User-Agent here overrides
142152
# the SDK's default UA on outbound requests so the gateway can attribute
143153
# traffic to ucode.
144-
custom_headers = "\n".join(
145-
[
146-
"x-databricks-use-coding-agent-mode: true",
147-
f"User-Agent: ucode/{ucode_version()} claude/{agent_version('claude')}",
148-
]
149-
)
154+
header_lines = [
155+
"x-databricks-use-coding-agent-mode: true",
156+
f"User-Agent: ucode/{ucode_version()} claude/{agent_version('claude')}",
157+
]
158+
if provider:
159+
header_lines.append(f"Databricks-Model-Provider-Service: {provider}")
160+
custom_headers = "\n".join(header_lines)
150161
env: dict[str, str] = {
151162
"ANTHROPIC_BASE_URL": base_url,
152163
"ANTHROPIC_CUSTOM_HEADERS": custom_headers,
@@ -160,7 +171,21 @@ def render_overlay(
160171
# only one row per model. `ucode claude -- --model X` still overrides for a
161172
# single session via Claude Code's own --model flag.
162173
_ = model # API stability; no longer pinned via env.
163-
if claude_models:
174+
# A Bedrock-backed provider needs its provider-side ids pinned verbatim
175+
# (Claude Code's canonical names aren't routable there). These come from the
176+
# service's targets, already de-duped to one id per family upstream.
177+
if provider and provider_models:
178+
if provider_models.get("opus"):
179+
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = provider_models["opus"]
180+
if provider_models.get("sonnet"):
181+
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = provider_models["sonnet"]
182+
if provider_models.get("haiku"):
183+
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = provider_models["haiku"]
184+
# With an Anthropic Model Provider Service, the header routes to the external
185+
# provider and Claude Code's own canonical model names are sent verbatim —
186+
# pinning a Databricks model id here would mislabel the picker and isn't
187+
# routable.
188+
elif claude_models and not provider:
164189
# Picker rows show the raw routable id (e.g. "system.ai.claude-opus-4-8[1m]")
165190
# so users can see which gateway-routable model is behind each shortcut.
166191
# We deliberately don't set the `_NAME` companion env vars — the raw id
@@ -244,7 +269,12 @@ def _unregister_web_search_mcp() -> None:
244269
pass
245270

246271

247-
def write_tool_config(state: dict, model: str) -> dict:
272+
def write_tool_config(
273+
state: dict,
274+
model: str | None,
275+
provider: str | None = None,
276+
provider_models: dict[str, str] | None = None,
277+
) -> dict:
248278
backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH)
249279
web_search_model = _resolve_web_search_model(state)
250280
overlay, managed_keys = render_overlay(
@@ -254,6 +284,8 @@ def write_tool_config(state: dict, model: str) -> dict:
254284
disable_web_search=web_search_model is not None,
255285
profile=state.get("profile"),
256286
use_pat=bool(state.get("use_pat")),
287+
provider=provider,
288+
provider_models=provider_models,
257289
)
258290
tracing_env_vars = tracing_env(state, "claude")
259291
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None

src/ucode/agents/codex.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,26 @@ def _use_legacy_layout() -> bool:
102102
return parsed < MINIMUM_CODEX_VERSION
103103

104104

105-
def _provider_block(workspace: str, databricks_profile: str | None, use_pat: bool = False) -> dict:
105+
def _provider_block(
106+
workspace: str,
107+
databricks_profile: str | None,
108+
use_pat: bool = False,
109+
provider: str | None = None,
110+
) -> dict:
106111
auth_argv = build_auth_token_argv(workspace, databricks_profile, use_pat=use_pat)
107112
base_url = build_tool_base_url("codex", workspace)
113+
http_headers = {
114+
"User-Agent": f"ucode/{ucode_version()} codex/{agent_version('codex')}",
115+
}
116+
# Route to an external Model Provider Service; the gateway selects the
117+
# provider from this header on every request.
118+
if provider:
119+
http_headers["Databricks-Model-Provider-Service"] = provider
108120
return {
109121
"name": "Databricks AI Gateway",
110122
"base_url": base_url,
111123
"wire_api": "responses",
112-
"http_headers": {
113-
"User-Agent": f"ucode/{ucode_version()} codex/{agent_version('codex')}",
114-
},
124+
"http_headers": http_headers,
115125
# Run the `ucode auth-token` executable directly (not via `sh -c`) so the
116126
# helper works on Windows, where there is no POSIX shell (issue #116).
117127
"auth": {
@@ -128,12 +138,15 @@ def render_overlay(
128138
model: str | None = None,
129139
databricks_profile: str | None = None,
130140
use_pat: bool = False,
141+
provider: str | None = None,
131142
) -> dict:
132143
overlay: dict = {"model_provider": CODEX_MODEL_PROVIDER_NAME}
133144
if model:
134145
overlay["model"] = model
135146
overlay["model_providers"] = {
136-
CODEX_MODEL_PROVIDER_NAME: _provider_block(workspace, databricks_profile, use_pat),
147+
CODEX_MODEL_PROVIDER_NAME: _provider_block(
148+
workspace, databricks_profile, use_pat, provider
149+
),
137150
}
138151
return overlay
139152

@@ -143,6 +156,7 @@ def render_legacy_overlay(
143156
model: str | None = None,
144157
databricks_profile: str | None = None,
145158
use_pat: bool = False,
159+
provider: str | None = None,
146160
) -> dict:
147161
"""Overlay for Codex CLI < 0.134.0, which only reads `~/.codex/config.toml`.
148162
@@ -156,7 +170,9 @@ def render_legacy_overlay(
156170
"profile": CODEX_PROFILE_NAME,
157171
"profiles": {CODEX_PROFILE_NAME: profile_block},
158172
"model_providers": {
159-
CODEX_MODEL_PROVIDER_NAME: _provider_block(workspace, databricks_profile, use_pat),
173+
CODEX_MODEL_PROVIDER_NAME: _provider_block(
174+
workspace, databricks_profile, use_pat, provider
175+
),
160176
},
161177
}
162178

@@ -293,9 +309,12 @@ def _parse_gpt(model: str | None) -> tuple[int, int | None, int | None, str] | N
293309
)
294310

295311

296-
def write_tool_config(state: dict, model: str | None = None) -> dict:
312+
def write_tool_config(state: dict, model: str | None = None, provider: str | None = None) -> dict:
297313
workspace = state["workspace"]
298-
chosen_model = _codex_model_id(model or default_model(state))
314+
# With a Model Provider Service the gateway routes by header and Codex sends
315+
# its own canonical model name (e.g. `gpt-5`) — leave `model` unset so no
316+
# Databricks endpoint id is pinned.
317+
chosen_model = None if provider else _codex_model_id(model or default_model(state))
299318
databricks_profile = state.get("profile")
300319

301320
if _use_legacy_layout():
@@ -305,10 +324,20 @@ def write_tool_config(state: dict, model: str | None = None) -> dict:
305324
# ucode's entry from the shared file.
306325
backup_existing_file(LEGACY_CODEX_CONFIG_PATH, LEGACY_CODEX_BACKUP_PATH)
307326
overlay = render_legacy_overlay(
308-
workspace, chosen_model, databricks_profile, use_pat=bool(state.get("use_pat"))
327+
workspace,
328+
chosen_model,
329+
databricks_profile,
330+
use_pat=bool(state.get("use_pat")),
331+
provider=provider,
309332
)
310333
doc = read_toml_safe(LEGACY_CODEX_CONFIG_PATH)
311334
deep_merge_dict(doc, overlay)
335+
if provider:
336+
# deep_merge can't drop keys, so clear a `model` pinned by an
337+
# earlier non-provider run that the provider overlay omits.
338+
profiles = doc.get("profiles")
339+
if isinstance(profiles, dict) and isinstance(profiles.get(CODEX_PROFILE_NAME), dict):
340+
profiles[CODEX_PROFILE_NAME].pop("model", None)
312341
write_toml_file(LEGACY_CODEX_CONFIG_PATH, doc)
313342
state = mark_tool_managed(state, "codex", LEGACY_MANAGED_KEYS)
314343
save_state(state)
@@ -317,10 +346,18 @@ def write_tool_config(state: dict, model: str | None = None) -> dict:
317346
_remove_legacy_ucode_profile()
318347
backup_existing_file(CODEX_CONFIG_PATH, CODEX_BACKUP_PATH)
319348
overlay = render_overlay(
320-
workspace, chosen_model, databricks_profile, use_pat=bool(state.get("use_pat"))
349+
workspace,
350+
chosen_model,
351+
databricks_profile,
352+
use_pat=bool(state.get("use_pat")),
353+
provider=provider,
321354
)
322355
doc = read_toml_safe(CODEX_CONFIG_PATH)
323356
deep_merge_dict(doc, overlay)
357+
if provider:
358+
# deep_merge can't drop keys, so clear a `model` pinned by an earlier
359+
# non-provider run that the provider overlay omits.
360+
doc.pop("model", None)
324361
write_toml_file(CODEX_CONFIG_PATH, doc)
325362
state = mark_tool_managed(state, "codex", MANAGED_KEYS)
326363
save_state(state)

0 commit comments

Comments
 (0)