Skip to content

Commit a6359d3

Browse files
committed
fix: declare long-context Claude and GPT metadata
1 parent ededa9c commit a6359d3

6 files changed

Lines changed: 244 additions & 3 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,12 @@ def _maybe_add_1m_suffix(model: str) -> str:
232232
family, major_raw, minor_raw, _ = match.groups()
233233
major = int(major_raw)
234234
minor = int(minor_raw)
235+
# 1M-token context window support: Opus from 4.6, Sonnet from 4.5 (Sonnet
236+
# 4.5 gained the 1M beta window before Opus did). Without the `[1m]`
237+
# suffix the gateway serves these models with the default 200k context, so
238+
# gate each family at its own floor rather than a shared version.
235239
should_suffix = (family == "opus" and (major, minor) >= (4, 6)) or (
236-
family == "sonnet" and (major, minor) >= (4, 6)
240+
family == "sonnet" and (major, minor) >= (4, 5)
237241
)
238242
return f"{model}[1m]" if should_suffix else model
239243

src/ucode/agents/pi.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from __future__ import annotations
3939

4040
import os
41+
import re
4142
import signal
4243
import subprocess
4344
import threading
@@ -55,7 +56,9 @@
5556
from ucode.databricks import (
5657
TOKEN_REFRESH_INTERVAL_SECONDS,
5758
build_pi_base_urls,
59+
claude_model_token_limits,
5860
get_databricks_token,
61+
gpt_model_token_limits,
5962
model_is_reasoning,
6063
model_token_limits,
6164
)
@@ -117,6 +120,26 @@ def _resolve_model_selector(
117120
return model
118121

119122

123+
def _pi_claude_model_entry(model_id: str) -> dict:
124+
"""Build a Claude entry with explicit limits.
125+
126+
Databricks model ids do not match Pi's built-in Anthropic ids, so a bare
127+
custom entry silently gets Pi's 128k context / 4k output defaults.
128+
"""
129+
limits = claude_model_token_limits(model_id)
130+
entry: dict = {
131+
"id": model_id,
132+
"reasoning": True,
133+
"input": ["text", "image"],
134+
"contextWindow": limits["context"],
135+
"maxTokens": limits["output"],
136+
}
137+
normalized = model_id.lower().replace(".", "-")
138+
if re.search(r"claude-(?:opus|sonnet)-4-(?:[6-9]|[1-9]\d)", normalized):
139+
entry["compat"] = {"forceAdaptiveThinking": True}
140+
return entry
141+
142+
120143
def _pi_oss_model_entry(model_id: str) -> dict:
121144
"""Build a Pi mlflow model entry enriched from the shared limits/reasoning
122145
tables: `reasoning:true` for reasoning models (Pi renders their streamed
@@ -135,6 +158,23 @@ def _pi_oss_model_entry(model_id: str) -> dict:
135158
return entry
136159

137160

161+
def _pi_gpt_model_entry(model_id: str) -> dict:
162+
"""Build a Pi openai (codex) model entry with `contextWindow`/`maxTokens`
163+
from `databricks.gpt_model_token_limits`. GPT ids aren't in Pi's built-in
164+
catalog, so without an explicit window Pi falls back to a small default and
165+
truncates long sessions."""
166+
limits = gpt_model_token_limits(model_id)
167+
entry: dict = {
168+
"id": model_id,
169+
"contextWindow": limits["context"],
170+
"maxTokens": limits["output"],
171+
}
172+
if "gpt-5" in model_id.lower().replace(".", "-"):
173+
entry["reasoning"] = True
174+
entry["input"] = ["text", "image"]
175+
return entry
176+
177+
138178
def render_overlay(
139179
model: str,
140180
token: str,
@@ -163,7 +203,7 @@ def render_overlay(
163203
# the legacy beta header instead when this is false.
164204
"compat": {"supportsEagerToolInputStreaming": False},
165205
"headers": ua_headers,
166-
"models": [{"id": m} for m in claude_ids],
206+
"models": [_pi_claude_model_entry(m) for m in claude_ids],
167207
}
168208
keys.append(["providers", "databricks-claude"])
169209
if codex_models:
@@ -173,7 +213,7 @@ def render_overlay(
173213
"apiKey": token,
174214
"authHeader": True,
175215
"headers": ua_headers,
176-
"models": [{"id": m} for m in codex_models],
216+
"models": [_pi_gpt_model_entry(m) for m in codex_models],
177217
}
178218
keys.append(["providers", "databricks-openai"])
179219
if gemini_models:

src/ucode/databricks.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,76 @@ def model_token_limits(model_id: str) -> dict[str, int] | None:
11841184
return None
11851185

11861186

1187+
# Pi treats every custom model without explicit metadata as 128k context / 4k
1188+
# output. Gateway ids are custom ids (not Pi's built-ins), so preserve the
1189+
# upstream windows explicitly. Entries are ordered most-specific first after
1190+
# normalizing dotted OpenAI ids and hyphenated Databricks ids to one form.
1191+
# GPT-5.6 Sol/Terra/Luna support the opt-in 1.05M window; 272k is merely Pi's
1192+
# built-in short-context pricing default, not the model's hard context limit.
1193+
_GPT_TOKEN_LIMITS: tuple[tuple[str, dict[str, int]], ...] = (
1194+
("gpt-5-6-sol", {"context": 1_050_000, "output": 128_000}),
1195+
("gpt-5-6-terra", {"context": 1_050_000, "output": 128_000}),
1196+
("gpt-5-6-luna", {"context": 1_050_000, "output": 128_000}),
1197+
("gpt-5-5-pro", {"context": 1_050_000, "output": 128_000}),
1198+
("gpt-5-4-pro", {"context": 1_050_000, "output": 128_000}),
1199+
("gpt-5-5", {"context": 272_000, "output": 128_000}),
1200+
("gpt-5-4-mini", {"context": 400_000, "output": 128_000}),
1201+
("gpt-5-4-nano", {"context": 400_000, "output": 128_000}),
1202+
("gpt-5-4", {"context": 272_000, "output": 128_000}),
1203+
("gpt-5", {"context": 400_000, "output": 128_000}),
1204+
("gpt-4-1", {"context": 1_047_576, "output": 32_768}),
1205+
("gpt-4o", {"context": 128_000, "output": 16_384}),
1206+
("gpt-4-turbo", {"context": 128_000, "output": 4_096}),
1207+
("gpt-4", {"context": 8_192, "output": 8_192}),
1208+
)
1209+
_GPT_FALLBACK_LIMITS = {"context": 128_000, "output": 16_384}
1210+
1211+
1212+
def _normalized_foundation_model_id(model_id: str) -> str:
1213+
"""Strip route prefixes and normalize dotted versions to hyphens."""
1214+
tail = model_id.split("/")[-1]
1215+
if tail.startswith("system.ai."):
1216+
tail = tail[len("system.ai.") :]
1217+
if tail.startswith("databricks-"):
1218+
tail = tail[len("databricks-") :]
1219+
return tail.lower().replace(".", "-")
1220+
1221+
1222+
def gpt_model_token_limits(model_id: str) -> dict[str, int]:
1223+
"""Return Pi metadata limits for a GPT (codex/openai) gateway model."""
1224+
tail = _normalized_foundation_model_id(model_id)
1225+
for family, limits in _GPT_TOKEN_LIMITS:
1226+
if tail.startswith(family):
1227+
return dict(limits)
1228+
return dict(_GPT_FALLBACK_LIMITS)
1229+
1230+
1231+
_CLAUDE_FALLBACK_LIMITS = {"context": 200_000, "output": 64_000}
1232+
1233+
1234+
def claude_model_token_limits(model_id: str) -> dict[str, int]:
1235+
"""Return Pi metadata limits for a Claude gateway model.
1236+
1237+
Claude gateway ids are custom to Pi, so omitting these fields silently
1238+
applies Pi's 128k/4k custom-model defaults. Current 1M families mirror
1239+
Anthropic's model metadata: Opus >=4.6 and Sonnet >=4.5. Sonnet 4.5 has a
1240+
64k output cap; later 1M models have 128k output.
1241+
"""
1242+
tail = _normalized_foundation_model_id(model_id)
1243+
match = re.match(r"claude-(opus|sonnet|haiku)-(\d+)-(\d+)", tail)
1244+
if not match:
1245+
return dict(_CLAUDE_FALLBACK_LIMITS)
1246+
family, major_raw, minor_raw = match.groups()
1247+
version = (int(major_raw), int(minor_raw))
1248+
if family == "opus" and version >= (4, 6):
1249+
return {"context": 1_000_000, "output": 128_000}
1250+
if family == "sonnet" and version >= (4, 6):
1251+
return {"context": 1_000_000, "output": 128_000}
1252+
if family == "sonnet" and version >= (4, 5):
1253+
return {"context": 1_000_000, "output": 64_000}
1254+
return dict(_CLAUDE_FALLBACK_LIMITS)
1255+
1256+
11871257
def _model_service_id(service: dict) -> str | None:
11881258
"""Extract the `system.ai.<model-name>` id from one model-service entry.
11891259

tests/test_agent_claude.py

Lines changed: 24 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"}

tests/test_agent_pi.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,55 @@ 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+
},
118+
)
119+
entries = {m["id"]: m for m in overlay["providers"]["databricks-claude"]["models"]}
120+
opus = entries["databricks-claude-opus-4-8"]
121+
assert opus["contextWindow"] == 1_000_000
122+
assert opus["maxTokens"] == 128_000
123+
assert opus["reasoning"] is True
124+
assert opus["input"] == ["text", "image"]
125+
assert opus["compat"] == {"forceAdaptiveThinking": True}
126+
assert entries["system.ai.claude-sonnet-4-5"]["contextWindow"] == 1_000_000
127+
assert entries["system.ai.claude-sonnet-4-5"]["maxTokens"] == 64_000
128+
assert entries["databricks-claude-haiku-4-5"]["contextWindow"] == 200_000
129+
81130
def test_gemini_provider_uses_google_generative_ai(self):
82131
overlay, _ = _overlay("gemini-2", gemini_models=["gemini-2"])
83132
provider = overlay["providers"]["databricks-gemini"]

