Skip to content

Commit f2fbb83

Browse files
committed
fix: share Claude context capability policy
Centralize Claude family/version parsing so Pi metadata, adaptive-thinking compatibility, and Claude Code's [1m] selector cannot drift. Cover Sonnet 4.5, Opus 4.6, future major versions, Fable fallback, and prefixed model IDs.
1 parent 5a46e7a commit f2fbb83

6 files changed

Lines changed: 300 additions & 19 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ucode.databricks import (
2424
build_auth_shell_command,
2525
build_tool_base_url,
26+
claude_model_supports_1m,
2627
get_databricks_token,
2728
)
2829
from ucode.launcher import exec_or_spawn
@@ -65,11 +66,6 @@ def _resolve_web_search_model(state: dict) -> str | None:
6566

6667

6768
WEB_SEARCH_MCP_NAME = "web_search"
68-
# Matches both the AI Gateway form (`databricks-claude-opus-4-8`) and the UC
69-
# model-services form (`system.ai.claude-opus-4-8`).
70-
_CLAUDE_MODEL_RE = re.compile(
71-
r"^(?:system\.ai\.)?(?:databricks-)?claude-(opus|sonnet)-(\d+)-(\d+)(.*)$"
72-
)
7369

7470
# Env keys the MLflow Stop hook reads to route traces. Written into the
7571
# settings `env` block alongside the hook itself.
@@ -242,19 +238,9 @@ def render_overlay(
242238

243239

244240
def _maybe_add_1m_suffix(model: str) -> str:
245-
if model.endswith("[1m]"):
246-
return model
247-
match = _CLAUDE_MODEL_RE.match(model)
248-
if not match:
241+
if model.endswith("[1m]") or not claude_model_supports_1m(model):
249242
return model
250-
251-
family, major_raw, minor_raw, _ = match.groups()
252-
major = int(major_raw)
253-
minor = int(minor_raw)
254-
should_suffix = (family == "opus" and (major, minor) >= (4, 6)) or (
255-
family == "sonnet" and (major, minor) >= (4, 6)
256-
)
257-
return f"{model}[1m]" if should_suffix else model
243+
return f"{model}[1m]"
258244

259245

260246
def _register_web_search_mcp(workspace: str, search_model: str, profile: str | None = None) -> bool:

src/ucode/agents/pi.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@
5151
from ucode.databricks import (
5252
TOKEN_REFRESH_INTERVAL_SECONDS,
5353
build_pi_base_urls,
54+
claude_model_capabilities,
5455
get_databricks_token,
56+
gpt_model_token_limits,
5557
model_is_reasoning,
5658
model_token_limits,
5759
)
@@ -113,6 +115,25 @@ def _resolve_model_selector(
113115
return model
114116

115117

118+
def _pi_claude_model_entry(model_id: str) -> dict:
119+
"""Build a Claude entry with explicit limits.
120+
121+
Databricks model ids do not match Pi's built-in Anthropic ids, so a bare
122+
custom entry silently gets Pi's 128k context / 4k output defaults.
123+
"""
124+
capabilities = claude_model_capabilities(model_id)
125+
entry: dict = {
126+
"id": model_id,
127+
"reasoning": True,
128+
"input": ["text", "image"],
129+
"contextWindow": capabilities.context,
130+
"maxTokens": capabilities.output,
131+
}
132+
if capabilities.force_adaptive_thinking:
133+
entry["compat"] = {"forceAdaptiveThinking": True}
134+
return entry
135+
136+
116137
def _pi_oss_model_entry(model_id: str) -> dict:
117138
"""Build a Pi mlflow model entry enriched from the shared limits/reasoning
118139
tables: `reasoning:true` for reasoning models (Pi renders their streamed
@@ -131,6 +152,23 @@ def _pi_oss_model_entry(model_id: str) -> dict:
131152
return entry
132153

133154

155+
def _pi_gpt_model_entry(model_id: str) -> dict:
156+
"""Build a Pi openai (codex) model entry with `contextWindow`/`maxTokens`
157+
from `databricks.gpt_model_token_limits`. GPT ids aren't in Pi's built-in
158+
catalog, so without an explicit window Pi falls back to a small default and
159+
truncates long sessions."""
160+
limits = gpt_model_token_limits(model_id)
161+
entry: dict = {
162+
"id": model_id,
163+
"contextWindow": limits["context"],
164+
"maxTokens": limits["output"],
165+
}
166+
if "gpt-5" in model_id.lower().replace(".", "-"):
167+
entry["reasoning"] = True
168+
entry["input"] = ["text", "image"]
169+
return entry
170+
171+
134172
def render_overlay(
135173
model: str,
136174
token: str,
@@ -159,7 +197,7 @@ def render_overlay(
159197
# the legacy beta header instead when this is false.
160198
"compat": {"supportsEagerToolInputStreaming": False},
161199
"headers": ua_headers,
162-
"models": [{"id": m} for m in claude_ids],
200+
"models": [_pi_claude_model_entry(m) for m in claude_ids],
163201
}
164202
keys.append(["providers", "databricks-claude"])
165203
if codex_models:
@@ -169,7 +207,7 @@ def render_overlay(
169207
"apiKey": token,
170208
"authHeader": True,
171209
"headers": ua_headers,
172-
"models": [{"id": m} for m in codex_models],
210+
"models": [_pi_gpt_model_entry(m) for m in codex_models],
173211
}
174212
keys.append(["providers", "databricks-openai"])
175213
if gemini_models:

src/ucode/databricks.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from concurrent.futures import (
2323
TimeoutError as FutureTimeoutError,
2424
)
25+
from dataclasses import dataclass
2526
from pathlib import Path
2627
from typing import Literal, cast, overload
2728
from urllib import error as urllib_error
@@ -1232,6 +1233,106 @@ def model_token_limits(model_id: str) -> dict[str, int] | None:
12321233
return None
12331234

12341235

1236+
# Pi treats every custom model without explicit metadata as 128k context / 4k
1237+
# output. Gateway ids are custom ids (not Pi's built-ins), so preserve the
1238+
# upstream windows explicitly. Entries are ordered most-specific first after
1239+
# normalizing dotted OpenAI ids and hyphenated Databricks ids to one form.
1240+
# GPT-5.6 Sol/Terra/Luna support the opt-in 1.05M window; 272k is merely Pi's
1241+
# built-in short-context pricing default, not the model's hard context limit.
1242+
_GPT_TOKEN_LIMITS: tuple[tuple[str, dict[str, int]], ...] = (
1243+
("gpt-5-6-sol", {"context": 1_050_000, "output": 128_000}),
1244+
("gpt-5-6-terra", {"context": 1_050_000, "output": 128_000}),
1245+
("gpt-5-6-luna", {"context": 1_050_000, "output": 128_000}),
1246+
("gpt-5-5-pro", {"context": 1_050_000, "output": 128_000}),
1247+
("gpt-5-4-pro", {"context": 1_050_000, "output": 128_000}),
1248+
("gpt-5-5", {"context": 272_000, "output": 128_000}),
1249+
("gpt-5-4-mini", {"context": 400_000, "output": 128_000}),
1250+
("gpt-5-4-nano", {"context": 400_000, "output": 128_000}),
1251+
("gpt-5-4", {"context": 272_000, "output": 128_000}),
1252+
("gpt-5", {"context": 400_000, "output": 128_000}),
1253+
("gpt-4-1", {"context": 1_047_576, "output": 32_768}),
1254+
("gpt-4o", {"context": 128_000, "output": 16_384}),
1255+
("gpt-4-turbo", {"context": 128_000, "output": 4_096}),
1256+
("gpt-4", {"context": 8_192, "output": 8_192}),
1257+
)
1258+
_GPT_FALLBACK_LIMITS = {"context": 128_000, "output": 16_384}
1259+
1260+
1261+
def _normalized_foundation_model_id(model_id: str) -> str:
1262+
"""Strip route prefixes and normalize dotted versions to hyphens."""
1263+
tail = model_id.split("/")[-1]
1264+
if tail.startswith("system.ai."):
1265+
tail = tail[len("system.ai.") :]
1266+
if tail.startswith("databricks-"):
1267+
tail = tail[len("databricks-") :]
1268+
return tail.lower().replace(".", "-")
1269+
1270+
1271+
def gpt_model_token_limits(model_id: str) -> dict[str, int]:
1272+
"""Return Pi metadata limits for a GPT (codex/openai) gateway model."""
1273+
tail = _normalized_foundation_model_id(model_id)
1274+
for family, limits in _GPT_TOKEN_LIMITS:
1275+
if tail.startswith(family):
1276+
return dict(limits)
1277+
return dict(_GPT_FALLBACK_LIMITS)
1278+
1279+
1280+
@dataclass(frozen=True)
1281+
class ClaudeModelCapabilities:
1282+
context: int
1283+
output: int
1284+
supports_1m: bool = False
1285+
force_adaptive_thinking: bool = False
1286+
1287+
1288+
_CLAUDE_FALLBACK_CAPABILITIES = ClaudeModelCapabilities(context=200_000, output=64_000)
1289+
_CLAUDE_MODEL_RE = re.compile(r"^claude-(fable|opus|sonnet|haiku)-(\d+)(?:-(\d+))?")
1290+
1291+
1292+
def claude_model_capabilities(model_id: str) -> ClaudeModelCapabilities:
1293+
"""Return the shared Claude capability policy for every agent.
1294+
1295+
Opus gained the opt-in 1M window in 4.6; Sonnet gained it in 4.5.
1296+
Sonnet 4.5 retains a 64k output cap, while later 1M tiers use 128k.
1297+
Opus 4.5, Haiku, Fable, and unrecognized ids intentionally use the
1298+
conservative 200k fallback until separately verified.
1299+
"""
1300+
tail = _normalized_foundation_model_id(model_id)
1301+
match = _CLAUDE_MODEL_RE.match(tail)
1302+
if not match:
1303+
return _CLAUDE_FALLBACK_CAPABILITIES
1304+
family, major_raw, minor_raw = match.groups()
1305+
version = (int(major_raw), int(minor_raw or 0))
1306+
if family == "opus" and version >= (4, 6):
1307+
return ClaudeModelCapabilities(
1308+
context=1_000_000,
1309+
output=128_000,
1310+
supports_1m=True,
1311+
force_adaptive_thinking=True,
1312+
)
1313+
if family == "sonnet" and version >= (4, 6):
1314+
return ClaudeModelCapabilities(
1315+
context=1_000_000,
1316+
output=128_000,
1317+
supports_1m=True,
1318+
force_adaptive_thinking=True,
1319+
)
1320+
if family == "sonnet" and version >= (4, 5):
1321+
return ClaudeModelCapabilities(context=1_000_000, output=64_000, supports_1m=True)
1322+
return _CLAUDE_FALLBACK_CAPABILITIES
1323+
1324+
1325+
def claude_model_supports_1m(model_id: str) -> bool:
1326+
"""Whether Claude Code should request the model's opt-in ``[1m]`` tier."""
1327+
return claude_model_capabilities(model_id).supports_1m
1328+
1329+
1330+
def claude_model_token_limits(model_id: str) -> dict[str, int]:
1331+
"""Return Pi metadata limits from the shared Claude capability policy."""
1332+
capabilities = claude_model_capabilities(model_id)
1333+
return {"context": capabilities.context, "output": capabilities.output}
1334+
1335+
12351336
def _model_service_id(service: dict) -> str | None:
12361337
"""Extract the `system.ai.<model-name>` id from one model-service entry.
12371338

tests/test_agent_claude.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ def test_adds_1m_suffix_for_sonnet_4_6_and_later(self):
4848
overlay["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "databricks-claude-sonnet-4-7[1m]"
4949
)
5050

51+
def test_adds_1m_suffix_for_sonnet_4_5(self):
52+
# Sonnet 4.5 supports the 1M context window (its 1M beta shipped before
53+
# Opus's), so it must get the [1m] suffix even though it predates the
54+
# Opus 4.6 floor.
55+
overlay, _ = claude.render_overlay(
56+
WS, "s4", claude_models={"sonnet": "databricks-claude-sonnet-4-5"}
57+
)
58+
assert (
59+
overlay["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "databricks-claude-sonnet-4-5[1m]"
60+
)
61+
62+
def test_does_not_add_1m_suffix_for_sonnet_4_4(self):
63+
overlay, _ = claude.render_overlay(
64+
WS, "s4", claude_models={"sonnet": "databricks-claude-sonnet-4-4"}
65+
)
66+
assert overlay["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "databricks-claude-sonnet-4-4"
67+
68+
def test_does_not_add_1m_suffix_for_opus_4_5(self):
69+
# Opus's 1M window starts at 4.6, so 4.5 stays on the default context.
70+
overlay, _ = claude.render_overlay(
71+
WS, "s4", claude_models={"opus": "databricks-claude-opus-4-5"}
72+
)
73+
assert overlay["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "databricks-claude-opus-4-5"
74+
5175
def test_does_not_add_1m_suffix_for_haiku(self):
5276
overlay, _ = claude.render_overlay(
5377
WS, "s4", claude_models={"haiku": "databricks-claude-haiku-4-6"}
@@ -72,6 +96,19 @@ def test_no_1m_suffix_for_model_services_haiku(self):
7296
)
7397
assert overlay["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "system.ai.claude-haiku-4-6"
7498

99+
@pytest.mark.parametrize(
100+
("model_id", "expected"),
101+
[
102+
("system.ai.claude-opus-5", "system.ai.claude-opus-5[1m]"),
103+
("databricks-claude-sonnet-5", "databricks-claude-sonnet-5[1m]"),
104+
("system.ai.claude-opus-4-5", "system.ai.claude-opus-4-5"),
105+
("system.ai.claude-fable-5", "system.ai.claude-fable-5"),
106+
("not-a-claude-model", "not-a-claude-model"),
107+
],
108+
)
109+
def test_suffix_uses_shared_capability_policy(self, model_id, expected):
110+
assert claude._maybe_add_1m_suffix(model_id) == expected
111+
75112
def test_sets_anthropic_base_url(self):
76113
overlay, _ = claude.render_overlay(WS, "s4")
77114
assert overlay["env"]["ANTHROPIC_BASE_URL"] == f"{WS}/ai-gateway/anthropic"

tests/test_agent_pi.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,57 @@ def test_openai_provider_uses_openai_responses(self):
7878
assert provider["api"] == "openai-responses"
7979
assert provider["baseUrl"] == f"{WS}/ai-gateway/codex/v1"
8080

81+
def test_gpt56_sol_model_entry_pins_1m_context(self):
82+
# Gateway ids are custom to Pi, so explicit metadata is required to
83+
# avoid its 128k custom-model default.
84+
overlay, _ = _overlay("gpt-5-6-sol", codex_models=["gpt-5-6-sol"])
85+
entry = overlay["providers"]["databricks-openai"]["models"][0]
86+
assert entry["id"] == "gpt-5-6-sol"
87+
assert entry["contextWindow"] == 1_050_000
88+
assert entry["maxTokens"] == 128_000
89+
assert entry["reasoning"] is True
90+
assert entry["input"] == ["text", "image"]
91+
92+
def test_gpt_model_entries_use_model_specific_windows(self):
93+
overlay, _ = _overlay(
94+
"system.ai.gpt-5-2",
95+
codex_models=[
96+
"system.ai.gpt-5-2",
97+
"databricks-gpt-5-4-nano",
98+
"databricks-gpt-5-6-sol",
99+
],
100+
)
101+
windows = {
102+
m["id"]: m["contextWindow"] for m in overlay["providers"]["databricks-openai"]["models"]
103+
}
104+
assert windows == {
105+
"system.ai.gpt-5-2": 400_000,
106+
"databricks-gpt-5-4-nano": 400_000,
107+
"databricks-gpt-5-6-sol": 1_050_000,
108+
}
109+
110+
def test_claude_entries_pin_limits_and_capabilities(self):
111+
overlay, _ = _overlay(
112+
"databricks-claude-opus-4-8",
113+
claude_models={
114+
"opus": "databricks-claude-opus-4-8",
115+
"sonnet": "system.ai.claude-sonnet-4-5",
116+
"haiku": "databricks-claude-haiku-4-5",
117+
"fable": "system.ai.claude-fable-5",
118+
},
119+
)
120+
entries = {m["id"]: m for m in overlay["providers"]["databricks-claude"]["models"]}
121+
opus = entries["databricks-claude-opus-4-8"]
122+
assert opus["contextWindow"] == 1_000_000
123+
assert opus["maxTokens"] == 128_000
124+
assert opus["reasoning"] is True
125+
assert opus["input"] == ["text", "image"]
126+
assert opus["compat"] == {"forceAdaptiveThinking": True}
127+
assert entries["system.ai.claude-sonnet-4-5"]["contextWindow"] == 1_000_000
128+
assert entries["system.ai.claude-sonnet-4-5"]["maxTokens"] == 64_000
129+
assert entries["databricks-claude-haiku-4-5"]["contextWindow"] == 200_000
130+
assert entries["system.ai.claude-fable-5"]["contextWindow"] == 200_000
131+
81132
def test_gemini_provider_uses_google_generative_ai(self):
82133
overlay, _ = _overlay("gemini-2", gemini_models=["gemini-2"])
83134
provider = overlay["providers"]["databricks-gemini"]

0 commit comments

Comments
 (0)