Skip to content

Commit 636c3b6

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 65b4609 commit 636c3b6

6 files changed

Lines changed: 389 additions & 25 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: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@
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,
59+
preferred_gpt_model,
5760
)
5861
from ucode.state import mark_tool_managed, save_state
5962
from ucode.telemetry import agent_version, ucode_version
@@ -113,6 +116,25 @@ def _resolve_model_selector(
113116
return model
114117

115118

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

133155

156+
def _pi_gpt_model_entry(model_id: str) -> dict:
157+
"""Build a Pi openai (codex) model entry with `contextWindow`/`maxTokens`
158+
from `databricks.gpt_model_token_limits`. GPT ids aren't in Pi's built-in
159+
catalog, so without an explicit window Pi falls back to a small default and
160+
truncates long sessions."""
161+
limits = gpt_model_token_limits(model_id)
162+
entry: dict = {
163+
"id": model_id,
164+
"contextWindow": limits["context"],
165+
"maxTokens": limits["output"],
166+
}
167+
if "gpt-5" in model_id.lower().replace(".", "-"):
168+
entry["reasoning"] = True
169+
entry["input"] = ["text", "image"]
170+
return entry
171+
172+
134173
def render_overlay(
135174
model: str,
136175
token: str,
@@ -159,7 +198,7 @@ def render_overlay(
159198
# the legacy beta header instead when this is false.
160199
"compat": {"supportsEagerToolInputStreaming": False},
161200
"headers": ua_headers,
162-
"models": [{"id": m} for m in claude_ids],
201+
"models": [_pi_claude_model_entry(m) for m in claude_ids],
163202
}
164203
keys.append(["providers", "databricks-claude"])
165204
if codex_models:
@@ -169,7 +208,7 @@ def render_overlay(
169208
"apiKey": token,
170209
"authHeader": True,
171210
"headers": ua_headers,
172-
"models": [{"id": m} for m in codex_models],
211+
"models": [_pi_gpt_model_entry(m) for m in codex_models],
173212
}
174213
keys.append(["providers", "databricks-openai"])
175214
if gemini_models:
@@ -259,9 +298,9 @@ def default_model(state: dict) -> str | None:
259298
for family in ("opus", "sonnet", "haiku"):
260299
if claude_models.get(family):
261300
return claude_models[family]
262-
codex_models = state.get("codex_models") or []
263-
if codex_models:
264-
return codex_models[0]
301+
codex_model = preferred_gpt_model(state.get("codex_models") or [])
302+
if codex_model:
303+
return codex_model
265304
gemini_models = state.get("gemini_models") or []
266305
if gemini_models:
267306
return gemini_models[0]

src/ucode/databricks.py

Lines changed: 131 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
@@ -1235,6 +1236,136 @@ def model_token_limits(model_id: str) -> dict[str, int] | None:
12351236
return None
12361237

12371238

1239+
# Pi treats every custom model without explicit metadata as 128k context / 4k
1240+
# output. Gateway ids are custom ids (not Pi's built-ins), so preserve the
1241+
# upstream windows explicitly. Entries are ordered most-specific first after
1242+
# normalizing dotted OpenAI ids and hyphenated Databricks ids to one form.
1243+
# GPT-5.6 Sol/Terra/Luna support the opt-in 1.05M window; 272k is merely Pi's
1244+
# built-in short-context pricing default, not the model's hard context limit.
1245+
_GPT_TOKEN_LIMITS: tuple[tuple[str, dict[str, int]], ...] = (
1246+
("gpt-5-6-sol", {"context": 1_050_000, "output": 128_000}),
1247+
("gpt-5-6-terra", {"context": 1_050_000, "output": 128_000}),
1248+
("gpt-5-6-luna", {"context": 1_050_000, "output": 128_000}),
1249+
("gpt-5-5-pro", {"context": 1_050_000, "output": 128_000}),
1250+
("gpt-5-4-pro", {"context": 1_050_000, "output": 128_000}),
1251+
("gpt-5-5", {"context": 272_000, "output": 128_000}),
1252+
("gpt-5-4-mini", {"context": 400_000, "output": 128_000}),
1253+
("gpt-5-4-nano", {"context": 400_000, "output": 128_000}),
1254+
("gpt-5-4", {"context": 272_000, "output": 128_000}),
1255+
("gpt-5", {"context": 400_000, "output": 128_000}),
1256+
("gpt-4-1", {"context": 1_047_576, "output": 32_768}),
1257+
("gpt-4o", {"context": 128_000, "output": 16_384}),
1258+
("gpt-4-turbo", {"context": 128_000, "output": 4_096}),
1259+
("gpt-4", {"context": 8_192, "output": 8_192}),
1260+
)
1261+
_GPT_FALLBACK_LIMITS = {"context": 128_000, "output": 16_384}
1262+
1263+
1264+
def _normalized_foundation_model_id(model_id: str) -> str:
1265+
"""Strip route prefixes case-insensitively and normalize dotted versions."""
1266+
tail = model_id.split("/")[-1].lower()
1267+
if tail.startswith("system.ai."):
1268+
tail = tail[len("system.ai.") :]
1269+
if tail.startswith("databricks-"):
1270+
tail = tail[len("databricks-") :]
1271+
return tail.replace(".", "-")
1272+
1273+
1274+
def gpt_model_token_limits(model_id: str) -> dict[str, int]:
1275+
"""Return Pi metadata limits for a GPT (codex/openai) gateway model."""
1276+
tail = _normalized_foundation_model_id(model_id)
1277+
for family, limits in _GPT_TOKEN_LIMITS:
1278+
if tail == family or tail.startswith(f"{family}-"):
1279+
return dict(limits)
1280+
return dict(_GPT_FALLBACK_LIMITS)
1281+
1282+
1283+
def preferred_gpt_model(model_ids: list[str]) -> str | None:
1284+
"""Prefer the newest numeric GPT id, then any other Responses endpoint.
1285+
1286+
``gpt-oss`` is chat-completions-only and must never be selected for the
1287+
Responses route, even if stale state supplies it here.
1288+
"""
1289+
eligible = [
1290+
model_id
1291+
for model_id in model_ids
1292+
if not _normalized_foundation_model_id(model_id).startswith("gpt-oss")
1293+
]
1294+
numeric_gpt = [
1295+
model_id
1296+
for model_id in eligible
1297+
if re.match(r"^gpt-\d(?:-|$)", _normalized_foundation_model_id(model_id))
1298+
]
1299+
if numeric_gpt:
1300+
return min(
1301+
numeric_gpt,
1302+
key=lambda model_id: model_version_sort_key(_normalized_foundation_model_id(model_id)),
1303+
)
1304+
return eligible[0] if eligible else None
1305+
1306+
1307+
@dataclass(frozen=True)
1308+
class ClaudeModelCapabilities:
1309+
context: int
1310+
output: int
1311+
supports_1m: bool = False
1312+
force_adaptive_thinking: bool = False
1313+
1314+
1315+
_CLAUDE_FALLBACK_CAPABILITIES = ClaudeModelCapabilities(context=200_000, output=64_000)
1316+
_CLAUDE_MODEL_RE = re.compile(r"^claude-(fable|opus|sonnet|haiku)-(\d+)(?:-(\d+))?")
1317+
1318+
1319+
def claude_model_capabilities(model_id: str) -> ClaudeModelCapabilities:
1320+
"""Return the shared Claude capability policy for every agent.
1321+
1322+
Opus gained the opt-in 1M window in 4.6; Sonnet gained it in 4.5.
1323+
Sonnet's verified 1M tiers retain a conservative 64k output cap. Fable 5
1324+
is 1M by default (so it needs no ``[1m]`` suffix) with a 128k output cap.
1325+
Opus 4.5, Haiku, and unrecognized ids use the conservative 200k fallback.
1326+
"""
1327+
tail = _normalized_foundation_model_id(model_id)
1328+
match = _CLAUDE_MODEL_RE.match(tail)
1329+
if not match:
1330+
return _CLAUDE_FALLBACK_CAPABILITIES
1331+
family, major_raw, minor_raw = match.groups()
1332+
version = (int(major_raw), int(minor_raw or 0))
1333+
if family == "opus" and version >= (4, 6):
1334+
return ClaudeModelCapabilities(
1335+
context=1_000_000,
1336+
output=128_000,
1337+
supports_1m=True,
1338+
force_adaptive_thinking=True,
1339+
)
1340+
if family == "sonnet" and version >= (4, 6):
1341+
return ClaudeModelCapabilities(
1342+
context=1_000_000,
1343+
output=64_000,
1344+
supports_1m=True,
1345+
force_adaptive_thinking=True,
1346+
)
1347+
if family == "sonnet" and version >= (4, 5):
1348+
return ClaudeModelCapabilities(context=1_000_000, output=64_000, supports_1m=True)
1349+
if family == "fable" and version >= (5, 0):
1350+
return ClaudeModelCapabilities(
1351+
context=1_000_000,
1352+
output=128_000,
1353+
force_adaptive_thinking=True,
1354+
)
1355+
return _CLAUDE_FALLBACK_CAPABILITIES
1356+
1357+
1358+
def claude_model_supports_1m(model_id: str) -> bool:
1359+
"""Whether Claude Code should request the model's opt-in ``[1m]`` tier."""
1360+
return claude_model_capabilities(model_id).supports_1m
1361+
1362+
1363+
def claude_model_token_limits(model_id: str) -> dict[str, int]:
1364+
"""Return Pi metadata limits from the shared Claude capability policy."""
1365+
capabilities = claude_model_capabilities(model_id)
1366+
return {"context": capabilities.context, "output": capabilities.output}
1367+
1368+
12381369
def _model_service_id(service: dict) -> str | None:
12391370
"""Extract the `system.ai.<model-name>` id from one model-service entry.
12401371

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"

0 commit comments

Comments
 (0)