tests/test_databricks.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,60 @@ def test_embedding_model_returns_none_not_fallback(self):
187187
assert db_mod.model_token_limits("system.ai.qwen3-embedding-0-6b") is None
188188

189189

190+
class TestGptModelTokenLimits:
191+
def test_gpt56_sol_serves_1m_context_across_id_forms(self):
192+
for model_id in (
193+
"gpt-5.6-sol",
194+
"system.ai.gpt-5-6-sol",
195+
"databricks-gpt-5-6-sol",
196+
"databricks-openai/gpt-5.6-sol",
197+
):
198+
assert db_mod.gpt_model_token_limits(model_id) == {
199+
"context": 1_050_000,
200+
"output": 128_000,
201+
}
202+
203+
def test_gpt5_windows_are_model_specific(self):
204+
assert db_mod.gpt_model_token_limits("system.ai.gpt-5-2")["context"] == 400_000
205+
assert db_mod.gpt_model_token_limits("databricks-gpt-5-4")["context"] == 272_000
206+
assert db_mod.gpt_model_token_limits("databricks-gpt-5-4-nano")["context"] == 400_000
207+
assert db_mod.gpt_model_token_limits("gpt-5.5-pro")["context"] == 1_050_000
208+
209+
def test_gpt41_window(self):
210+
assert db_mod.gpt_model_token_limits("databricks-gpt-4-1") == {
211+
"context": 1_047_576,
212+
"output": 32_768,
213+
}
214+
215+
def test_unknown_gpt_uses_conservative_floor(self):
216+
assert db_mod.gpt_model_token_limits("gpt-6-turbo") == {
217+
"context": 128_000,
218+
"output": 16_384,
219+
}
220+
221+
222+
class TestClaudeModelTokenLimits:
223+
def test_1m_models_and_output_caps(self):
224+
assert db_mod.claude_model_token_limits("databricks-claude-opus-4-8") == {
225+
"context": 1_000_000,
226+
"output": 128_000,
227+
}
228+
assert db_mod.claude_model_token_limits("system.ai.claude-sonnet-4-5") == {
229+
"context": 1_000_000,
230+
"output": 64_000,
231+
}
232+
assert db_mod.claude_model_token_limits("claude-sonnet-4-6[1m]") == {
233+
"context": 1_000_000,
234+
"output": 128_000,
235+
}
236+
237+
def test_older_and_unknown_claude_use_200k_floor(self):
238+
expected = {"context": 200_000, "output": 64_000}
239+
assert db_mod.claude_model_token_limits("claude-opus-4-5") == expected
240+
assert db_mod.claude_model_token_limits("claude-haiku-4-5") == expected
241+
assert db_mod.claude_model_token_limits("claude-future") == expected
242+
243+
190244
class TestModelIsReasoning:
191245
def test_reasoning_families(self):
192246
assert db_mod.model_is_reasoning("system.ai.glm-5-2") is True

0 commit comments

Comments
 (0)