1import {
2 type CustomModelSetting,
3 type ModelCapabilities,
4 type ModelSelection,
5 ProviderDriverKind,
6 type ServerProviderModel,
7 type ServerProviderUpdateRequiredModel,
8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import {
11 getModelSelectionStringOptionValue,
12 getProviderOptionCurrentValue,
13 getProviderOptionDescriptors,
14 readCustomModelEntries,
15} from "@t3tools/shared/model";
16import { compareSemverVersions } from "@t3tools/shared/semver";
17
18import {
19 type ClaudeCodeCompatibility,
20 type ClaudeCodeProfile,
21 decodeClaudeModelAdapter,
22 decodeClaudeProfileAdapter,
23} from "./ClaudeModelManifest.ts";
24import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
25import { BUNDLED_MODEL_MANIFEST, resolveProviderCatalog } from "./ModelManifest.ts";
26
27const CLAUDE = ProviderDriverKind.make("claudeAgent");
28const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] };
29
30export interface ClaudeCatalogModel {
31 readonly model: ServerProviderModel;
32 readonly runtime: ClaudeCodeProfile;
33 readonly compatibility: ClaudeCodeCompatibility;
34}
35
36export interface ClaudeModelCatalog {
37 readonly models: ReadonlyArray<ClaudeCatalogModel>;
38}
39
40function tryResolveClaudeModelCatalog(
41 resolved: ModelCatalog.ProviderCatalog | undefined,
42): ClaudeModelCatalog | null {
43 if (!resolved) return null;
44
45 const models: Array<ClaudeCatalogModel> = [];
46 for (const entry of resolved.models) {
47 const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {});
48 const adapter = decodeClaudeModelAdapter(entry.adapter ?? {});
49 if (Option.isNone(profile) || Option.isNone(adapter)) return null;
50 models.push({
51 model: ModelCatalog.catalogServerModel(resolved, entry),
52 runtime: profile.value.claudeCode ?? {},
53 compatibility: adapter.value.claudeCode ?? {},
54 });
55 }
56
57 return {
58 models,
59 };
60}
61
62const BUNDLED_CLAUDE_PROVIDER_CATALOG =
63 resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined;
64
65/**
66 * Claude's built-in catalog from its manifest entry, falling back to the
67 * bundled entry when the current one is missing or has invalid adapters.
68 */
69export function resolveClaudeModelCatalog(
70 catalog: ModelCatalog.ProviderCatalog | undefined,
71): ClaudeModelCatalog {
72 return (
73 tryResolveClaudeModelCatalog(catalog) ??
74 tryResolveClaudeModelCatalog(BUNDLED_CLAUDE_PROVIDER_CATALOG) ?? {
75 models: [],
76 }
77 );
78}
79
80export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(
81 BUNDLED_CLAUDE_PROVIDER_CATALOG,
82);
83
84/**
85 * Scope the catalog to one instance's settings: custom model slugs stay opaque
86 * (a built-in alias they shadow is dropped, canonical slugs and capabilities
87 * are preserved), and custom entries that declare their own capabilities are
88 * appended so the adapter resolves effort / fast mode / thinking against the
89 * user's descriptors instead of the empty default. Custom entries carry no
90 * runtime profile, so option values pass through to Claude Code verbatim.
91 */
92export function scopeClaudeModelCatalog(
93 catalog: ClaudeModelCatalog,
94 customModels: ReadonlyArray<CustomModelSetting>,
95): ClaudeModelCatalog {
96 const customEntries = readCustomModelEntries(customModels);
97 if (customEntries.length === 0) return catalog;
98 const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase()));
99
100 const builtInModels = catalog.models.map((entry) => {
101 if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
102 return entry;
103 }
104 return {
105 ...entry,
106 model: {
107 ...entry.model,
108 aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
109 },
110 };
111 });
112 const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
113 const customCatalogModels: Array<ClaudeCatalogModel> = [];
114 for (const entry of customEntries) {
115 if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue;
116 customCatalogModels.push({
117 model: {
118 slug: entry.slug,
119 name: entry.name,
120 isCustom: true,
121 capabilities: entry.capabilities,
122 },
123 runtime: {},
124 compatibility: {},
125 });
126 }
127
128 return { models: [...builtInModels, ...customCatalogModels] };
129}
130
131function resolveClaudeCatalogModel(
132 catalog: ClaudeModelCatalog,
133 slugOrAlias: string | null | undefined,
134): ClaudeCatalogModel | undefined {
135 const value = slugOrAlias?.trim();
136 if (!value) return undefined;
137 return (
138 catalog.models.find((entry) => entry.model.slug === value) ??
139 catalog.models.find((entry) =>
140 entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()),
141 )
142 );
143}
144
145export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string {
146 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias;
147}
148
149export function getClaudeCatalogModelCapabilities(
150 catalog: ClaudeModelCatalog,
151 slugOrAlias: string | null | undefined,
152): ModelCapabilities {
153 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES;
154}
155
156function isVersionSupported(
157 compatibility: ClaudeCodeCompatibility,
158 version: string | null | undefined,
159): boolean {
160 if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true;
161 if (!version) return false;
162 if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) {
163 return false;
164 }
165 return !(
166 compatibility.maxVersionExclusive &&
167 compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0
168 );
169}
170
171export function resolveClaudeModelsForVersion(
172 catalog: ClaudeModelCatalog,
173 version: string | null | undefined,
174): ReadonlyArray<ClaudeCatalogModel["model"]> {
175 return catalog.models
176 .filter((entry) => isVersionSupported(entry.compatibility, version))
177 .map((entry) => entry.model);
178}
179
180/**
181 * Current catalog models the installed Claude Code is too old to run, so the
182 * picker can show them as "update to use" instead of hiding them.
183 */
184export function resolveClaudeUpdateRequiredModels(
185 catalog: ClaudeModelCatalog,
186 version: string | null | undefined,
187): ReadonlyArray<ServerProviderUpdateRequiredModel> {
188 if (!version) return [];
189 return catalog.models.flatMap(({ model, compatibility }) => {
190 const minVersion = compatibility.minVersion;
191 if (model.isLegacy || !minVersion || compareSemverVersions(version, minVersion) >= 0) {
192 return [];
193 }
194 return [
195 {
196 slug: model.slug,
197 name: model.name,
198 ...(model.badge ? { badge: model.badge } : {}),
199 minVersion,
200 },
201 ];
202 });
203}
204
205export function formatClaudeVersionUpgradeMessage(
206 catalog: ClaudeModelCatalog,
207 version: string | null,
208): string | undefined {
209 const unavailable = catalog.models
210 .filter(
211 (entry) =>
212 entry.compatibility.minVersion &&
213 (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0),
214 )
215 .toSorted((left, right) =>
216 compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!),
217 )[0];
218 if (!unavailable?.compatibility.minVersion) return undefined;
219 const versionLabel = version ? `v${version}` : "the installed version";
220 return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`;
221}
222
223export function resolveClaudeCatalogEffort(
224 catalog: ClaudeModelCatalog,
225 model: string | null | undefined,
226 raw: string | null | undefined,
227): string | undefined {
228 const caps = getClaudeCatalogModelCapabilities(catalog, model);
229 const descriptors = getProviderOptionDescriptors({
230 caps,
231 ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}),
232 });
233 const descriptor = descriptors.find((candidate) => candidate.id === "effort");
234 const value = getProviderOptionCurrentValue(descriptor);
235 return typeof value === "string" ? value : undefined;
236}
237
238export function normalizeClaudeCatalogEffort(
239 catalog: ClaudeModelCatalog,
240 effort: string | null | undefined,
241 model: string | null | undefined,
242): string | undefined {
243 if (!effort) return undefined;
244 const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap;
245 if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort;
246 return effortMap[effort] ?? undefined;
247}
248
249export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean {
250 return effort === "ultracode";
251}
252
253function resolveClaudeCatalogContextWindow(
254 catalog: ClaudeModelCatalog,
255 modelSelection: ModelSelection | undefined,
256): string | undefined {
257 const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model);
258 const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow");
259 const descriptors = getProviderOptionDescriptors({
260 caps,
261 ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}),
262 });
263 const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow");
264 const value = getProviderOptionCurrentValue(descriptor);
265 return typeof value === "string" ? value : undefined;
266}
267
268export function resolveClaudeCatalogApiModelId(
269 catalog: ClaudeModelCatalog,
270 modelSelection: ModelSelection,
271): string {
272 const entry = resolveClaudeCatalogModel(catalog, modelSelection.model);
273 const slug = entry?.model.slug ?? modelSelection.model;
274 const descriptors = getProviderOptionDescriptors({
275 caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES,
276 selections: modelSelection.options,
277 });
278 for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) {
279 const value = getProviderOptionCurrentValue(
280 descriptors.find((descriptor) => descriptor.id === optionId),
281 );
282 if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`;
283 }
284 return slug;
285}
286
287export function resolveClaudeCatalogContextWindowTokens(
288 catalog: ClaudeModelCatalog,
289 modelSelection: ModelSelection | undefined,
290): number | undefined {
291 const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model);
292 if (!entry) return undefined;
293 if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens;
294 const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection);
295 return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined;
296}