|
22 | 22 | from concurrent.futures import ( |
23 | 23 | TimeoutError as FutureTimeoutError, |
24 | 24 | ) |
| 25 | +from dataclasses import dataclass |
25 | 26 | from pathlib import Path |
26 | 27 | from typing import Literal, cast, overload |
27 | 28 | from urllib import error as urllib_error |
@@ -1235,6 +1236,136 @@ def model_token_limits(model_id: str) -> dict[str, int] | None: |
1235 | 1236 | return None |
1236 | 1237 |
|
1237 | 1238 |
|
| 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 | + |
1238 | 1369 | def _model_service_id(service: dict) -> str | None: |
1239 | 1370 | """Extract the `system.ai.<model-name>` id from one model-service entry. |
1240 | 1371 |
|
|
0 commit comments