authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 13:53:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 13:53:46-07:00
log03542836834d008a097450cf24d0d9c6f965b859
tree8866e08382183be2698ffaa92549ef719428d5a0
parenta924fbe08e681ef56d8cfb8b99e1e803d52aa463
signature Signed by PGP key B5690EEEBB952194

feat(models): discover Claude models from remote manifest (#9084)


26 files changed, 1774 insertions(+), 1159 deletions(-)

apps/server/src/provider/ClaudeModelCatalog.test.ts created+137
......@@ -0,0 +1,137 @@
1import { assert, describe, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3
4import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
5import type { ModelManifestData } from "./ModelManifest.ts";
6import {
7 formatClaudeVersionUpgradeMessage,
8 normalizeClaudeCatalogEffort,
9 resolveClaudeCatalogApiModelId,
10 resolveClaudeModelCatalog,
11 resolveClaudeModelsForVersion,
12 resolveClaudeModelSlug,
13} from "./ClaudeModelCatalog.ts";
14
15/**
16 * Test policy: adding or changing a real Claude model in model-manifest.json
17 * must not add or update tests here. These synthetic fixtures cover resolver
18 * behavior once. Add a test only when Claude adapter semantics change, such
19 * as introducing a new compatibility rule or dispatch mapping type.
20 */
21
22const manifest = (): ModelManifestData => ({
23 version: 1,
24 currentModels: {},
25 providers: {
26 claudeAgent: {
27 profiles: {
28 synthetic: {
29 capabilities: {
30 optionDescriptors: [
31 {
32 id: "effort",
33 label: "Reasoning",
34 type: "select",
35 options: [{ id: "extreme", label: "Extreme", isDefault: true }],
36 },
37 {
38 id: "contextWindow",
39 label: "Context Window",
40 type: "select",
41 options: [{ id: "large", label: "Large", isDefault: true }],
42 },
43 ],
44 },
45 adapter: {
46 claudeCode: {
47 effortMap: { extreme: "high" },
48 modelSuffixes: { contextWindow: { large: "[large]" } },
49 },
50 },
51 },
52 },
53 models: [
54 {
55 slug: "claude-synthetic-next",
56 name: "Claude Synthetic Next",
57 aliases: ["synthetic"],
58 status: "current",
59 profile: "synthetic",
60 adapter: { claudeCode: { minVersion: "3.2.0" } },
61 },
62 ],
63 },
64 },
65});
66
67describe("Claude model catalog", () => {
68 it("filters models at runtime-version boundaries and derives the upgrade message", () => {
69 const catalog = resolveClaudeModelCatalog(manifest());
70 assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []);
71 assert.deepStrictEqual(
72 resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
73 ["claude-synthetic-next"],
74 );
75 assert.strictEqual(
76 formatClaudeVersionUpgradeMessage(catalog, "3.1.9"),
77 "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.",
78 );
79 });
80
81 it("resolves aliases and declarative adapter mappings", () => {
82 const base = manifest();
83 const input: ModelManifestData = {
84 ...base,
85 providers: {
86 ...base.providers,
87 claudeAgent: {
88 ...base.providers!.claudeAgent!,
89 models: [
90 {
91 slug: "claude-synthetic-collision",
92 name: "Claude Synthetic Collision",
93 aliases: ["claude-synthetic-next"],
94 status: "current",
95 },
96 ...base.providers!.claudeAgent!.models,
97 ],
98 },
99 },
100 };
101 const catalog = resolveClaudeModelCatalog(input);
102 assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next");
103 assert.strictEqual(
104 resolveClaudeModelSlug(catalog, "claude-synthetic-next"),
105 "claude-synthetic-next",
106 );
107 assert.strictEqual(normalizeClaudeCatalogEffort(catalog, "extreme", "synthetic"), "high");
108 assert.strictEqual(
109 resolveClaudeCatalogApiModelId(catalog, {
110 instanceId: ProviderInstanceId.make("claudeAgent"),
111 model: "synthetic",
112 }),
113 "claude-synthetic-next[large]",
114 );
115 });
116
117 it("rejects malformed adapter mappings", () => {
118 const base = manifest();
119 const malformed: ModelManifestData = {
120 ...base,
121 providers: {
122 ...base.providers,
123 claudeAgent: {
124 ...base.providers!.claudeAgent!,
125 profiles: {
126 ...base.providers!.claudeAgent!.profiles,
127 synthetic: {
128 ...base.providers!.claudeAgent!.profiles.synthetic!,
129 adapter: { claudeCode: { effortMap: { extreme: 123 } } },
130 },
131 },
132 },
133 },
134 };
135 assert.isFalse(hasValidClaudeManifestAdapters(malformed));
136 });
137});
apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts created+83
......@@ -0,0 +1,83 @@
1import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts";
2
3// Transport tests must stay independent of bundled or remote manifest contents.
4// Keep every model, alias, capability, and runtime mapping in this fixture synthetic.
5export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable";
6export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision";
7export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard";
8export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking";
9
10const effort = {
11 id: "effort",
12 label: "Reasoning",
13 type: "select" as const,
14 options: [
15 { id: "low", label: "Low" },
16 { id: "high", label: "High", isDefault: true },
17 { id: "max", label: "Max" },
18 { id: "ultrathink", label: "Ultrathink" },
19 ],
20 promptInjectedValues: ["ultrathink"],
21};
22
23const contextWindow = {
24 id: "contextWindow",
25 label: "Context Window",
26 type: "select" as const,
27 options: [
28 { id: "standard", label: "Standard" },
29 { id: "expanded", label: "Expanded", isDefault: true },
30 ],
31};
32
33const runtime = {
34 effortMap: { ultrathink: null },
35 modelSuffixes: { contextWindow: { expanded: "[expanded]" } },
36 contextWindowTokens: { standard: 200_000, expanded: 1_000_000 },
37};
38
39export const SYNTHETIC_CLAUDE_MODEL_CATALOG: ClaudeModelCatalog = {
40 models: [
41 {
42 model: {
43 slug: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
44 name: "Claude Synthetic Capable",
45 aliases: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS],
46 isCustom: false,
47 capabilities: {
48 optionDescriptors: [
49 effort,
50 { id: "fastMode", label: "Fast Mode", type: "boolean" },
51 contextWindow,
52 ],
53 },
54 },
55 runtime,
56 compatibility: {},
57 },
58 {
59 model: {
60 slug: SYNTHETIC_CLAUDE_STANDARD_MODEL,
61 name: "Claude Synthetic Standard",
62 isCustom: false,
63 capabilities: {
64 optionDescriptors: [effort, contextWindow],
65 },
66 },
67 runtime,
68 compatibility: {},
69 },
70 {
71 model: {
72 slug: SYNTHETIC_CLAUDE_THINKING_MODEL,
73 name: "Claude Synthetic Thinking",
74 isCustom: false,
75 capabilities: {
76 optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }],
77 },
78 },
79 runtime: {},
80 compatibility: {},
81 },
82 ],
83};
apps/server/src/provider/ClaudeModelCatalog.ts created+242
......@@ -0,0 +1,242 @@
1import {
2 type ModelCapabilities,
3 type ModelSelection,
4 ProviderDriverKind,
5 type ServerProviderModel,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import {
9 getModelSelectionStringOptionValue,
10 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,
12 normalizeCustomModelSlug,
13} from "@t3tools/shared/model";
14import { compareSemverVersions } from "@t3tools/shared/semver";
15
16import {
17 type ClaudeCodeCompatibility,
18 type ClaudeCodeProfile,
19 decodeClaudeModelAdapter,
20 decodeClaudeProfileAdapter,
21} from "./ClaudeModelManifest.ts";
22import {
23 BUNDLED_MODEL_MANIFEST,
24 type ModelManifestData,
25 resolveProviderCatalog,
26} 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;
35}
36
37export interface ClaudeModelCatalog {
38 readonly models: ReadonlyArray<ClaudeCatalogModel>;
39}
40
41function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null {
42 const resolved = resolveProviderCatalog(manifest, CLAUDE);
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: entry.model,
52 runtime: profile.value.claudeCode ?? {},
53 compatibility: adapter.value.claudeCode ?? {},
54 });
55 }
56
57 return {
58 models,
59 };
60}
61
62export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog {
63 return (
64 tryResolveClaudeModelCatalog(manifest) ??
65 tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? {
66 models: [],
67 }
68 );
69}
70
71export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);
72
73/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */
74export function scopeClaudeModelCatalog(
75 catalog: ClaudeModelCatalog,
76 customModels: ReadonlyArray<string>,
77): ClaudeModelCatalog {
78 const customAliases = new Set(
79 customModels.flatMap((model) => {
80 const slug = normalizeCustomModelSlug(model);
81 return slug ? [slug.toLowerCase()] : [];
82 }),
83 );
84 if (customAliases.size === 0) return catalog;
85
86 return {
87 models: catalog.models.map((entry) => {
88 if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
89 return entry;
90 }
91 return {
92 ...entry,
93 model: {
94 ...entry.model,
95 aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
96 },
97 };
98 }),
99 };
100}
101
102export function resolveClaudeCatalogModel(
103 catalog: ClaudeModelCatalog,
104 slugOrAlias: string | null | undefined,
105): ClaudeCatalogModel | undefined {
106 const value = slugOrAlias?.trim();
107 if (!value) return undefined;
108 return (
109 catalog.models.find((entry) => entry.model.slug === value) ??
110 catalog.models.find((entry) =>
111 entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()),
112 )
113 );
114}
115
116export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string {
117 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias;
118}
119
120export function getClaudeCatalogModelCapabilities(
121 catalog: ClaudeModelCatalog,
122 slugOrAlias: string | null | undefined,
123): ModelCapabilities {
124 return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES;
125}
126
127function isVersionSupported(
128 compatibility: ClaudeCodeCompatibility,
129 version: string | null | undefined,
130): boolean {
131 if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true;
132 if (!version) return false;
133 if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) {
134 return false;
135 }
136 return !(
137 compatibility.maxVersionExclusive &&
138 compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0
139 );
140}
141
142export function resolveClaudeModelsForVersion(
143 catalog: ClaudeModelCatalog,
144 version: string | null | undefined,
145): ReadonlyArray<ClaudeCatalogModel["model"]> {
146 return catalog.models
147 .filter((entry) => isVersionSupported(entry.compatibility, version))
148 .map((entry) => entry.model);
149}
150
151export function formatClaudeVersionUpgradeMessage(
152 catalog: ClaudeModelCatalog,
153 version: string | null,
154): string | undefined {
155 const unavailable = catalog.models
156 .filter(
157 (entry) =>
158 entry.compatibility.minVersion &&
159 (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0),
160 )
161 .toSorted((left, right) =>
162 compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!),
163 )[0];
164 if (!unavailable?.compatibility.minVersion) return undefined;
165 const versionLabel = version ? `v${version}` : "the installed version";
166 return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`;
167}
168
169export function resolveClaudeCatalogEffort(
170 catalog: ClaudeModelCatalog,
171 model: string | null | undefined,
172 raw: string | null | undefined,
173): string | undefined {
174 const caps = getClaudeCatalogModelCapabilities(catalog, model);
175 const descriptors = getProviderOptionDescriptors({
176 caps,
177 ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}),
178 });
179 const descriptor = descriptors.find((candidate) => candidate.id === "effort");
180 const value = getProviderOptionCurrentValue(descriptor);
181 return typeof value === "string" ? value : undefined;
182}
183
184export function normalizeClaudeCatalogEffort(
185 catalog: ClaudeModelCatalog,
186 effort: string | null | undefined,
187 model: string | null | undefined,
188): string | undefined {
189 if (!effort) return undefined;
190 const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap;
191 if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort;
192 return effortMap[effort] ?? undefined;
193}
194
195export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean {
196 return effort === "ultracode";
197}
198
199export function resolveClaudeCatalogContextWindow(
200 catalog: ClaudeModelCatalog,
201 modelSelection: ModelSelection | undefined,
202): string | undefined {
203 const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model);
204 const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow");
205 const descriptors = getProviderOptionDescriptors({
206 caps,
207 ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}),
208 });
209 const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow");
210 const value = getProviderOptionCurrentValue(descriptor);
211 return typeof value === "string" ? value : undefined;
212}
213
214export function resolveClaudeCatalogApiModelId(
215 catalog: ClaudeModelCatalog,
216 modelSelection: ModelSelection,
217): string {
218 const entry = resolveClaudeCatalogModel(catalog, modelSelection.model);
219 const slug = entry?.model.slug ?? modelSelection.model;
220 const descriptors = getProviderOptionDescriptors({
221 caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES,
222 selections: modelSelection.options,
223 });
224 for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) {
225 const value = getProviderOptionCurrentValue(
226 descriptors.find((descriptor) => descriptor.id === optionId),
227 );
228 if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`;
229 }
230 return slug;
231}
232
233export function resolveClaudeCatalogContextWindowTokens(
234 catalog: ClaudeModelCatalog,
235 modelSelection: ModelSelection | undefined,
236): number | undefined {
237 const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model);
238 if (!entry) return undefined;
239 if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens;
240 const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection);
241 return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined;
242}
apps/server/src/provider/ClaudeModelManifest.ts created+82
......@@ -0,0 +1,82 @@
1import { TrimmedNonEmptyString } from "@t3tools/contracts";
2import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6export const ClaudeCodeProfileSchema = Schema.Struct({
7 effortMap: Schema.optional(
8 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)),
9 ),
10 modelSuffixes: Schema.optional(
11 Schema.Record(
12 TrimmedNonEmptyString,
13 Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString),
14 ),
15 ),
16 contextWindowTokens: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.Number)),
17 fixedContextWindowTokens: Schema.optional(Schema.Number),
18});
19
20export const ClaudeProfileAdapterSchema = Schema.Struct({
21 claudeCode: Schema.optional(ClaudeCodeProfileSchema),
22});
23
24const ClaudeVersionSchema = TrimmedNonEmptyString.pipe(
25 Schema.check(
26 Schema.makeFilter((version) => parseSemver(version) !== null, {
27 expected: "a supported semantic version",
28 }),
29 ),
30);
31
32const ClaudeCodeCompatibilitySchema = Schema.Struct({
33 minVersion: Schema.optional(ClaudeVersionSchema),
34 maxVersionExclusive: Schema.optional(ClaudeVersionSchema),
35}).pipe(
36 Schema.check(
37 Schema.makeFilter(
38 ({ minVersion, maxVersionExclusive }) =>
39 minVersion === undefined ||
40 maxVersionExclusive === undefined ||
41 compareSemverVersions(minVersion, maxVersionExclusive) < 0,
42 { expected: "a minimum version below the exclusive maximum version" },
43 ),
44 ),
45);
46
47export const ClaudeModelAdapterSchema = Schema.Struct({
48 claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema),
49});
50
51export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type;
52export type ClaudeCodeCompatibility = NonNullable<typeof ClaudeModelAdapterSchema.Type.claudeCode>;
53
54export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema);
55export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema);
56
57interface ClaudeManifestAdapterInput {
58 readonly providers?:
59 | Readonly<
60 Record<
61 string,
62 | {
63 readonly profiles: Readonly<Record<string, { readonly adapter?: unknown }>>;
64 readonly models: ReadonlyArray<{ readonly adapter?: unknown }>;
65 }
66 | undefined
67 >
68 >
69 | undefined;
70}
71
72export function hasValidClaudeManifestAdapters(manifest: ClaudeManifestAdapterInput): boolean {
73 const catalog = manifest.providers?.claudeAgent;
74 if (!catalog) return true;
75
76 return (
77 Object.values(catalog.profiles).every((profile) =>
78 Option.isSome(decodeClaudeProfileAdapter(profile.adapter ?? {})),
79 ) &&
80 catalog.models.every((model) => Option.isSome(decodeClaudeModelAdapter(model.adapter ?? {})))
81 );
82}
apps/server/src/provider/Drivers/ClaudeDriver.ts+25-19
......@@ -35,6 +35,7 @@ import {
3535 probeClaudeCapabilities,
3636} from "../Layers/ClaudeProvider.ts";
3737import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
38import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
3839import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3940import * as ModelManifest from "../ModelManifest.ts";
4041import {
......@@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
128129 const serverSettings = yield* ServerSettingsService;
129130 const eventLoggers = yield* ProviderEventLoggers;
130131 const modelManifest = yield* ModelManifest.ModelManifest;
132 const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog));
131133 const processEnv = mergeProviderInstanceEnvironment(environment);
132134 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
133135 driverKind: DRIVER_KIND,
......@@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
149151 const adapterOptions = {
150152 instanceId,
151153 environment: processEnv,
154 modelCatalog,
152155 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
153156 };
154157 const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
155 const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv);
158 const textGeneration = yield* makeClaudeTextGeneration(
159 effectiveConfig,
160 processEnv,
161 modelCatalog,
162 );
156163
157164 // Per-instance capabilities cache: keyed on binary + resolved HOME so
158165 // account-specific probes never share auth metadata across instances.
......@@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
166173 });
167174 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
168175
169 // Kick the TTL-gated manifest refresh in the background and classify
170 // with the in-memory manifest, so a slow or hung fetch never delays the
171 // provider check. A refresh that lands mid-probe applies on the next one.
176 // Start the TTL-gated refresh without delaying provider readiness. The
177 // next check observes a remote manifest after the background fetch lands.
172178 const checkProvider = modelManifest.refreshInBackground.pipe(
173179 Effect.andThen(
174 Effect.zipWith(
175 checkClaudeProviderStatus(
176 effectiveConfig,
177 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
178 processEnv,
179 cwd,
180 modelManifest.current.pipe(
181 Effect.flatMap((manifest) =>
182 checkClaudeProviderStatus(
183 effectiveConfig,
184 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
185 processEnv,
186 cwd,
187 resolveClaudeModelCatalog(manifest),
188 ),
180189 ),
181 modelManifest.current,
182 (draft, manifest) =>
183 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
184 { concurrent: true },
190 Effect.map(stampIdentity),
185191 ),
186192 ),
187193 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
......@@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
196202 streamSettings: snapshotSettings.streamSettings,
197203 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
198204 initialSnapshot: (settings) =>
199 Effect.zipWith(
200 makePendingClaudeProvider(settings.provider),
201 modelManifest.current,
202 (draft, manifest) =>
203 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
205 modelManifest.current.pipe(
206 Effect.flatMap((manifest) =>
207 makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)),
208 ),
209 Effect.map(stampIdentity),
204210 ),
205211 checkProvider,
206212 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+141-196
......@@ -35,6 +35,13 @@ import * as TestClock from "effect/testing/TestClock";
3535import { attachmentRelativePath } from "../../attachmentStore.ts";
3636import { ServerConfig } from "../../config.ts";
3737import { ServerSettingsService } from "../../serverSettings.ts";
38import {
39 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
40 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
41 SYNTHETIC_CLAUDE_MODEL_CATALOG,
42 SYNTHETIC_CLAUDE_STANDARD_MODEL,
43 SYNTHETIC_CLAUDE_THINKING_MODEL,
44} from "../ClaudeModelCatalog.testFixtures.ts";
3845import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
3946import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
4047import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
......@@ -166,6 +173,7 @@ function makeHarness(config?: {
166173
167174 const adapterOptions: ClaudeAdapterLiveOptions = {
168175 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
176 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
169177 createQuery: (input) => {
170178 createInput = input;
171179 return query;
......@@ -265,6 +273,7 @@ async function readFirstPromptMessage(
265273
266274const THREAD_ID = ThreadId.make("thread-claude-1");
267275const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume");
276const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]";
268277
269278describe("ClaudeAdapterLive", () => {
270279 it.effect("returns validation error for non-claude provider on startSession", () => {
......@@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => {
441450 provider: ProviderDriverKind.make("claudeAgent"),
442451 modelSelection: createModelSelection(
443452 ProviderInstanceId.make("claudeAgent"),
444 "claude-opus-4-6",
453 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
445454 [{ id: "effort", value: "max" }],
446455 ),
447456 runtimeMode: "full-access",
......@@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => {
464473 provider: ProviderDriverKind.make("claudeAgent"),
465474 modelSelection: createModelSelection(
466475 ProviderInstanceId.make("claudeAgent"),
467 "claude-opus-4-6",
476 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
468477 ),
469478 runtimeMode: "full-access",
470479 });
......@@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => {
480489 );
481490 });
482491
483 it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {
484 const harness = makeHarness();
485 return Effect.gen(function* () {
486 const adapter = yield* ClaudeAdapter;
487 yield* adapter.startSession({
488 threadId: THREAD_ID,
489 provider: ProviderDriverKind.make("claudeAgent"),
490 modelSelection: {
491 instanceId: ProviderInstanceId.make("claudeAgent"),
492 model: "claude-opus-4-7",
493 },
494 runtimeMode: "full-access",
495 });
496
497 const createInput = harness.getLastCreateQueryInput();
498 assert.equal(createInput?.options.effort, "max");
499 }).pipe(
500 Effect.provideService(Random.Random, makeDeterministicRandomService()),
501 Effect.provide(harness.layer),
502 );
503 });
504
505 it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => {
506 const harness = makeHarness();
507 return Effect.gen(function* () {
508 const adapter = yield* ClaudeAdapter;
509 yield* adapter.startSession({
510 threadId: THREAD_ID,
511 provider: ProviderDriverKind.make("claudeAgent"),
512 modelSelection: createModelSelection(
513 ProviderInstanceId.make("claudeAgent"),
514 "claude-opus-4-7",
515 [{ id: "effort", value: "xhigh" }],
516 ),
517 runtimeMode: "full-access",
518 });
519
520 const createInput = harness.getLastCreateQueryInput();
521 assert.equal(createInput?.options.effort, "max");
522 }).pipe(
523 Effect.provideService(Random.Random, makeDeterministicRandomService()),
524 Effect.provide(harness.layer),
525 );
526 });
527
528 it.effect("preserves xhigh effort for Claude Fable 5.1", () => {
529 const harness = makeHarness();
530 return Effect.gen(function* () {
531 const adapter = yield* ClaudeAdapter;
532 yield* adapter.startSession({
533 threadId: THREAD_ID,
534 provider: ProviderDriverKind.make("claudeAgent"),
535 modelSelection: createModelSelection(
536 ProviderInstanceId.make("claudeAgent"),
537 "claude-fable-5-1",
538 [{ id: "effort", value: "xhigh" }],
539 ),
540 runtimeMode: "full-access",
541 });
542
543 const createInput = harness.getLastCreateQueryInput();
544 assert.equal(createInput?.options.effort, "xhigh");
545 }).pipe(
546 Effect.provideService(Random.Random, makeDeterministicRandomService()),
547 Effect.provide(harness.layer),
548 );
549 });
550
551 it.effect("preserves xhigh effort for Claude Fable 5", () => {
552 const harness = makeHarness();
553 return Effect.gen(function* () {
554 const adapter = yield* ClaudeAdapter;
555 yield* adapter.startSession({
556 threadId: THREAD_ID,
557 provider: ProviderDriverKind.make("claudeAgent"),
558 modelSelection: createModelSelection(
559 ProviderInstanceId.make("claudeAgent"),
560 "claude-fable-5",
561 [{ id: "effort", value: "xhigh" }],
562 ),
563 runtimeMode: "full-access",
564 });
565
566 const createInput = harness.getLastCreateQueryInput();
567 assert.equal(createInput?.options.effort, "xhigh");
568 }).pipe(
569 Effect.provideService(Random.Random, makeDeterministicRandomService()),
570 Effect.provide(harness.layer),
571 );
572 });
573
574 it.effect("preserves xhigh effort for Claude Opus 5", () => {
575 const harness = makeHarness();
576 return Effect.gen(function* () {
577 const adapter = yield* ClaudeAdapter;
578 yield* adapter.startSession({
579 threadId: THREAD_ID,
580 provider: ProviderDriverKind.make("claudeAgent"),
581 modelSelection: createModelSelection(
582 ProviderInstanceId.make("claudeAgent"),
583 "claude-opus-5",
584 [{ id: "effort", value: "xhigh" }],
585 ),
586 runtimeMode: "full-access",
587 });
588
589 const createInput = harness.getLastCreateQueryInput();
590 assert.equal(createInput?.options.effort, "xhigh");
591 }).pipe(
592 Effect.provideService(Random.Random, makeDeterministicRandomService()),
593 Effect.provide(harness.layer),
594 );
595 });
596
597 it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => {
598 const harness = makeHarness();
599 return Effect.gen(function* () {
600 const adapter = yield* ClaudeAdapter;
601 yield* adapter.startSession({
602 threadId: THREAD_ID,
603 provider: ProviderDriverKind.make("claudeAgent"),
604 modelSelection: createModelSelection(
605 ProviderInstanceId.make("claudeAgent"),
606 "claude-sonnet-4-6",
607 [{ id: "effort", value: "max" }],
608 ),
609 runtimeMode: "full-access",
610 });
611
612 const createInput = harness.getLastCreateQueryInput();
613 assert.equal(createInput?.options.effort, "high");
614 }).pipe(
615 Effect.provideService(Random.Random, makeDeterministicRandomService()),
616 Effect.provide(harness.layer),
617 );
618 });
619
620 it.effect("ignores adaptive effort for Haiku 4.5", () => {
621 const harness = makeHarness();
622 return Effect.gen(function* () {
623 const adapter = yield* ClaudeAdapter;
624 yield* adapter.startSession({
625 threadId: THREAD_ID,
626 provider: ProviderDriverKind.make("claudeAgent"),
627 modelSelection: createModelSelection(
628 ProviderInstanceId.make("claudeAgent"),
629 "claude-haiku-4-5",
630 [{ id: "effort", value: "high" }],
631 ),
632 runtimeMode: "full-access",
633 });
634
635 const createInput = harness.getLastCreateQueryInput();
636 assert.equal(createInput?.options.effort, undefined);
637 }).pipe(
638 Effect.provideService(Random.Random, makeDeterministicRandomService()),
639 Effect.provide(harness.layer),
640 );
641 });
642
643 it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => {
492 it.effect("forwards Claude thinking toggle for models that support it", () => {
644493 const harness = makeHarness();
645494 return Effect.gen(function* () {
646495 const adapter = yield* ClaudeAdapter;
......@@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => {
649498 provider: ProviderDriverKind.make("claudeAgent"),
650499 modelSelection: createModelSelection(
651500 ProviderInstanceId.make("claudeAgent"),
652 "claude-haiku-4-5",
501 SYNTHETIC_CLAUDE_THINKING_MODEL,
653502 [{ id: "thinking", value: false }],
654503 ),
655504 runtimeMode: "full-access",
......@@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => {
665514 );
666515 });
667516
668 it.effect("ignores Claude thinking toggle for non-Haiku models", () => {
517 it.effect("ignores Claude thinking toggle for models without it", () => {
669518 const harness = makeHarness();
670519 return Effect.gen(function* () {
671520 const adapter = yield* ClaudeAdapter;
......@@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => {
674523 provider: ProviderDriverKind.make("claudeAgent"),
675524 modelSelection: createModelSelection(
676525 ProviderInstanceId.make("claudeAgent"),
677 "claude-sonnet-4-6",
526 SYNTHETIC_CLAUDE_STANDARD_MODEL,
678527 [{ id: "thinking", value: false }],
679528 ),
680529 runtimeMode: "full-access",
......@@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => {
697546 provider: ProviderDriverKind.make("claudeAgent"),
698547 modelSelection: createModelSelection(
699548 ProviderInstanceId.make("claudeAgent"),
700 "claude-opus-4-6",
549 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
701550 [{ id: "fastMode", value: true }],
702551 ),
703552 runtimeMode: "full-access",
......@@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => {
713562 );
714563 });
715564
716 it.effect("ignores claude fast mode for non-opus models", () => {
565 it.effect("ignores claude fast mode for models without it", () => {
717566 const harness = makeHarness();
718567 return Effect.gen(function* () {
719568 const adapter = yield* ClaudeAdapter;
......@@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => {
722571 provider: ProviderDriverKind.make("claudeAgent"),
723572 modelSelection: createModelSelection(
724573 ProviderInstanceId.make("claudeAgent"),
725 "claude-sonnet-4-6",
574 SYNTHETIC_CLAUDE_STANDARD_MODEL,
726575 [{ id: "fastMode", value: true }],
727576 ),
728577 runtimeMode: "full-access",
......@@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => {
736585 );
737586 });
738587
588 it.effect(
589 "keeps a configured custom alias opaque without disabling the canonical built-in",
590 () => {
591 const claudeConfig = { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] };
592 const customHarness = makeHarness({ claudeConfig });
593 const builtInHarness = makeHarness({ claudeConfig });
594 const start = (harness: ReturnType<typeof makeHarness>, model: string) =>
595 Effect.gen(function* () {
596 const adapter = yield* ClaudeAdapter;
597 yield* adapter.startSession({
598 threadId: THREAD_ID,
599 provider: ProviderDriverKind.make("claudeAgent"),
600 modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), model, [
601 { id: "effort", value: "max" },
602 { id: "fastMode", value: true },
603 { id: "contextWindow", value: "expanded" },
604 ]),
605 runtimeMode: "full-access",
606 });
607 return harness.getLastCreateQueryInput()!.options;
608 }).pipe(
609 Effect.provideService(Random.Random, makeDeterministicRandomService()),
610 Effect.provide(harness.layer),
611 );
612 const runCustomFlow = Effect.gen(function* () {
613 const adapter = yield* ClaudeAdapter;
614 yield* adapter.startSession({
615 threadId: THREAD_ID,
616 provider: ProviderDriverKind.make("claudeAgent"),
617 modelSelection: createModelSelection(
618 ProviderInstanceId.make("claudeAgent"),
619 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
620 [
621 { id: "effort", value: "max" },
622 { id: "fastMode", value: true },
623 { id: "contextWindow", value: "expanded" },
624 ],
625 ),
626 runtimeMode: "full-access",
627 });
628 const options = customHarness.getLastCreateQueryInput()!.options;
629
630 yield* adapter.sendTurn({
631 threadId: THREAD_ID,
632 input: "use the built-in model",
633 modelSelection: createModelSelection(
634 ProviderInstanceId.make("claudeAgent"),
635 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
636 [{ id: "contextWindow", value: "expanded" }],
637 ),
638 attachments: [],
639 });
640 yield* Effect.promise(() => readFirstPromptText(customHarness.getLastCreateQueryInput()));
641 yield* adapter.sendTurn({
642 threadId: THREAD_ID,
643 input: "keep this prompt literal",
644 modelSelection: createModelSelection(
645 ProviderInstanceId.make("claudeAgent"),
646 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
647 [{ id: "effort", value: "ultrathink" }],
648 ),
649 attachments: [],
650 });
651 const prompt = yield* Effect.promise(() =>
652 readFirstPromptText(customHarness.getLastCreateQueryInput()),
653 );
654 return { options, prompt };
655 }).pipe(
656 Effect.provideService(Random.Random, makeDeterministicRandomService()),
657 Effect.provide(customHarness.layer),
658 );
659
660 return Effect.gen(function* () {
661 const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow;
662 assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS);
663 assert.equal(customOptions.effort, undefined);
664 assert.equal(customOptions.settings, undefined);
665 assert.deepEqual(customHarness.query.setModelCalls, [
666 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,
667 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
668 ]);
669 assert.equal(customPrompt, "keep this prompt literal");
670
671 const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
672 assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`);
673 assert.equal(builtInOptions.effort, "max");
674 assert.deepEqual(builtInOptions.settings, { fastMode: true });
675 });
676 },
677 );
678
739679 it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {
740680 const harness = makeHarness();
741681 return Effect.gen(function* () {
......@@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => {
745685 provider: ProviderDriverKind.make("claudeAgent"),
746686 modelSelection: createModelSelection(
747687 ProviderInstanceId.make("claudeAgent"),
748 "claude-sonnet-4-6",
688 SYNTHETIC_CLAUDE_STANDARD_MODEL,
749689 [{ id: "effort", value: "ultrathink" }],
750690 ),
751691 runtimeMode: "full-access",
......@@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => {
757697 attachments: [],
758698 modelSelection: createModelSelection(
759699 ProviderInstanceId.make("claudeAgent"),
760 "claude-sonnet-4-6",
700 SYNTHETIC_CLAUDE_STANDARD_MODEL,
761701 [{ id: "effort", value: "ultrathink" }],
762702 ),
763703 });
......@@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => {
778718 const adapter = yield* ClaudeAdapter;
779719 const modelSelection = createModelSelection(
780720 ProviderInstanceId.make("claudeAgent"),
781 "claude-sonnet-4-6",
721 SYNTHETIC_CLAUDE_STANDARD_MODEL,
782722 [{ id: "effort", value: "ultrathink" }],
783723 );
784724 const session = yield* adapter.startSession({
......@@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => {
885825 provider: ProviderDriverKind.make("claudeAgent"),
886826 modelSelection: {
887827 instanceId: ProviderInstanceId.make("claudeAgent"),
888 model: "claude-sonnet-4-5",
828 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
889829 },
890830 runtimeMode: "full-access",
891831 });
......@@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => {
19001840 output_tokens: 50,
19011841 },
19021842 modelUsage: {
1903 "claude-opus-4-6": {
1843 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
19041844 contextWindow: 200000,
19051845 maxOutputTokens: 64000,
19061846 },
......@@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => {
19901930 output_tokens: 50,
19911931 },
19921932 modelUsage: {
1993 "claude-opus-4-6": {
1933 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
19941934 contextWindow: 200000,
19951935 maxOutputTokens: 64000,
19961936 },
......@@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => {
21252065 provider: ProviderDriverKind.make("claudeAgent"),
21262066 modelSelection: createModelSelection(
21272067 ProviderInstanceId.make("claudeAgent"),
2128 "claude-opus-4-6",
2068 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
21292069 [{ id: "effort", value: "max" }],
21302070 ),
21312071 runtimeMode: "full-access",
......@@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => {
21542094 type: "assistant",
21552095 parent_tool_use_id: "toolu_agent_m",
21562096 message: {
2157 model: "claude-sonnet-5[1m]",
2097 model: SYNTHETIC_SUBAGENT_MODEL,
21582098 content: [],
21592099 },
21602100 uuid: "subagent-snapshot-uuid",
......@@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => {
21742114 const started = taskEvents[0];
21752115 assert.equal(started?.type, "task.started");
21762116 if (started?.type === "task.started") {
2177 assert.equal(started.payload.model, "claude-opus-4-6");
2117 assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
21782118 assert.equal(started.payload.effort, "max");
21792119 }
21802120 const progress = taskEvents[1];
21812121 assert.equal(progress?.type, "task.progress");
21822122 if (progress?.type === "task.progress") {
2183 assert.equal(progress.payload.model, "claude-sonnet-5[1m]");
2123 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
21842124 assert.equal(progress.payload.effort, "max");
21852125 }
21862126 }).pipe(
......@@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => {
22062146 provider: ProviderDriverKind.make("claudeAgent"),
22072147 modelSelection: createModelSelection(
22082148 ProviderInstanceId.make("claudeAgent"),
2209 "claude-opus-4-6",
2149 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
22102150 [{ id: "effort", value: "max" }],
22112151 ),
22122152 runtimeMode: "full-access",
......@@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => {
22232163 type: "assistant",
22242164 parent_tool_use_id: "toolu_agent_early",
22252165 message: {
2226 model: "claude-sonnet-5[1m]",
2166 model: SYNTHETIC_SUBAGENT_MODEL,
22272167 content: [],
22282168 },
22292169 uuid: "early-snapshot-uuid",
......@@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => {
22532193 const started = taskEvents[0];
22542194 assert.equal(started?.type, "task.started");
22552195 if (started?.type === "task.started") {
2256 assert.equal(started.payload.model, "claude-sonnet-5[1m]");
2196 assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL);
22572197 assert.equal(started.payload.effort, "max");
22582198 }
22592199 const progress = taskEvents[1];
22602200 assert.equal(progress?.type, "task.progress");
22612201 if (progress?.type === "task.progress") {
2262 assert.equal(progress.payload.model, "claude-sonnet-5[1m]");
2202 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
22632203 }
22642204 }).pipe(
22652205 Effect.provideService(Random.Random, makeDeterministicRandomService()),
......@@ -2884,7 +2824,7 @@ describe("ClaudeAdapterLive", () => {
28842824 output_tokens: 679,
28852825 },
28862826 modelUsage: {
2887 "claude-opus-4-6": {
2827 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
28882828 contextWindow: 200000,
28892829 maxOutputTokens: 64000,
28902830 },
......@@ -2948,7 +2888,7 @@ describe("ClaudeAdapterLive", () => {
29482888 total_tokens: 535000,
29492889 },
29502890 modelUsage: {
2951 "claude-opus-4-6": {
2891 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
29522892 contextWindow: 200000,
29532893 maxOutputTokens: 64000,
29542894 },
......@@ -3025,7 +2965,7 @@ describe("ClaudeAdapterLive", () => {
30252965 total_tokens: 535000,
30262966 },
30272967 modelUsage: {
3028 "claude-opus-4-6": {
2968 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
30292969 contextWindow: 200000,
30302970 maxOutputTokens: 64000,
30312971 },
......@@ -4069,7 +4009,7 @@ describe("ClaudeAdapterLive", () => {
40694009 cwd: "/tmp/claude-adapter-test",
40704010 tools: [],
40714011 mcp_servers: [],
4072 model: "claude-sonnet-4-5",
4012 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
40734013 permissionMode: "bypassPermissions",
40744014 slash_commands: [],
40754015 output_style: "default",
......@@ -4232,12 +4172,14 @@ describe("ClaudeAdapterLive", () => {
42324172 input: "hello",
42334173 modelSelection: {
42344174 instanceId: ProviderInstanceId.make("claudeAgent"),
4235 model: "claude-opus-4-6",
4175 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
42364176 },
42374177 attachments: [],
42384178 });
42394179
4240 assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]);
4180 assert.deepEqual(harness.query.setModelCalls, [
4181 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,
4182 ]);
42414183 }).pipe(
42424184 Effect.provideService(Random.Random, makeDeterministicRandomService()),
42434185 Effect.provide(harness.layer),
......@@ -4280,7 +4222,7 @@ describe("ClaudeAdapterLive", () => {
42804222 const adapter = yield* ClaudeAdapter;
42814223 const modelSelection = {
42824224 instanceId: ProviderInstanceId.make("claudeAgent"),
4283 model: "claude-opus-4-6",
4225 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
42844226 };
42854227
42864228 const session = yield* adapter.startSession({
......@@ -4327,8 +4269,8 @@ describe("ClaudeAdapterLive", () => {
43274269 input: "hello",
43284270 modelSelection: createModelSelection(
43294271 ProviderInstanceId.make("claudeAgent"),
4330 "claude-opus-4-6",
4331 [{ id: "contextWindow", value: "1m" }],
4272 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4273 [{ id: "contextWindow", value: "expanded" }],
43324274 ),
43334275 attachments: [],
43344276 });
......@@ -4337,13 +4279,16 @@ describe("ClaudeAdapterLive", () => {
43374279 input: "hello again",
43384280 modelSelection: createModelSelection(
43394281 ProviderInstanceId.make("claudeAgent"),
4340 "claude-opus-4-6",
4341 [{ id: "contextWindow", value: "200k" }],
4282 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4283 [{ id: "contextWindow", value: "standard" }],
43424284 ),
43434285 attachments: [],
43444286 });
43454287
4346 assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]", "claude-opus-4-6"]);
4288 assert.deepEqual(harness.query.setModelCalls, [
4289 `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`,
4290 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4291 ]);
43474292 }).pipe(
43484293 Effect.provideService(Random.Random, makeDeterministicRandomService()),
43494294 Effect.provide(harness.layer),
......@@ -4553,7 +4498,7 @@ describe("ClaudeAdapterLive", () => {
45534498 uuid: "assistant-exit-plan",
45544499 parent_tool_use_id: null,
45554500 message: {
4556 model: "claude-opus-4-6",
4501 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
45574502 id: "msg-exit-plan",
45584503 type: "message",
45594504 role: "assistant",
apps/server/src/provider/Layers/ClaudeAdapter.ts+56-38
......@@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
8080import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
8181import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
8282import {
83 getClaudeModelCapabilities,
84 isClaudeUltracodeEffort,
85 normalizeClaudeCliEffort,
86 resolveClaudeApiModelId,
87 resolveClaudeContextWindow,
88 resolveClaudeEffort,
89} from "./ClaudeProvider.ts";
83 BUNDLED_CLAUDE_MODEL_CATALOG,
84 type ClaudeModelCatalog,
85 getClaudeCatalogModelCapabilities,
86 isClaudeCatalogUltracodeEffort,
87 normalizeClaudeCatalogEffort,
88 resolveClaudeCatalogApiModelId,
89 resolveClaudeCatalogContextWindowTokens,
90 resolveClaudeCatalogEffort,
91 resolveClaudeModelSlug,
92 scopeClaudeModelCatalog,
93} from "../ClaudeModelCatalog.ts";
9094import {
9195 ProviderAdapterProcessError,
9296 ProviderAdapterRequestError,
......@@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions {
333337 }) => ClaudeQueryRuntime;
334338 readonly nativeEventLogPath?: string;
335339 readonly nativeEventLogger?: EventNdjsonLogger;
340 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
336341}
337342
338343function isUuid(value: string): boolean {
......@@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages(
381386}
382387
383388function getEffectiveClaudeAgentEffort(
389 catalog: ClaudeModelCatalog,
384390 effort: string | null | undefined,
385391 model: string | null | undefined,
386392): ClaudeSdkEffort | null {
387 const normalized = normalizeClaudeCliEffort(effort, model);
393 const normalized = normalizeClaudeCatalogEffort(catalog, effort, model);
388394 return normalized ? (normalized as ClaudeSdkEffort) : null;
389395}
390396
......@@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage(
470476}
471477
472478function selectedClaudeContextWindow(
479 catalog: ClaudeModelCatalog,
473480 modelSelection: ModelSelection | undefined,
474481): number | undefined {
475 switch (modelSelection?.model) {
476 case "claude-opus-4-8":
477 case "claude-opus-4-7":
478 // Always 1M at the API; these models expose no contextWindow option.
479 return 1_000_000;
480 }
481
482 switch (resolveClaudeContextWindow(modelSelection)) {
483 case "1m":
484 return 1_000_000;
485 case "200k":
486 return 200_000;
487 default:
488 return undefined;
489 }
482 return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection);
490483}
491484
492485function finiteNonNegativeInteger(value: unknown): number | undefined {
......@@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [
12341227function buildPromptText(
12351228 input: ProviderSendTurnInput,
12361229 boundInstanceId: ProviderInstanceId,
1230 catalog: ClaudeModelCatalog,
12371231): string {
12381232 const rawEffort =
12391233 input.modelSelection?.instanceId === boundInstanceId
......@@ -1241,7 +1235,7 @@ function buildPromptText(
12411235 : null;
12421236 const claudeModel =
12431237 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;
1244 const caps = getClaudeModelCapabilities(claudeModel);
1238 const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel);
12451239
12461240 const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);
12471241 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
......@@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
12811275 readonly fileSystem: FileSystem.FileSystem;
12821276 readonly attachmentsDir: string;
12831277 readonly boundInstanceId: ProviderInstanceId;
1278 readonly modelCatalog: ClaudeModelCatalog;
12841279 },
12851280) {
1286 const text = buildPromptText(input, dependencies.boundInstanceId);
1281 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);
12871282 const sdkContent: Array<Record<string, unknown>> = [];
12881283
12891284 if (text.length > 0) {
......@@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
16821677 options?: ClaudeAdapterLiveOptions,
16831678) {
16841679 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent");
1680 const modelCatalogEffect = (
1681 options?.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG)
1682 ).pipe(Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)));
16851683 const fileSystem = yield* FileSystem.FileSystem;
16861684 const path = yield* Path.Path;
16871685 const serverConfig = yield* ServerConfig;
......@@ -3805,6 +3803,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
38053803
38063804 const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")(
38073805 function* (input) {
3806 const modelCatalog = yield* modelCatalogEffect;
38083807 if (input.provider !== undefined && input.provider !== PROVIDER) {
38093808 return yield* new ProviderAdapterValidationError({
38103809 provider: PROVIDER,
......@@ -4243,14 +4242,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
42434242
42444243 const claudeBinaryPath = claudeSdkExecutablePath;
42454244 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
4246 const modelSelection =
4245 const selectedModel =
42474246 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
4248 const caps = getClaudeModelCapabilities(modelSelection?.model);
4247 const modelSelection = selectedModel
4248 ? {
4249 ...selectedModel,
4250 model: resolveClaudeModelSlug(modelCatalog, selectedModel.model),
4251 }
4252 : undefined;
4253 const caps = getClaudeCatalogModelCapabilities(modelCatalog, modelSelection?.model);
42494254 const descriptors = getProviderOptionDescriptors({ caps });
4250 const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
4251 const initialContextWindow = selectedClaudeContextWindow(modelSelection);
4255 const apiModelId = modelSelection
4256 ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection)
4257 : undefined;
4258 const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection);
42524259 const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort");
4253 const effort = resolveClaudeEffort(caps, rawEffort) ?? null;
4260 const effort =
4261 resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null;
42544262 const fastModeSupported = descriptors.some(
42554263 (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
42564264 );
......@@ -4263,8 +4271,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
42634271 const thinking = thinkingSupported
42644272 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")
42654273 : undefined;
4266 const ultracode = isClaudeUltracodeEffort(effort);
4267 const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model);
4274 const ultracode = isClaudeCatalogUltracodeEffort(effort);
4275 const effectiveEffort = getEffectiveClaudeAgentEffort(
4276 modelCatalog,
4277 effort,
4278 modelSelection?.model,
4279 );
42684280 const runtimeModeToPermission: Record<string, PermissionMode> = {
42694281 "auto-accept-edits": "acceptEdits",
42704282 auto: "auto",
......@@ -4495,10 +4507,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
44954507
44964508 const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
44974509 const context = yield* requireSession(input.threadId);
4498 const modelSelection =
4510 const modelCatalog = yield* modelCatalogEffect;
4511 const selectedModel =
44994512 input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId
45004513 ? input.modelSelection
45014514 : undefined;
4515 const modelSelection = selectedModel
4516 ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) }
4517 : undefined;
45024518
45034519 // A sendTurn while a real turn is running is a steer: the message is
45044520 // queued into the live SDK agent loop and the work continues as the same
......@@ -4512,7 +4528,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
45124528 }
45134529
45144530 if (modelSelection?.model) {
4515 const apiModelId = resolveClaudeApiModelId(modelSelection);
4531 const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection);
45164532 if (context.currentApiModelId !== apiModelId) {
45174533 yield* Effect.tryPromise({
45184534 try: () => context.query.setModel(apiModelId),
......@@ -4524,13 +4540,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
45244540 ...context.session,
45254541 model: modelSelection.model,
45264542 };
4527 const turnCaps = getClaudeModelCapabilities(modelSelection.model);
4528 const turnEffort = resolveClaudeEffort(
4529 turnCaps,
4543 const turnEffort = resolveClaudeCatalogEffort(
4544 modelCatalog,
4545 modelSelection.model,
45304546 getModelSelectionStringOptionValue(modelSelection, "effort"),
45314547 );
45324548 context.currentEffort =
4533 getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined;
4549 getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ??
4550 undefined;
45344551 }
45354552
45364553 // Apply interaction mode by switching the SDK's permission mode.
......@@ -4589,6 +4606,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
45894606 fileSystem,
45904607 attachmentsDir: serverConfig.attachmentsDir,
45914608 boundInstanceId,
4609 modelCatalog,
45924610 });
45934611
45944612 yield* Queue.offer(context.promptQueue, {
apps/server/src/provider/Layers/ClaudeProvider.ts+13-487
......@@ -1,8 +1,6 @@
11import {
22 type ClaudeSettings,
33 type ModelCapabilities,
4 type ModelSelection,
5 type ServerProviderModel,
64 type ServerProviderSlashCommand,
75} from "@t3tools/contracts";
86import * as DateTime from "effect/DateTime";
......@@ -12,14 +10,8 @@ import * as Option from "effect/Option";
1210import * as Path from "effect/Path";
1311import * as Result from "effect/Result";
1412import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
15import {
16 createModelCapabilities,
17 getModelSelectionStringOptionValue,
18 getProviderOptionCurrentValue,
19 getProviderOptionDescriptors,
20} from "@t3tools/shared/model";
13import { createModelCapabilities } from "@t3tools/shared/model";
2114import { resolveSpawnCommand } from "@t3tools/shared/shell";
22import { compareSemverVersions } from "@t3tools/shared/semver";
2315import {
2416 query as claudeQuery,
2517 type Options as ClaudeQueryOptions,
......@@ -29,8 +21,6 @@ import {
2921} from "@anthropic-ai/claude-agent-sdk";
3022
3123import {
32 buildBooleanOptionDescriptor,
33 buildSelectOptionDescriptor,
3424 buildServerProvider,
3525 DEFAULT_TIMEOUT_MS,
3626 isCommandMissingCause,
......@@ -42,6 +32,12 @@ import {
4232import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
4333import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
4434import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
35import {
36 BUNDLED_CLAUDE_MODEL_CATALOG,
37 type ClaudeModelCatalog,
38 formatClaudeVersionUpgradeMessage,
39 resolveClaudeModelsForVersion,
40} from "../ClaudeModelCatalog.ts";
4541
4642const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4743 optionDescriptors: [],
......@@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = {
5147 displayName: "Claude",
5248 showInteractionModeToggle: true,
5349} as const;
54const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257";
55const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219";
56const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
57const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
58const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
59
60const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
61 {
62 slug: "claude-fable-5-1",
63 name: "Claude Fable 5.1",
64 isCustom: false,
65 capabilities: createModelCapabilities({
66 optionDescriptors: [
67 buildSelectOptionDescriptor({
68 id: "effort",
69 label: "Reasoning",
70 options: [
71 { value: "low", label: "Low" },
72 { value: "medium", label: "Medium" },
73 { value: "high", label: "High", isDefault: true },
74 { value: "xhigh", label: "Extra High" },
75 { value: "max", label: "Max" },
76 {
77 value: "ultracode",
78 label: "Ultracode",
79 description: "xhigh effort plus multi-agent workflow orchestration",
80 },
81 { value: "ultrathink", label: "Ultrathink" },
82 ],
83 promptInjectedValues: ["ultrathink"],
84 }),
85 buildSelectOptionDescriptor({
86 id: "contextWindow",
87 label: "Context Window",
88 options: [
89 { value: "200k", label: "200k" },
90 { value: "1m", label: "1M", isDefault: true },
91 ],
92 }),
93 ],
94 }),
95 },
96 {
97 slug: "claude-fable-5",
98 name: "Claude Fable 5",
99 isCustom: false,
100 capabilities: createModelCapabilities({
101 optionDescriptors: [
102 buildSelectOptionDescriptor({
103 id: "effort",
104 label: "Reasoning",
105 options: [
106 { value: "low", label: "Low" },
107 { value: "medium", label: "Medium" },
108 { value: "high", label: "High", isDefault: true },
109 { value: "xhigh", label: "Extra High" },
110 { value: "max", label: "Max" },
111 {
112 value: "ultracode",
113 label: "Ultracode",
114 description: "xhigh effort plus multi-agent workflow orchestration",
115 },
116 { value: "ultrathink", label: "Ultrathink" },
117 ],
118 promptInjectedValues: ["ultrathink"],
119 }),
120 buildSelectOptionDescriptor({
121 id: "contextWindow",
122 label: "Context Window",
123 options: [
124 { value: "200k", label: "200k" },
125 { value: "1m", label: "1M", isDefault: true },
126 ],
127 }),
128 ],
129 }),
130 },
131 {
132 slug: "claude-opus-5",
133 name: "Claude Opus 5",
134 isCustom: false,
135 capabilities: createModelCapabilities({
136 optionDescriptors: [
137 buildSelectOptionDescriptor({
138 id: "effort",
139 label: "Reasoning",
140 options: [
141 { value: "low", label: "Low" },
142 { value: "medium", label: "Medium" },
143 { value: "high", label: "High", isDefault: true },
144 { value: "xhigh", label: "Extra High" },
145 { value: "max", label: "Max" },
146 {
147 value: "ultracode",
148 label: "Ultracode",
149 description: "xhigh effort plus multi-agent workflow orchestration",
150 },
151 { value: "ultrathink", label: "Ultrathink" },
152 ],
153 promptInjectedValues: ["ultrathink"],
154 }),
155 buildBooleanOptionDescriptor({
156 id: "fastMode",
157 label: "Fast Mode",
158 }),
159 buildSelectOptionDescriptor({
160 id: "contextWindow",
161 label: "Context Window",
162 // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`).
163 options: [
164 { value: "200k", label: "200k" },
165 { value: "1m", label: "1M", isDefault: true },
166 ],
167 }),
168 ],
169 }),
170 },
171 {
172 slug: "claude-opus-4-8",
173 name: "Claude Opus 4.8",
174 isCustom: false,
175 capabilities: createModelCapabilities({
176 optionDescriptors: [
177 buildSelectOptionDescriptor({
178 id: "effort",
179 label: "Reasoning",
180 options: [
181 { value: "low", label: "Low" },
182 { value: "medium", label: "Medium" },
183 { value: "high", label: "High", isDefault: true },
184 { value: "xhigh", label: "Extra High" },
185 { value: "max", label: "Max" },
186 {
187 value: "ultracode",
188 label: "Ultracode",
189 description: "xhigh effort plus multi-agent workflow orchestration",
190 },
191 { value: "ultrathink", label: "Ultrathink" },
192 ],
193 promptInjectedValues: ["ultrathink"],
194 }),
195 buildBooleanOptionDescriptor({
196 id: "fastMode",
197 label: "Fast Mode",
198 }),
199 ],
200 }),
201 },
202 {
203 slug: "claude-opus-4-7",
204 name: "Claude Opus 4.7",
205 isCustom: false,
206 capabilities: createModelCapabilities({
207 optionDescriptors: [
208 buildSelectOptionDescriptor({
209 id: "effort",
210 label: "Reasoning",
211 options: [
212 { value: "low", label: "Low" },
213 { value: "medium", label: "Medium" },
214 { value: "high", label: "High" },
215 { value: "xhigh", label: "Extra High", isDefault: true },
216 { value: "max", label: "Max" },
217 { value: "ultrathink", label: "Ultrathink" },
218 ],
219 promptInjectedValues: ["ultrathink"],
220 }),
221 buildBooleanOptionDescriptor({
222 id: "fastMode",
223 label: "Fast Mode",
224 }),
225 ],
226 }),
227 },
228 {
229 slug: "claude-opus-4-6",
230 name: "Claude Opus 4.6",
231 isCustom: false,
232 capabilities: createModelCapabilities({
233 optionDescriptors: [
234 buildSelectOptionDescriptor({
235 id: "effort",
236 label: "Reasoning",
237 options: [
238 { value: "low", label: "Low" },
239 { value: "medium", label: "Medium" },
240 { value: "high", label: "High", isDefault: true },
241 { value: "max", label: "Max" },
242 { value: "ultrathink", label: "Ultrathink" },
243 ],
244 promptInjectedValues: ["ultrathink"],
245 }),
246 buildBooleanOptionDescriptor({
247 id: "fastMode",
248 label: "Fast Mode",
249 }),
250 buildSelectOptionDescriptor({
251 id: "contextWindow",
252 label: "Context Window",
253 options: [
254 { value: "200k", label: "200k" },
255 { value: "1m", label: "1M", isDefault: true },
256 ],
257 }),
258 ],
259 }),
260 },
261 {
262 slug: "claude-opus-4-5",
263 name: "Claude Opus 4.5",
264 isCustom: false,
265 capabilities: createModelCapabilities({
266 optionDescriptors: [
267 buildSelectOptionDescriptor({
268 id: "effort",
269 label: "Reasoning",
270 options: [
271 { value: "low", label: "Low" },
272 { value: "medium", label: "Medium" },
273 { value: "high", label: "High", isDefault: true },
274 { value: "max", label: "Max" },
275 ],
276 }),
277 buildBooleanOptionDescriptor({
278 id: "fastMode",
279 label: "Fast Mode",
280 }),
281 ],
282 }),
283 },
284 {
285 slug: "claude-sonnet-5",
286 name: "Claude Sonnet 5",
287 isCustom: false,
288 capabilities: createModelCapabilities({
289 optionDescriptors: [
290 buildSelectOptionDescriptor({
291 id: "effort",
292 label: "Reasoning",
293 options: [
294 { value: "low", label: "Low" },
295 { value: "medium", label: "Medium" },
296 { value: "high", label: "High", isDefault: true },
297 { value: "xhigh", label: "Extra High" },
298 { value: "max", label: "Max" },
299 { value: "ultrathink", label: "Ultrathink" },
300 ],
301 promptInjectedValues: ["ultrathink"],
302 }),
303 buildSelectOptionDescriptor({
304 id: "contextWindow",
305 label: "Context Window",
306 // Sonnet is 200k-default in Claude Code (1M is opt-in there too).
307 options: [
308 { value: "200k", label: "200k", isDefault: true },
309 { value: "1m", label: "1M" },
310 ],
311 }),
312 ],
313 }),
314 },
315 {
316 slug: "claude-sonnet-4-6",
317 name: "Claude Sonnet 4.6",
318 isCustom: false,
319 capabilities: createModelCapabilities({
320 optionDescriptors: [
321 buildSelectOptionDescriptor({
322 id: "effort",
323 label: "Reasoning",
324 options: [
325 { value: "low", label: "Low" },
326 { value: "medium", label: "Medium" },
327 { value: "high", label: "High", isDefault: true },
328 { value: "max", label: "Max" },
329 { value: "ultrathink", label: "Ultrathink" },
330 ],
331 promptInjectedValues: ["ultrathink"],
332 }),
333 buildSelectOptionDescriptor({
334 id: "contextWindow",
335 label: "Context Window",
336 // Sonnet is 200k-default in Claude Code (1M is opt-in there too).
337 options: [
338 { value: "200k", label: "200k", isDefault: true },
339 { value: "1m", label: "1M" },
340 ],
341 }),
342 ],
343 }),
344 },
345 {
346 slug: "claude-haiku-4-5",
347 name: "Claude Haiku 4.5",
348 isCustom: false,
349 capabilities: createModelCapabilities({
350 optionDescriptors: [
351 buildBooleanOptionDescriptor({
352 id: "thinking",
353 label: "Thinking",
354 }),
355 ],
356 }),
357 },
358];
359
360// Legacy classification happens at the driver boundary via `applyModelManifest`,
361// so the catalog itself carries no `isLegacy` flags.
362const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG;
363
364function supportsClaudeFable51(version: string | null | undefined): boolean {
365 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false;
366}
367
368function supportsClaudeOpus5(version: string | null | undefined): boolean {
369 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false;
370}
371
372function supportsClaudeFable5(version: string | null | undefined): boolean {
373 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false;
374}
375
376function supportsClaudeOpus48(version: string | null | undefined): boolean {
377 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false;
378}
379
380function supportsClaudeOpus47(version: string | null | undefined): boolean {
381 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
382}
383
384function getBuiltInClaudeModelsForVersion(
385 version: string | null | undefined,
386): ReadonlyArray<ServerProviderModel> {
387 return BUILT_IN_MODELS.filter((model) => {
388 if (model.slug === "claude-fable-5-1") {
389 return supportsClaudeFable51(version);
390 }
391 if (model.slug === "claude-opus-5") {
392 return supportsClaudeOpus5(version);
393 }
394 if (model.slug === "claude-fable-5") {
395 return supportsClaudeFable5(version);
396 }
397 if (model.slug === "claude-opus-4-8") {
398 return supportsClaudeOpus48(version);
399 }
400 if (model.slug === "claude-opus-4-7") {
401 return supportsClaudeOpus47(version);
402 }
403 return true;
404 });
405}
406
407function formatClaudeFable51UpgradeMessage(version: string | null): string {
408 const versionLabel = version ? `v${version}` : "the installed version";
409 return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`;
410}
411
412function formatClaudeOpus5UpgradeMessage(version: string | null): string {
413 const versionLabel = version ? `v${version}` : "the installed version";
414 return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`;
415}
416
417function formatClaudeFable5UpgradeMessage(version: string | null): string {
418 const versionLabel = version ? `v${version}` : "the installed version";
419 return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`;
420}
421
422function formatClaudeOpus48UpgradeMessage(version: string | null): string {
423 const versionLabel = version ? `v${version}` : "the installed version";
424 return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`;
425}
426
427function formatClaudeOpus47UpgradeMessage(version: string | null): string {
428 const versionLabel = version ? `v${version}` : "the installed version";
429 return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`;
430}
431
432export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities {
433 const slug = model?.trim();
434 return (
435 BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ??
436 DEFAULT_CLAUDE_MODEL_CAPABILITIES
437 );
438}
439
440export function resolveClaudeEffort(
441 caps: ModelCapabilities,
442 raw: string | null | undefined,
443): string | undefined {
444 const descriptors = getProviderOptionDescriptors({
445 caps,
446 ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}),
447 });
448 const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort");
449 const value = getProviderOptionCurrentValue(effortDescriptor);
450 return typeof value === "string" ? value : undefined;
451}
452
453/**
454 * Normalize a resolved Claude effort value into one suitable for the Claude
455 * CLI's `--effort` flag.
456 *
457 * Mirrors the mapping used when invoking the Claude Agent SDK
458 * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): `ultracode` is a
459 * Claude Code setting that pairs with `xhigh`, `ultrathink` is filtered out
460 * because it is a prompt-prefix mode, and older model compatibility mappings
461 * are preserved for current Claude Code behavior.
462 */
463export function normalizeClaudeCliEffort(
464 effort: string | null | undefined,
465 model: string | null | undefined,
466): string | undefined {
467 if (!effort || effort === "ultrathink") {
468 return undefined;
469 }
470 if (effort === "ultracode") {
471 return "xhigh";
472 }
473 if (
474 effort === "xhigh" &&
475 model !== "claude-fable-5-1" &&
476 model !== "claude-fable-5" &&
477 model !== "claude-opus-5" &&
478 model !== "claude-opus-4-8" &&
479 model !== "claude-sonnet-5"
480 ) {
481 return "max";
482 }
483 if (effort === "max" && model === "claude-sonnet-4-6") {
484 return "high";
485 }
486 return effort;
487}
488
489export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean {
490 return effort === "ultracode";
491}
492
493export function resolveClaudeContextWindow(
494 modelSelection: ModelSelection | undefined,
495): string | undefined {
496 const caps = getClaudeModelCapabilities(modelSelection?.model);
497 const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow");
498 const descriptors = getProviderOptionDescriptors({
499 caps,
500 ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}),
501 });
502 const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow");
503 const value = getProviderOptionCurrentValue(descriptor);
504 return typeof value === "string" ? value : undefined;
505}
506
507export function resolveClaudeApiModelId(modelSelection: ModelSelection): string {
508 switch (resolveClaudeContextWindow(modelSelection)) {
509 case "1m":
510 return `${modelSelection.model}[1m]`;
511 default:
512 return modelSelection.model;
513 }
514}
515
51650function toTitleCaseWords(value: string): string {
51751 const parts: Array<string> = [];
51852 for (const part of value.split(/[\s_-]+/g)) {
......@@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
860394 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
861395 environment?: NodeJS.ProcessEnv,
862396 cwd?: string,
397 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
863398): Effect.fn.Return<
864399 ServerProviderDraft,
865400 never,
......@@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
868403 const resolvedEnvironment = environment ?? process.env;
869404 const checkedAt = DateTime.formatIso(yield* DateTime.now);
870405 const allModels = providerModelsFromSettings(
871 BUILT_IN_MODELS,
406 modelCatalog.models.map((entry) => entry.model),
872407 claudeSettings.customModels,
873408 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
874409 );
......@@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
958493 }
959494
960495 const models = providerModelsFromSettings(
961 getBuiltInClaudeModelsForVersion(parsedVersion),
496 resolveClaudeModelsForVersion(modelCatalog, parsedVersion),
962497 claudeSettings.customModels,
963498 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
964499 );
965 const versionUpgradeMessage = supportsClaudeFable51(parsedVersion)
966 ? undefined
967 : supportsClaudeOpus5(parsedVersion)
968 ? formatClaudeFable51UpgradeMessage(parsedVersion)
969 : supportsClaudeFable5(parsedVersion)
970 ? formatClaudeOpus5UpgradeMessage(parsedVersion)
971 : supportsClaudeOpus48(parsedVersion)
972 ? formatClaudeFable5UpgradeMessage(parsedVersion)
973 : supportsClaudeOpus47(parsedVersion)
974 ? formatClaudeOpus48UpgradeMessage(parsedVersion)
975 : formatClaudeOpus47UpgradeMessage(parsedVersion);
500 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
976501
977502 const capabilities = resolveCapabilities
978503 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
......@@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
1035560
1036561export const makePendingClaudeProvider = (
1037562 claudeSettings: ClaudeSettings,
563 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
1038564): Effect.Effect<ServerProviderDraft> =>
1039565 Effect.gen(function* () {
1040566 const checkedAt = yield* nowIso;
1041567 const models = providerModelsFromSettings(
1042 BUILT_IN_MODELS,
568 modelCatalog.models.map((entry) => entry.model),
1043569 claudeSettings.customModels,
1044570 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
1045571 );
apps/server/src/provider/Layers/ProviderRegistry.test.ts-242
......@@ -2051,248 +2051,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20512051 ),
20522052 );
20532053
2054 it.effect("includes Claude Opus 5 on supported Claude Code versions", () =>
2055 Effect.gen(function* () {
2056 const status = yield* checkClaudeProviderStatus(
2057 defaultClaudeSettings,
2058 claudeCapabilities(),
2059 );
2060 const opus5 = status.models.find((model) => model.slug === "claude-opus-5");
2061 assert.strictEqual(opus5?.name, "Claude Opus 5");
2062 }).pipe(
2063 Effect.provide(
2064 mockSpawnerLayer((args) => {
2065 const joined = args.join(" ");
2066 if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 };
2067 if (joined === "auth status")
2068 return {
2069 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2070 stderr: "",
2071 code: 0,
2072 };
2073 throw new Error(`Unexpected args: ${joined}`);
2074 }),
2075 ),
2076 ),
2077 );
2078
2079 it.effect("hides Claude Opus 5 on older Claude Code versions", () =>
2080 Effect.gen(function* () {
2081 const status = yield* checkClaudeProviderStatus(
2082 defaultClaudeSettings,
2083 claudeCapabilities(),
2084 );
2085 assert.strictEqual(
2086 status.models.some((model) => model.slug === "claude-opus-5"),
2087 false,
2088 );
2089 assert.strictEqual(
2090 status.message,
2091 "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.",
2092 );
2093 }).pipe(
2094 Effect.provide(
2095 mockSpawnerLayer((args) => {
2096 const joined = args.join(" ");
2097 if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 };
2098 if (joined === "auth status")
2099 return {
2100 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2101 stderr: "",
2102 code: 0,
2103 };
2104 throw new Error(`Unexpected args: ${joined}`);
2105 }),
2106 ),
2107 ),
2108 );
2109
2110 it.effect("includes Claude Fable 5 on supported Claude Code versions", () =>
2111 Effect.gen(function* () {
2112 const status = yield* checkClaudeProviderStatus(
2113 defaultClaudeSettings,
2114 claudeCapabilities(),
2115 );
2116 const fable5 = status.models.find((model) => model.slug === "claude-fable-5");
2117 assert.strictEqual(fable5?.name, "Claude Fable 5");
2118 }).pipe(
2119 Effect.provide(
2120 mockSpawnerLayer((args) => {
2121 const joined = args.join(" ");
2122 if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 };
2123 if (joined === "auth status")
2124 return {
2125 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2126 stderr: "",
2127 code: 0,
2128 };
2129 throw new Error(`Unexpected args: ${joined}`);
2130 }),
2131 ),
2132 ),
2133 );
2134
2135 it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () =>
2136 Effect.gen(function* () {
2137 const status = yield* checkClaudeProviderStatus(
2138 defaultClaudeSettings,
2139 claudeCapabilities(),
2140 );
2141 const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1");
2142 assert.strictEqual(fable51?.name, "Claude Fable 5.1");
2143 }).pipe(
2144 Effect.provide(
2145 mockSpawnerLayer((args) => {
2146 const joined = args.join(" ");
2147 if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 };
2148 if (joined === "auth status")
2149 return {
2150 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2151 stderr: "",
2152 code: 0,
2153 };
2154 throw new Error(`Unexpected args: ${joined}`);
2155 }),
2156 ),
2157 ),
2158 );
2159
2160 it.effect("hides Claude Fable 5.1 on older Claude Code versions", () =>
2161 Effect.gen(function* () {
2162 const status = yield* checkClaudeProviderStatus(
2163 defaultClaudeSettings,
2164 claudeCapabilities(),
2165 );
2166 assert.strictEqual(
2167 status.models.some((model) => model.slug === "claude-fable-5-1"),
2168 false,
2169 );
2170 assert.strictEqual(
2171 status.message,
2172 "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.",
2173 );
2174 }).pipe(
2175 Effect.provide(
2176 mockSpawnerLayer((args) => {
2177 const joined = args.join(" ");
2178 if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 };
2179 if (joined === "auth status")
2180 return {
2181 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2182 stderr: "",
2183 code: 0,
2184 };
2185 throw new Error(`Unexpected args: ${joined}`);
2186 }),
2187 ),
2188 ),
2189 );
2190
2191 it.effect("hides Claude Fable 5 on older Claude Code versions", () =>
2192 Effect.gen(function* () {
2193 const status = yield* checkClaudeProviderStatus(
2194 defaultClaudeSettings,
2195 claudeCapabilities(),
2196 );
2197 assert.strictEqual(
2198 status.models.some((model) => model.slug === "claude-fable-5"),
2199 false,
2200 );
2201 assert.strictEqual(
2202 status.message,
2203 "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.",
2204 );
2205 }).pipe(
2206 Effect.provide(
2207 mockSpawnerLayer((args) => {
2208 const joined = args.join(" ");
2209 if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 };
2210 if (joined === "auth status")
2211 return {
2212 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2213 stderr: "",
2214 code: 0,
2215 };
2216 throw new Error(`Unexpected args: ${joined}`);
2217 }),
2218 ),
2219 ),
2220 );
2221
2222 it.effect(
2223 "includes Claude Opus 4.7 with xhigh as the default effort on supported versions",
2224 () =>
2225 Effect.gen(function* () {
2226 const status = yield* checkClaudeProviderStatus(
2227 defaultClaudeSettings,
2228 claudeCapabilities(),
2229 );
2230 const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7");
2231 if (!opus47) {
2232 assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111.");
2233 }
2234 if (!opus47.capabilities) {
2235 assert.fail(
2236 "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.",
2237 );
2238 }
2239 const effortDescriptor = opus47.capabilities.optionDescriptors?.find(
2240 (descriptor) => descriptor.type === "select" && descriptor.id === "effort",
2241 );
2242 assert.deepStrictEqual(
2243 effortDescriptor?.type === "select"
2244 ? effortDescriptor.options.find((option) => option.isDefault)
2245 : undefined,
2246 { id: "xhigh", label: "Extra High", isDefault: true },
2247 );
2248 }).pipe(
2249 Effect.provide(
2250 mockSpawnerLayer((args) => {
2251 const joined = args.join(" ");
2252 if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 };
2253 if (joined === "auth status")
2254 return {
2255 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2256 stderr: "",
2257 code: 0,
2258 };
2259 throw new Error(`Unexpected args: ${joined}`);
2260 }),
2261 ),
2262 ),
2263 );
2264
2265 it.effect("hides Claude Opus 4.7 on older Claude Code versions", () =>
2266 Effect.gen(function* () {
2267 const status = yield* checkClaudeProviderStatus(
2268 defaultClaudeSettings,
2269 claudeCapabilities(),
2270 );
2271 assert.strictEqual(
2272 status.models.some((model) => model.slug === "claude-opus-4-7"),
2273 false,
2274 );
2275 assert.strictEqual(
2276 status.message,
2277 "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.",
2278 );
2279 }).pipe(
2280 Effect.provide(
2281 mockSpawnerLayer((args) => {
2282 const joined = args.join(" ");
2283 if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 };
2284 if (joined === "auth status")
2285 return {
2286 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
2287 stderr: "",
2288 code: 0,
2289 };
2290 throw new Error(`Unexpected args: ${joined}`);
2291 }),
2292 ),
2293 ),
2294 );
2295
22962054 it.effect("returns a display label for claude subscription types", () =>
22972055 Effect.gen(function* () {
22982056 const status = yield* checkClaudeProviderStatus(
apps/server/src/provider/ModelManifest.test.ts+239-64
......@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
33import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
67import { HttpClient, HttpClientResponse } from "effect/unstable/http";
78
89import * as ServerConfig from "../config.ts";
......@@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts";
1011import {
1112 BUNDLED_MODEL_MANIFEST,
1213 classifyModels,
13 isLegacyModel,
1414 make,
15 resolveProviderCatalog,
1516 type ModelManifestData,
1617} from "./ModelManifest.ts";
1718
18const CODEX = ProviderDriverKind.make("codex");
19const CLAUDE = ProviderDriverKind.make("claudeAgent");
20const CURSOR = ProviderDriverKind.make("cursor");
21
22describe("isLegacyModel (bundled manifest)", () => {
23 it("keeps current Codex models out of legacy models", () => {
24 assert.deepStrictEqual(
25 [
26 "gpt-5.6-luna",
27 "gpt-5.6-terra",
28 "gpt-5.6-sol",
29 "gpt-daybreak-blue-latest",
30 "gpt-daybreak-red-latest",
31 "gpt-5.4",
32 ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]),
33 [
34 ["gpt-5.6-luna", false],
35 ["gpt-5.6-terra", false],
36 ["gpt-5.6-sol", false],
37 ["gpt-daybreak-blue-latest", false],
38 ["gpt-daybreak-red-latest", false],
39 ["gpt-5.4", true],
40 ],
41 );
42 });
43
44 it("keeps only the Claude 5 family out of legacy models", () => {
45 assert.deepStrictEqual(
46 [
47 "claude-fable-5-1",
48 "claude-fable-5",
49 "claude-opus-5",
50 "claude-sonnet-5",
51 "claude-opus-4-8",
52 ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]),
53 [
54 ["claude-fable-5-1", false],
55 ["claude-fable-5", true],
56 ["claude-opus-5", false],
57 ["claude-sonnet-5", false],
58 ["claude-opus-4-8", true],
59 ],
60 );
61 });
62
63 it("leaves driver kinds without a manifest entry unflagged", () => {
64 assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5"));
65 });
66});
19/**
20 * Test policy: this file covers manifest machinery, not manifest contents.
21 * Do not add assertions for real model slugs, names, status, aliases, or
22 * profiles when editing model-manifest.json. Add tests only when fetch/cache
23 * behavior or the provider-neutral resolver semantics change, and use
24 * synthetic models for resolver coverage.
25 */
6726
27const CODEX = ProviderDriverKind.make("codex");
6828const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
6929 slug: "gpt-test",
7030 name: "GPT Test",
......@@ -75,37 +35,227 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>
7535
7636describe("classifyModels", () => {
7737 it("flags non-current models, clears stale flags, and skips custom models", () => {
38 const manifest: ModelManifestData = {
39 version: 1,
40 currentModels: { codex: ["current-a", "current-b"] },
41 };
7842 const models = [
79 model({ slug: "gpt-5.6-sol" }),
43 model({ slug: "current-a" }),
8044 // Stale flag from a previous classification pass must be cleared.
81 model({ slug: "gpt-5.6-luna", isLegacy: true }),
82 model({ slug: "gpt-5.4" }),
45 model({ slug: "current-b", isLegacy: true }),
46 model({ slug: "old-model" }),
8347 // Custom models are user-defined and never reclassified.
8448 model({ slug: "my-own-model", isCustom: true }),
8549 ];
8650 assert.deepStrictEqual(
87 classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [
88 entry.slug,
89 entry.isLegacy ?? false,
90 ]),
51 classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]),
9152 [
92 ["gpt-5.6-sol", false],
93 ["gpt-5.6-luna", false],
94 ["gpt-5.4", true],
53 ["current-a", false],
54 ["current-b", false],
55 ["old-model", true],
9556 ["my-own-model", false],
9657 ],
9758 );
9859 });
9960});
10061
62describe("resolveProviderCatalog", () => {
63 it("resolves generic model presentation through a reusable profile", () => {
64 const manifest: ModelManifestData = {
65 version: 1,
66 currentModels: {},
67 providers: {
68 synthetic: {
69 defaults: { chat: "model-next" },
70 profiles: {
71 standard: {
72 capabilities: {
73 optionDescriptors: [
74 {
75 id: "mode",
76 label: "Mode",
77 type: "select",
78 options: [{ id: "fast", label: "Fast", isDefault: true }],
79 },
80 ],
81 },
82 adapter: { opaque: true },
83 },
84 },
85 models: [
86 {
87 slug: "model-next",
88 name: "Model Next",
89 aliases: ["next"],
90 status: "current",
91 badge: "new",
92 profile: "standard",
93 },
94 ],
95 },
96 },
97 };
98
99 const catalog = resolveProviderCatalog(manifest, ProviderDriverKind.make("synthetic"));
100 assert.deepStrictEqual(catalog?.models[0], {
101 model: {
102 slug: "model-next",
103 name: "Model Next",
104 aliases: ["next"],
105 badge: "new",
106 isCustom: false,
107 isDefault: true,
108 capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!,
109 },
110 adapter: undefined,
111 profileAdapter: { opaque: true },
112 });
113 });
114
115 it("rejects invalid catalog references", () => {
116 const invalidCatalog = (input: {
117 readonly models: NonNullable<ModelManifestData["providers"]>[string]["models"];
118 readonly defaultChat?: string;
119 }): ModelManifestData => ({
120 version: 1,
121 currentModels: {},
122 providers: {
123 synthetic: {
124 ...(input.defaultChat ? { defaults: { chat: input.defaultChat } } : {}),
125 profiles: {},
126 models: input.models,
127 },
128 },
129 });
130
131 for (const invalid of [
132 invalidCatalog({
133 models: [
134 { slug: "duplicate", name: "First", status: "current" },
135 { slug: "duplicate", name: "Second", status: "current" },
136 ],
137 }),
138 invalidCatalog({
139 models: [
140 {
141 slug: "missing-profile",
142 name: "Missing Profile",
143 status: "current",
144 profile: "missing",
145 },
146 ],
147 }),
148 invalidCatalog({
149 models: [{ slug: "present", name: "Present", status: "current" }],
150 defaultChat: "absent",
151 }),
152 ]) {
153 assert.isNull(resolveProviderCatalog(invalid, ProviderDriverKind.make("synthetic")));
154 }
155 });
156});
157
101158const REMOTE_MANIFEST: ModelManifestData = {
102159 version: 1,
103160 currentModels: {
104 codex: ["gpt-5.4"],
105 claudeAgent: ["claude-fable-5"],
161 codex: ["remote-model"],
162 claudeAgent: ["remote-agent-model"],
106163 },
107164};
108165
166const REMOTE_CLAUDE_MANIFEST: ModelManifestData = {
167 version: 1,
168 currentModels: {},
169 providers: {
170 claudeAgent: {
171 profiles: {
172 synthetic: {
173 adapter: { claudeCode: { effortMap: { extreme: "high" } } },
174 },
175 },
176 models: [
177 {
178 slug: "remote-only-model",
179 name: "Remote Only Model",
180 status: "current",
181 profile: "synthetic",
182 },
183 ],
184 },
185 },
186};
187
188const remoteClaudeManifestWithCompatibility = (compatibility: unknown): ModelManifestData => ({
189 ...REMOTE_CLAUDE_MANIFEST,
190 providers: {
191 claudeAgent: {
192 profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles,
193 models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models.map((model) => ({
194 ...model,
195 adapter: { claudeCode: compatibility },
196 })),
197 },
198 },
199});
200
201const INVALID_REMOTE_MANIFESTS: ReadonlyArray<ModelManifestData> = [
202 {
203 ...REMOTE_CLAUDE_MANIFEST,
204 providers: {
205 claudeAgent: {
206 profiles: {
207 synthetic: {
208 adapter: { claudeCode: { effortMap: { extreme: 123 } } },
209 },
210 },
211 models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models,
212 },
213 },
214 },
215 {
216 ...REMOTE_CLAUDE_MANIFEST,
217 providers: {
218 claudeAgent: {
219 profiles: {},
220 models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models,
221 },
222 },
223 },
224 {
225 ...REMOTE_CLAUDE_MANIFEST,
226 providers: {
227 claudeAgent: {
228 profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles,
229 models: [
230 ...REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models,
231 {
232 slug: "remote-only-model",
233 name: "Duplicate Remote Model",
234 status: "current",
235 profile: "synthetic",
236 },
237 ],
238 },
239 },
240 },
241 {
242 ...REMOTE_CLAUDE_MANIFEST,
243 providers: {
244 claudeAgent: {
245 defaults: { chat: "absent-model" },
246 profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles,
247 models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models,
248 },
249 },
250 },
251 remoteClaudeManifestWithCompatibility({ minVersion: "2.x" }),
252 remoteClaudeManifestWithCompatibility({ maxVersionExclusive: "2.x" }),
253 remoteClaudeManifestWithCompatibility({
254 minVersion: "2.2",
255 maxVersionExclusive: "2.1",
256 }),
257];
258
109259const httpClientLayer = (handler: () => Response) =>
110260 Layer.succeed(
111261 HttpClient.HttpClient,
......@@ -129,8 +279,6 @@ describe("ModelManifest service", () => {
129279 const service = yield* make;
130280 const refreshed = yield* service.refresh;
131281 assert.deepStrictEqual(refreshed, REMOTE_MANIFEST);
132 assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol"));
133 assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4"));
134282
135283 // A fresh service instance sees the disk cache without another fetch:
136284 // its HTTP layer is still stubbed, but `current` never fetches at all.
......@@ -162,6 +310,33 @@ describe("ModelManifest service", () => {
162310 ),
163311 );
164312
313 it.effect("preserves the last-good remote cache when later payloads are invalid", () => {
314 let responseIndex = 0;
315 const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS];
316
317 return Effect.gen(function* () {
318 const service = yield* make;
319 assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST);
320
321 for (const _invalid of INVALID_REMOTE_MANIFESTS) {
322 yield* TestClock.adjust("1 hour");
323 responseIndex += 1;
324 assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST);
325 }
326
327 const rebooted = yield* make;
328 assert.deepStrictEqual(yield* rebooted.current, REMOTE_CLAUDE_MANIFEST);
329 }).pipe(
330 Effect.scoped,
331 Effect.provide(
332 serviceLayers({
333 prefix: "model-manifest-last-good-test",
334 response: () => Response.json(responses[responseIndex]),
335 }),
336 ),
337 );
338 });
339
165340 it.live("does not fetch when provider update checks are disabled", () =>
166341 Effect.gen(function* () {
167342 let fetchCount = 0;
apps/server/src/provider/ModelManifest.ts+139-18
......@@ -1,20 +1,24 @@
11/**
2 * ModelManifest — decides which provider models are current and which belong
3 * in the model picker's legacy section.
2 * ModelManifest — remote provider-model metadata with a bundled offline
3 * fallback.
44 *
5 * The classification data (current slugs per driver kind) lives in
6 * `model-manifest.json` next to this file. The bundled copy ships with every
7 * release. At runtime the service refreshes it from the same file on `main`
8 * via raw.githubusercontent.com, so a new model can leave the legacy section
9 * with a commit to `main` instead of a release. Preference order is remote,
10 * then the on-disk copy of the last successful fetch, then the bundle. A
11 * failed fetch never fails a provider check.
5 * Provider catalogs and legacy classification live in `model-manifest.json`.
6 * The bundled copy ships with every release; at runtime the service refreshes
7 * it from the same file on `main`. Preference order is remote, then the last
8 * successful on-disk copy, then the bundle. A failed fetch never fails a
9 * provider check.
1210 *
13 * Drivers apply the manifest to snapshot drafts with `applyModelManifest`
14 * before publishing, so every path that produces models (pending, probe,
15 * error fallbacks) is classified the same way.
11 * Providers with authoritative discovery can use only the classification
12 * overlay. Providers with static catalogs can resolve presentation and
13 * capabilities from `providers`, then decode their own allowlisted adapter
14 * payload separately.
1615 */
17import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";
16import {
17 ModelCapabilities,
18 TrimmedNonEmptyString,
19 type ProviderDriverKind,
20 type ServerProviderModel,
21} from "@t3tools/contracts";
1822import * as Clock from "effect/Clock";
1923import * as Context from "effect/Context";
2024import * as Effect from "effect/Effect";
......@@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2731
2832import { ServerConfig } from "../config.ts";
2933import * as ServerSettings from "../serverSettings.ts";
34import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
3035import bundledManifestJson from "./model-manifest.json" with { type: "json" };
3136import type { ServerProviderDraft } from "./providerSnapshot.ts";
3237
......@@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000;
4247
4348const FETCH_TIMEOUT_MS = 10_000;
4449
50const ManifestModelStatus = Schema.Literals(["current", "legacy"]);
51
52const ManifestModelProfile = Schema.Struct({
53 capabilities: Schema.optional(ModelCapabilities),
54 adapter: Schema.optional(Schema.Unknown),
55});
56
57const ManifestProviderModel = Schema.Struct({
58 slug: TrimmedNonEmptyString,
59 name: TrimmedNonEmptyString,
60 shortName: Schema.optional(TrimmedNonEmptyString),
61 subProvider: Schema.optional(TrimmedNonEmptyString),
62 aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
63 status: ManifestModelStatus,
64 badge: Schema.optional(Schema.Literal("new")),
65 profile: Schema.optional(TrimmedNonEmptyString),
66 adapter: Schema.optional(Schema.Unknown),
67});
68
69const ManifestProviderCatalog = Schema.Struct({
70 defaults: Schema.optional(
71 Schema.Struct({
72 chat: Schema.optional(TrimmedNonEmptyString),
73 }),
74 ),
75 profiles: Schema.Record(Schema.String, ManifestModelProfile),
76 models: Schema.Array(ManifestProviderModel),
77});
78
4579/**
46 * `version` gates breaking schema changes: a build only accepts remote
47 * manifests whose version it understands, and keeps its bundled copy
48 * otherwise. `currentModels` is keyed by driver kind; kinds absent from the
49 * map have no legacy concept and their models are left unflagged.
80 * `version` gates breaking schema changes. Provider catalogs are additive so
81 * clients that only understand `currentModels` keep accepting this v1 file.
5082 */
51const ModelManifestSchema = Schema.Struct({
83const ModelManifestEnvelopeSchema = Schema.Struct({
5284 version: Schema.Literal(1),
5385 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
86 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
5487});
88
89const hasValidProviderCatalogReferences = (
90 manifest: typeof ModelManifestEnvelopeSchema.Type,
91): boolean =>
92 Object.values(manifest.providers ?? {}).every((catalog) => {
93 const slugs = new Set<string>();
94 const modelsAreValid = catalog.models.every((model) => {
95 if (slugs.has(model.slug)) return false;
96 slugs.add(model.slug);
97 return model.profile === undefined || catalog.profiles[model.profile] !== undefined;
98 });
99 return (
100 modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat))
101 );
102 });
103
104const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe(
105 Schema.check(
106 Schema.makeFilter(hasValidProviderCatalogReferences, {
107 expected: "unique model slugs and existing model and profile references",
108 }),
109 Schema.makeFilter(hasValidClaudeManifestAdapters, {
110 expected: "valid Claude adapter metadata",
111 }),
112 ),
113);
55114export type ModelManifestData = typeof ModelManifestSchema.Type;
56115
116export interface ResolvedManifestModel {
117 readonly model: ServerProviderModel;
118 readonly adapter: unknown;
119 readonly profileAdapter: unknown;
120}
121
122export interface ResolvedProviderCatalog {
123 readonly models: ReadonlyArray<ResolvedManifestModel>;
124 readonly defaults: {
125 readonly chat: string | undefined;
126 };
127}
128
57129const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
58130
59131export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
60132 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
61133
134/** Resolve provider-neutral model presentation and capability data. */
135export function resolveProviderCatalog(
136 manifest: ModelManifestData,
137 driverKind: ProviderDriverKind,
138): ResolvedProviderCatalog | null {
139 const catalog = manifest.providers?.[driverKind];
140 if (!catalog) return null;
141
142 const seen = new Set<string>();
143 const models: Array<ResolvedManifestModel> = [];
144 for (const entry of catalog.models) {
145 if (seen.has(entry.slug)) return null;
146 seen.add(entry.slug);
147
148 const profile = entry.profile ? catalog.profiles[entry.profile] : undefined;
149 if (entry.profile && !profile) return null;
150
151 models.push({
152 model: {
153 slug: entry.slug,
154 name: entry.name,
155 ...(entry.shortName ? { shortName: entry.shortName } : {}),
156 ...(entry.subProvider ? { subProvider: entry.subProvider } : {}),
157 ...(entry.aliases ? { aliases: entry.aliases } : {}),
158 ...(entry.badge ? { badge: entry.badge } : {}),
159 isCustom: false,
160 ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}),
161 ...(entry.status === "legacy" ? { isLegacy: true } : {}),
162 capabilities: profile?.capabilities ?? null,
163 },
164 adapter: entry.adapter,
165 profileAdapter: profile?.adapter,
166 });
167 }
168
169 if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null;
170
171 return {
172 models,
173 defaults: {
174 chat: catalog.defaults?.chat,
175 },
176 };
177}
178
62179/** On-disk shape of the last successfully fetched manifest. */
63180const ManifestCacheFile = Schema.Struct({
64181 fetchedAtMs: Schema.Number,
......@@ -81,6 +198,10 @@ export function isLegacyModel(
81198 driverKind: ProviderDriverKind,
82199 slug: string,
83200): boolean {
201 const catalogModel = manifest.providers?.[driverKind]?.models.find(
202 (model) => model.slug === slug,
203 );
204 if (catalogModel) return catalogModel.status === "legacy";
84205 const currentModels = manifest.currentModels[driverKind];
85206 if (!currentModels) return false;
86207 return !currentModels.includes(slug);
apps/server/src/provider/model-manifest.json+359
......@@ -9,5 +9,364 @@
99 "gpt-daybreak-red-latest"
1010 ],
1111 "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"]
12 },
13 "providers": {
14 "claudeAgent": {
15 "defaults": {
16 "chat": "claude-sonnet-5"
17 },
18 "profiles": {
19 "fable-5": {
20 "capabilities": {
21 "optionDescriptors": [
22 {
23 "id": "effort",
24 "label": "Reasoning",
25 "type": "select",
26 "options": [
27 { "id": "low", "label": "Low" },
28 { "id": "medium", "label": "Medium" },
29 { "id": "high", "label": "High", "isDefault": true },
30 { "id": "xhigh", "label": "Extra High" },
31 { "id": "max", "label": "Max" },
32 {
33 "id": "ultracode",
34 "label": "Ultracode",
35 "description": "xhigh effort plus multi-agent workflow orchestration"
36 },
37 { "id": "ultrathink", "label": "Ultrathink" }
38 ],
39 "promptInjectedValues": ["ultrathink"]
40 },
41 {
42 "id": "contextWindow",
43 "label": "Context Window",
44 "type": "select",
45 "options": [
46 { "id": "200k", "label": "200k" },
47 { "id": "1m", "label": "1M", "isDefault": true }
48 ]
49 }
50 ]
51 },
52 "adapter": {
53 "claudeCode": {
54 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
55 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
56 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
57 }
58 }
59 },
60 "opus-5": {
61 "capabilities": {
62 "optionDescriptors": [
63 {
64 "id": "effort",
65 "label": "Reasoning",
66 "type": "select",
67 "options": [
68 { "id": "low", "label": "Low" },
69 { "id": "medium", "label": "Medium" },
70 { "id": "high", "label": "High", "isDefault": true },
71 { "id": "xhigh", "label": "Extra High" },
72 { "id": "max", "label": "Max" },
73 {
74 "id": "ultracode",
75 "label": "Ultracode",
76 "description": "xhigh effort plus multi-agent workflow orchestration"
77 },
78 { "id": "ultrathink", "label": "Ultrathink" }
79 ],
80 "promptInjectedValues": ["ultrathink"]
81 },
82 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },
83 {
84 "id": "contextWindow",
85 "label": "Context Window",
86 "type": "select",
87 "options": [
88 { "id": "200k", "label": "200k" },
89 { "id": "1m", "label": "1M", "isDefault": true }
90 ]
91 }
92 ]
93 },
94 "adapter": {
95 "claudeCode": {
96 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
97 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
98 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
99 }
100 }
101 },
102 "opus-4-8": {
103 "capabilities": {
104 "optionDescriptors": [
105 {
106 "id": "effort",
107 "label": "Reasoning",
108 "type": "select",
109 "options": [
110 { "id": "low", "label": "Low" },
111 { "id": "medium", "label": "Medium" },
112 { "id": "high", "label": "High", "isDefault": true },
113 { "id": "xhigh", "label": "Extra High" },
114 { "id": "max", "label": "Max" },
115 {
116 "id": "ultracode",
117 "label": "Ultracode",
118 "description": "xhigh effort plus multi-agent workflow orchestration"
119 },
120 { "id": "ultrathink", "label": "Ultrathink" }
121 ],
122 "promptInjectedValues": ["ultrathink"]
123 },
124 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
125 ]
126 },
127 "adapter": {
128 "claudeCode": {
129 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
130 "fixedContextWindowTokens": 1000000
131 }
132 }
133 },
134 "opus-4-7": {
135 "capabilities": {
136 "optionDescriptors": [
137 {
138 "id": "effort",
139 "label": "Reasoning",
140 "type": "select",
141 "options": [
142 { "id": "low", "label": "Low" },
143 { "id": "medium", "label": "Medium" },
144 { "id": "high", "label": "High" },
145 { "id": "xhigh", "label": "Extra High", "isDefault": true },
146 { "id": "max", "label": "Max" },
147 { "id": "ultrathink", "label": "Ultrathink" }
148 ],
149 "promptInjectedValues": ["ultrathink"]
150 },
151 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
152 ]
153 },
154 "adapter": {
155 "claudeCode": {
156 "effortMap": { "xhigh": "max", "ultrathink": null },
157 "fixedContextWindowTokens": 1000000
158 }
159 }
160 },
161 "opus-4-6": {
162 "capabilities": {
163 "optionDescriptors": [
164 {
165 "id": "effort",
166 "label": "Reasoning",
167 "type": "select",
168 "options": [
169 { "id": "low", "label": "Low" },
170 { "id": "medium", "label": "Medium" },
171 { "id": "high", "label": "High", "isDefault": true },
172 { "id": "max", "label": "Max" },
173 { "id": "ultrathink", "label": "Ultrathink" }
174 ],
175 "promptInjectedValues": ["ultrathink"]
176 },
177 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },
178 {
179 "id": "contextWindow",
180 "label": "Context Window",
181 "type": "select",
182 "options": [
183 { "id": "200k", "label": "200k" },
184 { "id": "1m", "label": "1M", "isDefault": true }
185 ]
186 }
187 ]
188 },
189 "adapter": {
190 "claudeCode": {
191 "effortMap": { "ultrathink": null },
192 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
193 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
194 }
195 }
196 },
197 "opus-4-5": {
198 "capabilities": {
199 "optionDescriptors": [
200 {
201 "id": "effort",
202 "label": "Reasoning",
203 "type": "select",
204 "options": [
205 { "id": "low", "label": "Low" },
206 { "id": "medium", "label": "Medium" },
207 { "id": "high", "label": "High", "isDefault": true },
208 { "id": "max", "label": "Max" }
209 ]
210 },
211 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
212 ]
213 },
214 "adapter": { "claudeCode": {} }
215 },
216 "sonnet-5": {
217 "capabilities": {
218 "optionDescriptors": [
219 {
220 "id": "effort",
221 "label": "Reasoning",
222 "type": "select",
223 "options": [
224 { "id": "low", "label": "Low" },
225 { "id": "medium", "label": "Medium" },
226 { "id": "high", "label": "High", "isDefault": true },
227 { "id": "xhigh", "label": "Extra High" },
228 { "id": "max", "label": "Max" },
229 { "id": "ultrathink", "label": "Ultrathink" }
230 ],
231 "promptInjectedValues": ["ultrathink"]
232 },
233 {
234 "id": "contextWindow",
235 "label": "Context Window",
236 "type": "select",
237 "options": [
238 { "id": "200k", "label": "200k", "isDefault": true },
239 { "id": "1m", "label": "1M" }
240 ]
241 }
242 ]
243 },
244 "adapter": {
245 "claudeCode": {
246 "effortMap": { "ultrathink": null },
247 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
248 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
249 }
250 }
251 },
252 "sonnet-4-6": {
253 "capabilities": {
254 "optionDescriptors": [
255 {
256 "id": "effort",
257 "label": "Reasoning",
258 "type": "select",
259 "options": [
260 { "id": "low", "label": "Low" },
261 { "id": "medium", "label": "Medium" },
262 { "id": "high", "label": "High", "isDefault": true },
263 { "id": "max", "label": "Max" },
264 { "id": "ultrathink", "label": "Ultrathink" }
265 ],
266 "promptInjectedValues": ["ultrathink"]
267 },
268 {
269 "id": "contextWindow",
270 "label": "Context Window",
271 "type": "select",
272 "options": [
273 { "id": "200k", "label": "200k", "isDefault": true },
274 { "id": "1m", "label": "1M" }
275 ]
276 }
277 ]
278 },
279 "adapter": {
280 "claudeCode": {
281 "effortMap": { "max": "high", "ultrathink": null },
282 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
283 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
284 }
285 }
286 },
287 "haiku-4-5": {
288 "capabilities": {
289 "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }]
290 },
291 "adapter": { "claudeCode": {} }
292 }
293 },
294 "models": [
295 {
296 "slug": "claude-fable-5-1",
297 "name": "Claude Fable 5.1",
298 "aliases": ["fable", "fable-5.1", "claude-fable-5.1"],
299 "status": "current",
300 "badge": "new",
301 "profile": "fable-5",
302 "adapter": { "claudeCode": { "minVersion": "2.1.257" } }
303 },
304 {
305 "slug": "claude-fable-5",
306 "name": "Claude Fable 5",
307 "status": "legacy",
308 "profile": "fable-5",
309 "adapter": { "claudeCode": { "minVersion": "2.1.169" } }
310 },
311 {
312 "slug": "claude-opus-5",
313 "name": "Claude Opus 5",
314 "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"],
315 "status": "current",
316 "profile": "opus-5",
317 "adapter": { "claudeCode": { "minVersion": "2.1.219" } }
318 },
319 {
320 "slug": "claude-opus-4-8",
321 "name": "Claude Opus 4.8",
322 "aliases": ["opus-4.8", "claude-opus-4.8"],
323 "status": "legacy",
324 "profile": "opus-4-8",
325 "adapter": { "claudeCode": { "minVersion": "2.1.154" } }
326 },
327 {
328 "slug": "claude-opus-4-7",
329 "name": "Claude Opus 4.7",
330 "aliases": ["opus-4.7", "claude-opus-4.7"],
331 "status": "legacy",
332 "profile": "opus-4-7",
333 "adapter": { "claudeCode": { "minVersion": "2.1.111" } }
334 },
335 {
336 "slug": "claude-opus-4-6",
337 "name": "Claude Opus 4.6",
338 "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"],
339 "status": "legacy",
340 "profile": "opus-4-6"
341 },
342 {
343 "slug": "claude-opus-4-5",
344 "name": "Claude Opus 4.5",
345 "status": "legacy",
346 "profile": "opus-4-5"
347 },
348 {
349 "slug": "claude-sonnet-5",
350 "name": "Claude Sonnet 5",
351 "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"],
352 "status": "current",
353 "profile": "sonnet-5"
354 },
355 {
356 "slug": "claude-sonnet-4-6",
357 "name": "Claude Sonnet 4.6",
358 "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"],
359 "status": "legacy",
360 "profile": "sonnet-4-6"
361 },
362 {
363 "slug": "claude-haiku-4-5",
364 "name": "Claude Haiku 4.5",
365 "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"],
366 "status": "legacy",
367 "profile": "haiku-4-5"
368 }
369 ]
370 }
12371 }
13372}
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+79-20
......@@ -11,6 +11,13 @@ import * as Schema from "effect/Schema";
1111import { expect } from "vite-plus/test";
1212
1313import * as ServerConfig from "../config.ts";
14import {
15 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
16 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
17 SYNTHETIC_CLAUDE_MODEL_CATALOG,
18 SYNTHETIC_CLAUDE_STANDARD_MODEL,
19 SYNTHETIC_CLAUDE_THINKING_MODEL,
20} from "../provider/ClaudeModelCatalog.testFixtures.ts";
1421import * as TextGeneration from "./TextGeneration.ts";
1522import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
1623import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
......@@ -219,13 +226,17 @@ function withFakeClaudeEnv<A, E, R>(
219226 );
220227
221228 const config = decodeClaudeSettings(input.claudeConfig ?? {});
222 const textGeneration = yield* makeClaudeTextGeneration(config);
229 const textGeneration = yield* makeClaudeTextGeneration(
230 config,
231 undefined,
232 Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
233 );
223234 return yield* effectFn(textGeneration);
224235 }).pipe(Effect.scoped);
225236}
226237
227238it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
228 it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
239 it.effect("forwards Claude thinking settings without passing unsupported effort", () =>
229240 withFakeClaudeEnv(
230241 {
231242 output: JSON.stringify({
......@@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
245256 stagedSummary: "M README.md",
246257 stagedPatch: "diff --git a/README.md b/README.md",
247258 modelSelection: {
248 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [
249 { id: "thinking", value: false },
250 { id: "effort", value: "high" },
251 ]),
259 ...createModelSelection(
260 ProviderInstanceId.make("claudeAgent"),
261 SYNTHETIC_CLAUDE_THINKING_MODEL,
262 [
263 { id: "thinking", value: false },
264 { id: "effort", value: "high" },
265 ],
266 ),
252267 },
253268 });
254269
......@@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
257272 ),
258273 );
259274
260 it.effect("forwards Claude fast mode and supported effort", () =>
275 it.effect("keeps a configured custom alias opaque to the Claude CLI", () =>
261276 withFakeClaudeEnv(
262277 {
263278 output: JSON.stringify({
264279 structured_output: {
265 title: "Improve orchestration flow",
266 body: "Body",
280 title: "Keep custom model",
281 body: "",
267282 },
268283 }),
269 argsMustContain: '--effort max --settings {"fastMode":true}',
284 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`,
285 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
270286 },
271287 (textGeneration) =>
272288 Effect.gen(function* () {
273289 const generated = yield* textGeneration.generatePrContent({
274290 cwd: process.cwd(),
275291 baseBranch: "main",
276 headBranch: "feature/claude-effect",
277 commitSummary: "Improve orchestration",
292 headBranch: "feature/custom-model",
293 commitSummary: "Keep custom model",
278294 diffSummary: "1 file changed",
279295 diffPatch: "diff --git a/README.md b/README.md",
280 modelSelection: {
281 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
296 modelSelection: createModelSelection(
297 ProviderInstanceId.make("claudeAgent"),
298 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
299 [
282300 { id: "effort", value: "max" },
283301 { id: "fastMode", value: true },
284 ]),
285 },
302 { id: "contextWindow", value: "expanded" },
303 ],
304 ),
286305 });
287306
288 expect(generated.title).toBe("Improve orchestration flow");
307 expect(generated.title).toBe("Keep custom model");
289308 }),
290309 ),
291310 );
292311
312 it.effect(
313 "keeps canonical built-in capabilities when a custom model collides with its alias",
314 () =>
315 withFakeClaudeEnv(
316 {
317 output: JSON.stringify({
318 structured_output: {
319 title: "Improve orchestration flow",
320 body: "Body",
321 },
322 }),
323 argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"fastMode":true} --dangerously-skip-permissions`,
324 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
325 },
326 (textGeneration) =>
327 Effect.gen(function* () {
328 const generated = yield* textGeneration.generatePrContent({
329 cwd: process.cwd(),
330 baseBranch: "main",
331 headBranch: "feature/claude-effect",
332 commitSummary: "Improve orchestration",
333 diffSummary: "1 file changed",
334 diffPatch: "diff --git a/README.md b/README.md",
335 modelSelection: {
336 ...createModelSelection(
337 ProviderInstanceId.make("claudeAgent"),
338 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
339 [
340 { id: "effort", value: "max" },
341 { id: "fastMode", value: true },
342 ],
343 ),
344 },
345 });
346
347 expect(generated.title).toBe("Improve orchestration flow");
348 }),
349 ),
350 );
351
293352 it.effect("generates thread titles through the Claude provider", () =>
294353 withFakeClaudeEnv(
295354 {
......@@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
308367 message: "Please investigate reconnect failures after restarting the session.",
309368 modelSelection: {
310369 instanceId: ProviderInstanceId.make("claudeAgent"),
311 model: "claude-sonnet-4-6",
370 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
312371 },
313372 });
314373
......@@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
343402 message: "thread title",
344403 modelSelection: {
345404 instanceId: ProviderInstanceId.make("claudeAgent"),
346 model: "claude-sonnet-4-6",
405 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
347406 },
348407 });
349408
......@@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
369428 message: "Name this thread.",
370429 modelSelection: {
371430 instanceId: ProviderInstanceId.make("claudeAgent"),
372 model: "claude-sonnet-4-6",
431 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
373432 },
374433 });
375434
apps/server/src/textGeneration/ClaudeTextGeneration.ts+34-13
......@@ -37,12 +37,16 @@ import {
3737 getProviderOptionDescriptors,
3838} from "@t3tools/shared/model";
3939import {
40 getClaudeModelCapabilities,
41 isClaudeUltracodeEffort,
42 normalizeClaudeCliEffort,
43 resolveClaudeApiModelId,
44 resolveClaudeEffort,
45} from "../provider/Layers/ClaudeProvider.ts";
40 BUNDLED_CLAUDE_MODEL_CATALOG,
41 type ClaudeModelCatalog,
42 getClaudeCatalogModelCapabilities,
43 isClaudeCatalogUltracodeEffort,
44 normalizeClaudeCatalogEffort,
45 resolveClaudeCatalogApiModelId,
46 resolveClaudeCatalogEffort,
47 resolveClaudeModelSlug,
48 scopeClaudeModelCatalog,
49} from "../provider/ClaudeModelCatalog.ts";
4650import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
4751
4852const CLAUDE_TIMEOUT_MS = 180_000;
......@@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla
6165export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
6266 claudeSettings: ClaudeSettings,
6367 environment?: NodeJS.ProcessEnv,
68 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
6469) {
6570 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6671 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
72 const scopedModelCatalog = modelCatalog.pipe(
73 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
74 );
6775
6876 const readStreamAsString = <E>(
6977 operation: string,
......@@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
121129 outputSchemaJson: S;
122130 modelSelection: ModelSelection;
123131 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
132 const catalog = yield* scopedModelCatalog;
133 const resolvedModelSelection = {
134 ...modelSelection,
135 model: resolveClaudeModelSlug(catalog, modelSelection.model),
136 };
124137 const jsonSchemaStr = yield* encodeJsonForOperation(
125138 operation,
126139 toJsonSchemaObject(outputSchemaJson),
127140 "Failed to encode structured output schema.",
128141 );
129 const caps = getClaudeModelCapabilities(modelSelection.model);
142 const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model);
130143 const descriptors = getProviderOptionDescriptors({
131144 caps,
132 selections: modelSelection.options,
145 selections: resolvedModelSelection.options,
133146 });
134147 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
135 const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort");
136 const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection);
137 const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model);
138 const ultracode = isClaudeUltracodeEffort(resolvedEffort);
148 const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort");
149 const resolvedEffort = resolveClaudeCatalogEffort(
150 catalog,
151 resolvedModelSelection.model,
152 rawEffortSelection,
153 );
154 const cliEffort = normalizeClaudeCatalogEffort(
155 catalog,
156 resolvedEffort,
157 resolvedModelSelection.model,
158 );
159 const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort);
139160 const thinkingDescriptor = findDescriptor("thinking");
140161 const fastModeDescriptor = findDescriptor("fastMode");
141162 const thinking =
......@@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
166187 "--json-schema",
167188 jsonSchemaStr,
168189 "--model",
169 resolveClaudeApiModelId(modelSelection),
190 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
170191 ...(cliEffort ? ["--effort", cliEffort] : []),
171192 ...(settingsJson ? ["--settings", settingsJson] : []),
172193 "--dangerously-skip-permissions",
apps/web/src/components/chat/ModelPickerContent.tsx+3-2
......@@ -15,7 +15,6 @@ import {
1515 parseModelPickerLegacySectionKey,
1616 parseModelPickerModelKey,
1717} from "./modelPickerKeys";
18import { isModelPickerNewModel } from "./modelPickerModelHighlights";
1918import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
2019import {
2120 Combobox,
......@@ -47,6 +46,7 @@ type ModelPickerItem = {
4746 name: string;
4847 shortName?: string;
4948 subProvider?: string;
49 badge?: "new";
5050 instanceId: ProviderInstanceId;
5151 driverKind: ProviderDriverKind;
5252 instanceDisplayName: string;
......@@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
256256 name: model.name,
257257 ...(model.shortName ? { shortName: model.shortName } : {}),
258258 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
259 ...(model.badge ? { badge: model.badge } : {}),
259260 ...(model.isLegacy ? { isLegacy: true } : {}),
260261 ...(model.isUnavailable ? { isUnavailable: true } : {}),
261262 instanceId,
......@@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
804805 showProvider
805806 preferShortName={!isLocked}
806807 useTriggerLabel={false}
807 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
808 showNewBadge={model.badge === "new"}
808809 unavailable={model.isUnavailable === true}
809810 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
810811 disabledReason={disabledReason}
apps/web/src/components/chat/composerProviderState.test.tsx+4-3
......@@ -291,11 +291,12 @@ describe("getComposerProviderState", () => {
291291 it("validates options for a known model selected through a legacy alias", () => {
292292 const state = getComposerProviderState({
293293 provider: ProviderDriverKind.make("claudeAgent"),
294 model: "opus",
294 model: "legacy-test-model",
295295 models: [
296296 {
297 slug: "claude-opus-5",
298 name: "Claude Opus 5",
297 slug: "test-model",
298 name: "Test Model",
299 aliases: ["legacy-test-model"],
299300 isCustom: false,
300301 capabilities: {
301302 optionDescriptors: [
apps/web/src/components/chat/modelPickerModelHighlights.ts deleted-13
......@@ -1,13 +0,0 @@
1import type { ProviderDriverKind } from "@t3tools/contracts";
2
3/**
4 * Model slugs that show a gold "NEW" chip in the model picker list.
5 * Add entries as `provider:slug` when you want to highlight freshly shipped models.
6 */
7const NEW_MODEL_KEYS = new Set<string>([
8 // Example: "claudeAgent:claude-opus-4-7",
9]);
10
11export function isModelPickerNewModel(provider: ProviderDriverKind, slug: string): boolean {
12 return NEW_MODEL_KEYS.has(`${provider}:${slug}`);
13}
apps/web/src/components/chat/providerIconUtils.ts+1
......@@ -26,6 +26,7 @@ export type ModelEsque = {
2626 name: string;
2727 shortName?: string | undefined;
2828 subProvider?: string | undefined;
29 badge?: "new" | undefined;
2930 isLegacy?: boolean | undefined;
3031 isUnavailable?: boolean | undefined;
3132};
apps/web/src/modelSelection.ts+4
......@@ -75,6 +75,8 @@ export interface AppModelOption {
7575 name: string;
7676 shortName?: string;
7777 subProvider?: string;
78 aliases?: ReadonlyArray<string>;
79 badge?: "new";
7880 isCustom: boolean;
7981 isDefault?: boolean;
8082 isLegacy?: boolean;
......@@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti
109111 };
110112 if (model.shortName) option.shortName = model.shortName;
111113 if (model.subProvider) option.subProvider = model.subProvider;
114 if (model.aliases) option.aliases = model.aliases;
115 if (model.badge) option.badge = model.badge;
112116 if (model.isDefault) option.isDefault = true;
113117 if (model.isLegacy) option.isLegacy = true;
114118 return option;
apps/web/src/providerModels.test.ts created+77
......@@ -0,0 +1,77 @@
1import {
2 ProviderDriverKind,
3 type ModelCapabilities,
4 type ServerProviderModel,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { getProviderModelCapabilities } from "./providerModels";
9
10const PROVIDER = ProviderDriverKind.make("claudeAgent");
11
12function capabilities(id: string): ModelCapabilities {
13 return {
14 optionDescriptors: [{ id, label: id, type: "boolean" }],
15 };
16}
17
18function model(input: {
19 slug: string;
20 capabilities: ModelCapabilities;
21 aliases?: ReadonlyArray<string>;
22 isCustom?: boolean;
23}): ServerProviderModel {
24 return {
25 slug: input.slug,
26 name: input.slug,
27 ...(input.aliases ? { aliases: [...input.aliases] } : {}),
28 isCustom: input.isCustom ?? false,
29 capabilities: input.capabilities,
30 };
31}
32
33describe("getProviderModelCapabilities", () => {
34 it("resolves model-declared aliases", () => {
35 const aliasCapabilities = capabilities("aliased-option");
36 const models = [
37 model({
38 slug: "synthetic-model",
39 aliases: ["Legacy-Synthetic-Model"],
40 capabilities: aliasCapabilities,
41 }),
42 ];
43
44 expect(getProviderModelCapabilities(models, "legacy-synthetic-model", PROVIDER)).toEqual(
45 aliasCapabilities,
46 );
47 });
48
49 it("prefers an exact custom slug over a built-in model alias", () => {
50 const customCapabilities = capabilities("custom-option");
51 const models = [
52 model({
53 slug: "synthetic-model",
54 aliases: ["custom-model"],
55 capabilities: capabilities("built-in-option"),
56 }),
57 model({ slug: "custom-model", capabilities: customCapabilities, isCustom: true }),
58 ];
59
60 expect(getProviderModelCapabilities(models, " custom-model ", PROVIDER)).toEqual(
61 customCapabilities,
62 );
63 });
64
65 it("returns empty capabilities for an unknown slug", () => {
66 const models = [
67 model({
68 slug: "default-model",
69 capabilities: capabilities("default-option"),
70 }),
71 ];
72
73 expect(getProviderModelCapabilities(models, "unknown-model", PROVIDER)).toEqual({
74 optionDescriptors: [],
75 });
76 });
77});
apps/web/src/providerModels.ts+4-4
......@@ -8,7 +8,7 @@ import {
88 type ServerProvider,
99 type ServerProviderModel,
1010} from "@t3tools/contracts";
11import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model";
11import { createModelCapabilities, resolveSelectableModel } from "@t3tools/shared/model";
1212
1313const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
1414 optionDescriptors: [],
......@@ -83,9 +83,9 @@ export function getProviderModelCapabilities(
8383 provider: ProviderDriverKind,
8484 planModeEnabled = true,
8585): ModelCapabilities {
86 const slug = normalizeModelSlug(model, provider);
87 const caps =
88 models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES;
86 const slug = resolveSelectableModel(provider, model, models);
87 const selectedModel = models.find((candidate) => candidate.slug === slug);
88 const caps = selectedModel?.capabilities ?? EMPTY_CAPABILITIES;
8989 if (planModeEnabled) {
9090 return caps;
9191 }
docs/internals/model-manifest.md created+40
......@@ -0,0 +1,40 @@
1# Model manifest
2
3`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from
4`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic
5catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the
6last successful remote manifest. The bundle is used only when no valid remote cache exists.
7
8The top-level provider catalog is generic: models contain presentation metadata, aliases, status,
9an optional badge, and a reusable capability profile. The profile and model `adapter` fields are
10opaque until the owning provider validates them with its own allowlisted schema.
11
12Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that
13uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or
14change application code. Add or change a profile in the same JSON file only when the model exposes
15a capability combination that does not already exist.
16
17`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate
18catalog discovery. New Claude models do not need to be added there. Codex still discovers models
19from its app server and uses `currentModels.codex` only as a legacy-classification overlay.
20
21Claude model entries support:
22
23- `aliases`, `status`, `badge`, and `profile` for client presentation and selection.
24- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility.
25- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch.
26
27## Test policy
28
29Changing model data does not require tests. Do not add or update tests for a model slug, display
30name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is
31configuration and is validated by its schema when imported.
32
33Add tests only when implementation behavior changes:
34
35- Fetching, caching, fallback, or schema-version handling changes in the manifest service.
36- Provider-neutral profile resolution gains new semantics.
37- A provider adapter gains a new compatibility or dispatch mapping type.
38
39Resolver tests must use synthetic providers and model names so normal JSON edits never create test
40churn.
packages/contracts/src/model.ts+1-27
......@@ -176,33 +176,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
176176 "5.3-spark": "gpt-5.3-codex-spark",
177177 "gpt-5.3-spark": "gpt-5.3-codex-spark",
178178 },
179 [CLAUDE_DRIVER_KIND]: {
180 fable: "claude-fable-5-1",
181 "fable-5.1": "claude-fable-5-1",
182 "claude-fable-5.1": "claude-fable-5-1",
183 opus: "claude-opus-5",
184 "opus-5": "claude-opus-5",
185 "claude-opus-5.0": "claude-opus-5",
186 "claude-opus-5-0": "claude-opus-5",
187 "opus-4.8": "claude-opus-4-8",
188 "claude-opus-4.8": "claude-opus-4-8",
189 "opus-4.7": "claude-opus-4-7",
190 "claude-opus-4.7": "claude-opus-4-7",
191 "opus-4.6": "claude-opus-4-6",
192 "claude-opus-4.6": "claude-opus-4-6",
193 "claude-opus-4-6-20251117": "claude-opus-4-6",
194 sonnet: "claude-sonnet-5",
195 "sonnet-5": "claude-sonnet-5",
196 "claude-sonnet-5.0": "claude-sonnet-5",
197 "claude-sonnet-5-0": "claude-sonnet-5",
198 "sonnet-4.6": "claude-sonnet-4-6",
199 "claude-sonnet-4.6": "claude-sonnet-4-6",
200 "claude-sonnet-4-6-20251117": "claude-sonnet-4-6",
201 haiku: "claude-haiku-4-5",
202 "haiku-4.5": "claude-haiku-4-5",
203 "claude-haiku-4.5": "claude-haiku-4-5",
204 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
205 },
179 [CLAUDE_DRIVER_KIND]: {},
206180 [CURSOR_DRIVER_KIND]: {
207181 composer: "composer-2",
208182 "composer-1.5": "composer-1.5",
packages/contracts/src/server.ts+2
......@@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({
6666 name: TrimmedNonEmptyString,
6767 shortName: Schema.optional(TrimmedNonEmptyString),
6868 subProvider: Schema.optional(TrimmedNonEmptyString),
69 aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
70 badge: Schema.optional(Schema.Literal("new")),
6971 isCustom: Schema.Boolean,
7072 isDefault: Schema.optional(Schema.Boolean),
7173 isLegacy: Schema.optional(Schema.Boolean),
packages/shared/src/model.test.ts+1-13
......@@ -1,5 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
2import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44import {
55 applyClaudePromptEffortPrefix,
......@@ -11,8 +11,6 @@ import {
1111 getProviderOptionDescriptors,
1212 getProviderOptionBooleanSelectionValue,
1313 getProviderOptionStringSelectionValue,
14 normalizeCustomModelSlug,
15 normalizeModelSlug,
1614} from "./model.ts";
1715
1816const codexCaps: ModelCapabilities = createModelCapabilities({
......@@ -148,16 +146,6 @@ describe("descriptor helpers", () => {
148146 });
149147});
150148
151describe("model slug normalization", () => {
152 it("preserves exact custom slugs instead of expanding provider aliases", () => {
153 const claude = ProviderDriverKind.make("claudeAgent");
154
155 expect(normalizeModelSlug("fable", claude)).toBe("claude-fable-5-1");
156 expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5");
157 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
158 });
159});
160
161149describe("applyClaudePromptEffortPrefix", () => {
162150 it("keeps slash commands intact when ultrathink is selected", () => {
163151 expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");
packages/shared/src/model.ts+8
......@@ -15,6 +15,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
1515export interface SelectableModelOption {
1616 slug: string;
1717 name: string;
18 aliases?: ReadonlyArray<string> | undefined;
1819}
1920
2021export function createModelCapabilities(input: {
......@@ -281,6 +282,13 @@ export function resolveSelectableModel(
281282 return byName.slug;
282283 }
283284
285 const byAlias = options.find((option) =>
286 option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()),
287 );
288 if (byAlias) {
289 return byAlias.slug;
290 }
291
284292 const normalized = normalizeModelSlug(trimmed, provider);
285293 if (!normalized) {
286294 return null;