Skip to content
Source
Sign inSign up

ClaudeModelCatalog.ts

532 lines18.9 KB

1import {
2 type CustomModelSetting,
3 type ModelCapabilities,
4 type ModelSelection,
5 ProviderDriverKind,
6 type ServerProviderModel,
7 type ServerProviderUpdateRequiredModel,
8} from "@t3tools/contracts";
9import type { ModelInfo as ClaudeModelInfo } from "@anthropic-ai/claude-agent-sdk";
10import * as Option from "effect/Option";
11import {
12 getModelSelectionStringOptionValue,
13 getProviderOptionCurrentValue,
14 getProviderOptionDescriptors,
15 readCustomModelEntries,
16} from "@t3tools/shared/model";
17import { compareSemverVersions } from "@t3tools/shared/semver";
18​
19import {
20 type ClaudeCodeCompatibility,
21 type ClaudeCodeProfile,
22 decodeClaudeModelAdapter,
23 decodeClaudeProfileAdapter,
24} from "./ClaudeModelManifest.ts";
25import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
26import { BUNDLED_MODEL_MANIFEST, resolveProviderCatalog } from "./ModelManifest.ts";
27​
28const CLAUDE = ProviderDriverKind.make("claudeAgent");
29const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] };
30​
31export interface ClaudeCatalogModel {
32 readonly model: ServerProviderModel;
33 readonly runtime: ClaudeCodeProfile;
34 readonly compatibility: ClaudeCodeCompatibility;
36​
37export interface ClaudeModelCatalog {
38 readonly models: ReadonlyArray<ClaudeCatalogModel>;
40​
41function tryResolveClaudeModelCatalog(
42 resolved: ModelCatalog.ProviderCatalog | undefined,
43): ClaudeModelCatalog | null {
44 if (!resolved) return null;
45​
46 const models: Array<ClaudeCatalogModel> = [];
47 for (const entry of resolved.models) {
48 const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {});
49 const adapter = decodeClaudeModelAdapter(entry.adapter ?? {});
50 if (Option.isNone(profile) || Option.isNone(adapter)) return null;
51 const model = ModelCatalog.catalogServerModel(resolved, entry);
52 models.push({
53 model: { ...model, capabilities: withUltracodeToggle(model.capabilities) },
54 runtime: profile.value.claudeCode ?? {},
55 compatibility: adapter.value.claudeCode ?? {},
56 });
57 }
58​
59 return {
60 models,
61 };
63​
64// The manifest keeps Ultracode as an effort level because released servers read it from main.
65export function withUltracodeToggle(
66 capabilities: ModelCapabilities | null,
67): ModelCapabilities | null {
68 const descriptors = capabilities?.optionDescriptors ?? [];
69 const effortIndex = descriptors.findIndex(
70 (descriptor) =>
71 descriptor.id === "effort" &&
72 descriptor.type === "select" &&
73 descriptor.options.some((option) => option.id === "ultracode"),
74 );
75 const effort = descriptors[effortIndex];
76 if (effort?.type !== "select") return capabilities;
77 const ultracodeByDefault =
78 (effort.currentValue ?? effort.options.find((option) => option.isDefault)?.id) === "ultracode";
79 const hasXhigh = effort.options.some((option) => option.id === "xhigh");
80 // Ultracode ran at xhigh, so a model that offered it keeps xhigh, and its default moves there.
81 const options = effort.options.flatMap(({ isDefault, ...option }) => {
82 if (option.id === "ultracode") {
83 return hasXhigh
84 ? []
85 : [{ id: "xhigh", label: "Extra High", ...(isDefault ? { isDefault } : {}) }];
86 }
87 if (ultracodeByDefault && option.id === "xhigh") return [{ ...option, isDefault: true }];
88 return [isDefault && !ultracodeByDefault ? { ...option, isDefault } : option];
89 });
90 return {
91 optionDescriptors: descriptors.toSpliced(
92 effortIndex,
93 1,
94 {
95 ...effort,
96 ...(effort.currentValue === "ultracode" ? { currentValue: "xhigh" } : {}),
97 options,
98 },
99 {
100 id: "ultracode",
101 label: "Ultracode",
102 type: "boolean",
103 ...(ultracodeByDefault ? { currentValue: true } : {}),
104 },
105 ),
106 };
108​
109/** Resolve the fallback on each read so its new-model badges can expire. */
110export function resolveClaudeModelCatalog(
111 catalog: ModelCatalog.ProviderCatalog | undefined,
112): ClaudeModelCatalog {
113 return (
114 tryResolveClaudeModelCatalog(catalog) ??
115 tryResolveClaudeModelCatalog(
116 resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined,
117 ) ?? {
118 models: [],
119 }
120 );
122​
123export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(undefined);
124​
125export function scopeClaudeModelCatalog(
126 catalog: ClaudeModelCatalog,
127 customModels: ReadonlyArray<CustomModelSetting>,
128): ClaudeModelCatalog {
129 const customEntries = readCustomModelEntries(customModels);
130 if (customEntries.length === 0) return catalog;
131 const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase()));
132​
133 const builtInModels = catalog.models.map((entry) => {
134 if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
135 return entry;
136 }
137 return {
138 ...entry,
139 model: {
140 ...entry.model,
141 aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
142 },
143 };
144 });
145 const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
146 const customCatalogModels: Array<ClaudeCatalogModel> = [];
147 for (const entry of customEntries) {
148 if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue;
149 customCatalogModels.push({
150 model: {
151 slug: entry.slug,
152 name: entry.name,
153 isCustom: true,
154 capabilities: withUltracodeToggle(entry.capabilities),
155 },
156 runtime: {},
157 compatibility: {},
158 });
159 }
160​
161 return { models: [...builtInModels, ...customCatalogModels] };
163​
164const CLAUDE_EFFORT_LABELS: Readonly<Record<string, string>> = {
165 low: "Low",
166 medium: "Medium",
167 high: "High",
168 xhigh: "Extra High",
169 max: "Max",
170};
171​
172function stripClaudeContextWindowSuffix(value: string): string {
173 return value.replace(/\[[^\]]*\]$/, "").trim();
175​
176export function claudeReportedModels(
177 catalog: ClaudeModelCatalog,
178 customModels: ReadonlyArray<CustomModelSetting>,
179 reported: ReadonlyArray<ClaudeModelInfo> | undefined,
180): ReadonlyArray<ServerProviderModel> {
181 if (!reported?.length) return [];
182 const known = new Set(
183 [
184 ...catalog.models.flatMap(({ model }) => [model.slug, ...(model.aliases ?? [])]),
185 ...readCustomModelEntries(customModels).map((entry) => entry.slug),
186 ].map((id) => stripClaudeContextWindowSuffix(id).toLowerCase()),
187 );
188 const models: Array<ServerProviderModel> = [];
189 for (const info of reported) {
190 const slug = info.value.trim();
191 // `default` is Claude Code's pointer at its own default model, not a model.
192 if (!slug || slug === "default") continue;
193 const ids = [slug, info.resolvedModel ?? ""]
194 .map((id) => stripClaudeContextWindowSuffix(id).toLowerCase())
195 .filter((id) => id.length > 0);
196 if (ids.some((id) => known.has(id))) continue;
197 for (const id of ids) known.add(id);
198 const efforts = info.supportsEffort ? (info.supportedEffortLevels ?? []) : [];
199 models.push({
200 slug,
201 name: info.displayName.trim() || slug,
202 isCustom: false,
203 capabilities: {
204 optionDescriptors: efforts.length
205 ? [
206 {
207 id: "effort",
208 label: "Reasoning",
209 type: "select",
210 // `default` is not an effort, so choosing it sends none and Claude Code picks.
211 options: [
212 { id: "default", label: "Default", isDefault: true },
213 ...efforts.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id] ?? id })),
214 ],
215 },
216 ]
217 : [],
218 },
219 });
220 }
221 return models;
223​
224function resolveClaudeCatalogModel(
225 catalog: ClaudeModelCatalog,
226 slugOrAlias: string | null | undefined,
227): ClaudeCatalogModel | undefined {
228 const value = slugOrAlias?.trim();
229 if (!value) return undefined;
230 return (
231 catalog.models.find((entry) => entry.model.slug === value) ??
232 catalog.models.find((entry) =>
233 entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()),
234 )
235 );
237​
238export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string {
239 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias;
241​
242/** Claude reports dated IDs, such as `claude-opus-4-5-20251101`, that the catalog may not list. */
243export function resolveClaudeReportedModelSlug(
244 catalog: ClaudeModelCatalog,
245 reported: string,
246): string {
247 return (
248 (
249 resolveClaudeCatalogModel(catalog, reported) ??
250 resolveClaudeCatalogModel(catalog, reported.replace(/-\d{8}$/, ""))
251 )?.model.slug ?? reported
252 );
254​
255export function getClaudeCatalogModelCapabilities(
256 catalog: ClaudeModelCatalog,
257 slugOrAlias: string | null | undefined,
258): ModelCapabilities {
259 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES;
261​
262function isVersionSupported(
263 compatibility: ClaudeCodeCompatibility,
264 version: string | null | undefined,
265): boolean {
266 if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true;
267 if (!version) return false;
268 if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) {
269 return false;
270 }
271 return !(
272 compatibility.maxVersionExclusive &&
273 compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0
274 );
276​
277export function resolveClaudeModelsForVersion(
278 catalog: ClaudeModelCatalog,
279 version: string | null | undefined,
280): ReadonlyArray<ClaudeCatalogModel["model"]> {
281 return catalog.models
282 .filter((entry) => isVersionSupported(entry.compatibility, version))
283 .map((entry) => entry.model);
285​
286export function resolveClaudeUpdateRequiredModels(
287 catalog: ClaudeModelCatalog,
288 version: string | null | undefined,
289): ReadonlyArray<ServerProviderUpdateRequiredModel> {
290 if (!version) return [];
291 return catalog.models.flatMap(({ model, compatibility }) => {
292 const minVersion = compatibility.minVersion;
293 if (model.isLegacy || !minVersion || compareSemverVersions(version, minVersion) >= 0) {
294 return [];
295 }
296 return [
297 {
298 slug: model.slug,
299 name: model.name,
300 ...(model.badge ? { badge: model.badge } : {}),
301 minVersion,
302 },
303 ];
304 });
306​
307export function formatClaudeVersionUpgradeMessage(
308 catalog: ClaudeModelCatalog,
309 version: string | null,
310): string | undefined {
311 const unavailable = catalog.models
312 .filter(
313 (entry) =>
314 entry.compatibility.minVersion &&
315 (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0),
316 )
317 .toSorted((left, right) =>
318 compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!),
319 )[0];
320 if (!unavailable?.compatibility.minVersion) return undefined;
321 const versionLabel = version ? `v${version}` : "the installed version";
322 return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`;
324​
325/** Claude Code's effort levels, lowest first. */
326export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
327export type ClaudeEffortLevel = (typeof CLAUDE_EFFORT_LEVELS)[number];
328​
329function claudeEffortRank(level: string | undefined): number {
330 return CLAUDE_EFFORT_LEVELS.indexOf(level as ClaudeEffortLevel);
332​
333/** One settings source's effort caps, with `modelSettings` keys as the user wrote them. */
334export interface ClaudeEffortCapSource {
335 readonly maxEffortLevel?: ClaudeEffortLevel;
336 readonly modelMaxEffortLevels?: ReadonlyMap<string, ClaudeEffortLevel>;
338​
339const CLAUDE_1M_SUFFIX = "[1m]";
340const CLAUDE_CODE_FAMILY_ALIASES: ReadonlySet<string> = new Set([
341 "opus",
342 "sonnet",
343 "haiku",
344 "fable",
345]);
346​
347/**
348 * Names the model a `modelSettings` key caps the way Claude Code does, which is narrower than the
349 * catalog's aliases: only a family alias (optionally `[1m]`) resolves as an alias, and any other key
350 * names the most specific model id it contains, so `claude-opus-5-5-20260101` and Bedrock ids match
351 * while `claude-opus-5.5` matches nothing.
352 */
353function resolveClaudeModelSettingsKey(
354 catalog: ClaudeModelCatalog,
355 key: string,
356): string | undefined {
357 const lowered = key.trim().toLowerCase();
358 const unsuffixed = lowered.endsWith(CLAUDE_1M_SUFFIX)
359 ? lowered.slice(0, -CLAUDE_1M_SUFFIX.length)
360 : lowered;
361 if (CLAUDE_CODE_FAMILY_ALIASES.has(unsuffixed)) {
362 return catalog.models.find(
363 (entry) => !entry.model.isCustom && entry.model.aliases?.includes(unsuffixed),
364 )?.model.slug;
365 }
366 let match: string | undefined;
367 for (const { model } of catalog.models) {
368 if (!model.isCustom && lowered.includes(model.slug) && model.slug.length > (match?.length ?? 0))
369 match = model.slug;
370 }
371 return match;
373​
374/** Keys `modelSettings` caps by canonical slug, keeping the lowest cap when several keys name one model. */
375function resolveClaudeModelMaxEffortLevels(
376 catalog: ClaudeModelCatalog,
377 levels: ReadonlyMap<string, ClaudeEffortLevel> | undefined,
378): ReadonlyMap<string, ClaudeEffortLevel> {
379 const resolved = new Map<string, ClaudeEffortLevel>();
380 for (const [key, level] of levels ?? []) {
381 const slug = resolveClaudeModelSettingsKey(catalog, key);
382 if (!slug) continue;
383 const current = resolved.get(slug);
384 if (!current || claudeEffortRank(level) < claudeEffortRank(current)) resolved.set(slug, level);
385 }
386 return resolved;
388​
389/**
390 * Claude Code clamps efforts above `maxEffortLevel` to the model's highest level under it, so
391 * such options are relabelled, not removed, keeping stored selections valid. Per-model caps
392 * replace their own source's top-level cap and `max` means none; across sources the lowest wins.
393 */
394export function capClaudeCatalogEffort(
395 catalog: ClaudeModelCatalog,
396 models: ReadonlyArray<ServerProviderModel>,
397 sources: ReadonlyArray<ClaudeEffortCapSource>,
398): ReadonlyArray<ServerProviderModel> {
399 if (sources.length === 0) return models;
400 const resolvedSources = sources.map((source) => ({
401 maxEffortLevel: source.maxEffortLevel,
402 modelMaxEffortLevels: resolveClaudeModelMaxEffortLevels(catalog, source.modelMaxEffortLevels),
403 }));
404 return models.map((model) => {
405 // Custom model labels are user-authored, so they are left alone.
406 if (model.isCustom || !model.capabilities?.optionDescriptors) return model;
407 const modelMaxEffortLevel = resolvedSources.reduce<ClaudeEffortLevel | undefined>(
408 (lowest, source) => {
409 const level = source.modelMaxEffortLevels.get(model.slug) ?? source.maxEffortLevel;
410 return level &&
411 level !== "max" &&
412 (!lowest || claudeEffortRank(level) < claudeEffortRank(lowest))
413 ? level
414 : lowest;
415 },
416 undefined,
417 );
418 if (!modelMaxEffortLevel) return model;
419 const capRank = claudeEffortRank(modelMaxEffortLevel);
420 return {
421 ...model,
422 capabilities: {
423 ...model.capabilities,
424 optionDescriptors: model.capabilities.optionDescriptors.map((descriptor) => {
425 if (descriptor.id !== "effort" || descriptor.type !== "select") return descriptor;
426 const cap = descriptor.options.reduce<{ rank: number; label: string } | undefined>(
427 (best, option) => {
428 const rank = claudeEffortRank(option.id);
429 return rank >= 0 && rank <= capRank && rank > (best?.rank ?? -1)
430 ? { rank, label: option.label }
431 : best;
432 },
433 undefined,
434 );
435 if (!cap) return descriptor;
436 return {
437 ...descriptor,
438 options: descriptor.options.map((option) => {
439 const idRank = claudeEffortRank(option.id);
440 const sentRank = claudeEffortRank(
441 normalizeClaudeCatalogEffort(catalog, option.id, model.slug),
442 );
443 const displayedRank = idRank >= 0 ? idRank : sentRank;
444 const runsRank = sentRank >= 0 ? sentRank : idRank;
445 return displayedRank > cap.rank && runsRank > cap.rank
446 ? { ...option, label: `${option.label} (capped to ${cap.label})` }
447 : option;
448 }),
449 };
450 }),
451 },
452 };
453 });
455​
456export function resolveClaudeCatalogEffort(
457 catalog: ClaudeModelCatalog,
458 model: string | null | undefined,
459 raw: string | null | undefined,
460): string | undefined {
461 if (!resolveClaudeCatalogModel(catalog, model)) {
462 const effort = raw?.trim();
463 return effort && (CLAUDE_EFFORT_LEVELS as ReadonlyArray<string>).includes(effort)
464 ? effort
465 : undefined;
466 }
467​
468 const caps = getClaudeCatalogModelCapabilities(catalog, model);
469 const descriptors = getProviderOptionDescriptors({
470 caps,
471 ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}),
472 });
473 const descriptor = descriptors.find((candidate) => candidate.id === "effort");
474 const value = getProviderOptionCurrentValue(descriptor);
475 return typeof value === "string" ? value : undefined;
477​
478export function normalizeClaudeCatalogEffort(
479 catalog: ClaudeModelCatalog,
480 effort: string | null | undefined,
481 model: string | null | undefined,
482): string | undefined {
483 if (!effort) return undefined;
484 const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap;
485 if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort;
486 return effortMap[effort] ?? undefined;
488​
489function resolveClaudeCatalogContextWindow(
490 catalog: ClaudeModelCatalog,
491 modelSelection: ModelSelection | undefined,
492): string | undefined {
493 const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model);
494 const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow");
495 const descriptors = getProviderOptionDescriptors({
496 caps,
497 ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}),
498 });
499 const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow");
500 const value = getProviderOptionCurrentValue(descriptor);
501 return typeof value === "string" ? value : undefined;
503​
504export function resolveClaudeCatalogApiModelId(
505 catalog: ClaudeModelCatalog,
506 modelSelection: ModelSelection,
507): string {
508 const entry = resolveClaudeCatalogModel(catalog, modelSelection.model);
509 const slug = entry?.model.slug ?? modelSelection.model;
510 const descriptors = getProviderOptionDescriptors({
511 caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES,
512 selections: modelSelection.options,
513 });
514 for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) {
515 const value = getProviderOptionCurrentValue(
516 descriptors.find((descriptor) => descriptor.id === optionId),
517 );
518 if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`;
519 }
520 return slug;
522​
523export function resolveClaudeCatalogContextWindowTokens(
524 catalog: ClaudeModelCatalog,
525 modelSelection: ModelSelection | undefined,
526): number | undefined {
527 const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model);
528 if (!entry) return undefined;
529 if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens;
530 const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection);
531 return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined;