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 {...@@ -35,6 +35,7 @@ import {
35 probeClaudeCapabilities,35 probeClaudeCapabilities,
36} from "../Layers/ClaudeProvider.ts";36} from "../Layers/ClaudeProvider.ts";
37import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";37import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
38import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
38import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";39import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
39import * as ModelManifest from "../ModelManifest.ts";40import * as ModelManifest from "../ModelManifest.ts";
40import {41import {
...@@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
128 const serverSettings = yield* ServerSettingsService;129 const serverSettings = yield* ServerSettingsService;
129 const eventLoggers = yield* ProviderEventLoggers;130 const eventLoggers = yield* ProviderEventLoggers;
130 const modelManifest = yield* ModelManifest.ModelManifest;131 const modelManifest = yield* ModelManifest.ModelManifest;
132 const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog));
131 const processEnv = mergeProviderInstanceEnvironment(environment);133 const processEnv = mergeProviderInstanceEnvironment(environment);
132 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({134 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
133 driverKind: DRIVER_KIND,135 driverKind: DRIVER_KIND,
...@@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
149 const adapterOptions = {151 const adapterOptions = {
150 instanceId,152 instanceId,
151 environment: processEnv,153 environment: processEnv,
154 modelCatalog,
152 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),155 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
153 };156 };
154 const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);157 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
157 // Per-instance capabilities cache: keyed on binary + resolved HOME so164 // Per-instance capabilities cache: keyed on binary + resolved HOME so
158 // account-specific probes never share auth metadata across instances.165 // account-specific probes never share auth metadata across instances.
...@@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
166 });173 });
167 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);174 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
168175
169 // Kick the TTL-gated manifest refresh in the background and classify176 // Start the TTL-gated refresh without delaying provider readiness. The
170 // with the in-memory manifest, so a slow or hung fetch never delays the177 // next check observes a remote manifest after the background fetch lands.
171 // provider check. A refresh that lands mid-probe applies on the next one.
172 const checkProvider = modelManifest.refreshInBackground.pipe(178 const checkProvider = modelManifest.refreshInBackground.pipe(
173 Effect.andThen(179 Effect.andThen(
174 Effect.zipWith(180 modelManifest.current.pipe(
175 checkClaudeProviderStatus(181 Effect.flatMap((manifest) =>
176 effectiveConfig,182 checkClaudeProviderStatus(
177 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),183 effectiveConfig,
178 processEnv,184 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
179 cwd,185 processEnv,
186 cwd,
187 resolveClaudeModelCatalog(manifest),
188 ),
180 ),189 ),
181 modelManifest.current,190 Effect.map(stampIdentity),
182 (draft, manifest) =>
183 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
184 { concurrent: true },
185 ),191 ),
186 ),192 ),
187 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),193 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
...@@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
196 streamSettings: snapshotSettings.streamSettings,202 streamSettings: snapshotSettings.streamSettings,
197 haveSettingsChanged: haveProviderSnapshotSettingsChanged,203 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
198 initialSnapshot: (settings) =>204 initialSnapshot: (settings) =>
199 Effect.zipWith(205 modelManifest.current.pipe(
200 makePendingClaudeProvider(settings.provider),206 Effect.flatMap((manifest) =>
201 modelManifest.current,207 makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)),
202 (draft, manifest) =>208 ),
203 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),209 Effect.map(stampIdentity),
204 ),210 ),
205 checkProvider,211 checkProvider,
206 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>212 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";...@@ -35,6 +35,13 @@ import * as TestClock from "effect/testing/TestClock";
35import { attachmentRelativePath } from "../../attachmentStore.ts";35import { attachmentRelativePath } from "../../attachmentStore.ts";
36import { ServerConfig } from "../../config.ts";36import { ServerConfig } from "../../config.ts";
37import { ServerSettingsService } from "../../serverSettings.ts";37import { 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";
38import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";45import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
39import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";46import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
40import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";47import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
...@@ -166,6 +173,7 @@ function makeHarness(config?: {...@@ -166,6 +173,7 @@ function makeHarness(config?: {
166173
167 const adapterOptions: ClaudeAdapterLiveOptions = {174 const adapterOptions: ClaudeAdapterLiveOptions = {
168 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),175 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
176 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
169 createQuery: (input) => {177 createQuery: (input) => {
170 createInput = input;178 createInput = input;
171 return query;179 return query;
...@@ -265,6 +273,7 @@ async function readFirstPromptMessage(...@@ -265,6 +273,7 @@ async function readFirstPromptMessage(
265273
266const THREAD_ID = ThreadId.make("thread-claude-1");274const THREAD_ID = ThreadId.make("thread-claude-1");
267const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume");275const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume");
276const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]";
268277
269describe("ClaudeAdapterLive", () => {278describe("ClaudeAdapterLive", () => {
270 it.effect("returns validation error for non-claude provider on startSession", () => {279 it.effect("returns validation error for non-claude provider on startSession", () => {
...@@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => {...@@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => {
441 provider: ProviderDriverKind.make("claudeAgent"),450 provider: ProviderDriverKind.make("claudeAgent"),
442 modelSelection: createModelSelection(451 modelSelection: createModelSelection(
443 ProviderInstanceId.make("claudeAgent"),452 ProviderInstanceId.make("claudeAgent"),
444 "claude-opus-4-6",453 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
445 [{ id: "effort", value: "max" }],454 [{ id: "effort", value: "max" }],
446 ),455 ),
447 runtimeMode: "full-access",456 runtimeMode: "full-access",
...@@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => {...@@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => {
464 provider: ProviderDriverKind.make("claudeAgent"),473 provider: ProviderDriverKind.make("claudeAgent"),
465 modelSelection: createModelSelection(474 modelSelection: createModelSelection(
466 ProviderInstanceId.make("claudeAgent"),475 ProviderInstanceId.make("claudeAgent"),
467 "claude-opus-4-6",476 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
468 ),477 ),
469 runtimeMode: "full-access",478 runtimeMode: "full-access",
470 });479 });
...@@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => {...@@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => {
480 );489 );
481 });490 });
482491
483 it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {492 it.effect("forwards Claude thinking toggle for models that support it", () => {
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", () => {
644 const harness = makeHarness();493 const harness = makeHarness();
645 return Effect.gen(function* () {494 return Effect.gen(function* () {
646 const adapter = yield* ClaudeAdapter;495 const adapter = yield* ClaudeAdapter;
...@@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => {...@@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => {
649 provider: ProviderDriverKind.make("claudeAgent"),498 provider: ProviderDriverKind.make("claudeAgent"),
650 modelSelection: createModelSelection(499 modelSelection: createModelSelection(
651 ProviderInstanceId.make("claudeAgent"),500 ProviderInstanceId.make("claudeAgent"),
652 "claude-haiku-4-5",501 SYNTHETIC_CLAUDE_THINKING_MODEL,
653 [{ id: "thinking", value: false }],502 [{ id: "thinking", value: false }],
654 ),503 ),
655 runtimeMode: "full-access",504 runtimeMode: "full-access",
...@@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => {...@@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => {
665 );514 );
666 });515 });
667516
668 it.effect("ignores Claude thinking toggle for non-Haiku models", () => {517 it.effect("ignores Claude thinking toggle for models without it", () => {
669 const harness = makeHarness();518 const harness = makeHarness();
670 return Effect.gen(function* () {519 return Effect.gen(function* () {
671 const adapter = yield* ClaudeAdapter;520 const adapter = yield* ClaudeAdapter;
...@@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => {...@@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => {
674 provider: ProviderDriverKind.make("claudeAgent"),523 provider: ProviderDriverKind.make("claudeAgent"),
675 modelSelection: createModelSelection(524 modelSelection: createModelSelection(
676 ProviderInstanceId.make("claudeAgent"),525 ProviderInstanceId.make("claudeAgent"),
677 "claude-sonnet-4-6",526 SYNTHETIC_CLAUDE_STANDARD_MODEL,
678 [{ id: "thinking", value: false }],527 [{ id: "thinking", value: false }],
679 ),528 ),
680 runtimeMode: "full-access",529 runtimeMode: "full-access",
...@@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => {...@@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => {
697 provider: ProviderDriverKind.make("claudeAgent"),546 provider: ProviderDriverKind.make("claudeAgent"),
698 modelSelection: createModelSelection(547 modelSelection: createModelSelection(
699 ProviderInstanceId.make("claudeAgent"),548 ProviderInstanceId.make("claudeAgent"),
700 "claude-opus-4-6",549 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
701 [{ id: "fastMode", value: true }],550 [{ id: "fastMode", value: true }],
702 ),551 ),
703 runtimeMode: "full-access",552 runtimeMode: "full-access",
...@@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => {...@@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => {
713 );562 );
714 });563 });
715564
716 it.effect("ignores claude fast mode for non-opus models", () => {565 it.effect("ignores claude fast mode for models without it", () => {
717 const harness = makeHarness();566 const harness = makeHarness();
718 return Effect.gen(function* () {567 return Effect.gen(function* () {
719 const adapter = yield* ClaudeAdapter;568 const adapter = yield* ClaudeAdapter;
...@@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => {...@@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => {
722 provider: ProviderDriverKind.make("claudeAgent"),571 provider: ProviderDriverKind.make("claudeAgent"),
723 modelSelection: createModelSelection(572 modelSelection: createModelSelection(
724 ProviderInstanceId.make("claudeAgent"),573 ProviderInstanceId.make("claudeAgent"),
725 "claude-sonnet-4-6",574 SYNTHETIC_CLAUDE_STANDARD_MODEL,
726 [{ id: "fastMode", value: true }],575 [{ id: "fastMode", value: true }],
727 ),576 ),
728 runtimeMode: "full-access",577 runtimeMode: "full-access",
...@@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => {...@@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => {
736 );585 );
737 });586 });
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
739 it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {679 it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {
740 const harness = makeHarness();680 const harness = makeHarness();
741 return Effect.gen(function* () {681 return Effect.gen(function* () {
...@@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => {...@@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => {
745 provider: ProviderDriverKind.make("claudeAgent"),685 provider: ProviderDriverKind.make("claudeAgent"),
746 modelSelection: createModelSelection(686 modelSelection: createModelSelection(
747 ProviderInstanceId.make("claudeAgent"),687 ProviderInstanceId.make("claudeAgent"),
748 "claude-sonnet-4-6",688 SYNTHETIC_CLAUDE_STANDARD_MODEL,
749 [{ id: "effort", value: "ultrathink" }],689 [{ id: "effort", value: "ultrathink" }],
750 ),690 ),
751 runtimeMode: "full-access",691 runtimeMode: "full-access",
...@@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => {...@@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => {
757 attachments: [],697 attachments: [],
758 modelSelection: createModelSelection(698 modelSelection: createModelSelection(
759 ProviderInstanceId.make("claudeAgent"),699 ProviderInstanceId.make("claudeAgent"),
760 "claude-sonnet-4-6",700 SYNTHETIC_CLAUDE_STANDARD_MODEL,
761 [{ id: "effort", value: "ultrathink" }],701 [{ id: "effort", value: "ultrathink" }],
762 ),702 ),
763 });703 });
...@@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => {...@@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => {
778 const adapter = yield* ClaudeAdapter;718 const adapter = yield* ClaudeAdapter;
779 const modelSelection = createModelSelection(719 const modelSelection = createModelSelection(
780 ProviderInstanceId.make("claudeAgent"),720 ProviderInstanceId.make("claudeAgent"),
781 "claude-sonnet-4-6",721 SYNTHETIC_CLAUDE_STANDARD_MODEL,
782 [{ id: "effort", value: "ultrathink" }],722 [{ id: "effort", value: "ultrathink" }],
783 );723 );
784 const session = yield* adapter.startSession({724 const session = yield* adapter.startSession({
...@@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => {...@@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => {
885 provider: ProviderDriverKind.make("claudeAgent"),825 provider: ProviderDriverKind.make("claudeAgent"),
886 modelSelection: {826 modelSelection: {
887 instanceId: ProviderInstanceId.make("claudeAgent"),827 instanceId: ProviderInstanceId.make("claudeAgent"),
888 model: "claude-sonnet-4-5",828 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
889 },829 },
890 runtimeMode: "full-access",830 runtimeMode: "full-access",
891 });831 });
...@@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => {
1900 output_tokens: 50,1840 output_tokens: 50,
1901 },1841 },
1902 modelUsage: {1842 modelUsage: {
1903 "claude-opus-4-6": {1843 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
1904 contextWindow: 200000,1844 contextWindow: 200000,
1905 maxOutputTokens: 64000,1845 maxOutputTokens: 64000,
1906 },1846 },
...@@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => {
1990 output_tokens: 50,1930 output_tokens: 50,
1991 },1931 },
1992 modelUsage: {1932 modelUsage: {
1993 "claude-opus-4-6": {1933 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
1994 contextWindow: 200000,1934 contextWindow: 200000,
1995 maxOutputTokens: 64000,1935 maxOutputTokens: 64000,
1996 },1936 },
...@@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => {
2125 provider: ProviderDriverKind.make("claudeAgent"),2065 provider: ProviderDriverKind.make("claudeAgent"),
2126 modelSelection: createModelSelection(2066 modelSelection: createModelSelection(
2127 ProviderInstanceId.make("claudeAgent"),2067 ProviderInstanceId.make("claudeAgent"),
2128 "claude-opus-4-6",2068 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
2129 [{ id: "effort", value: "max" }],2069 [{ id: "effort", value: "max" }],
2130 ),2070 ),
2131 runtimeMode: "full-access",2071 runtimeMode: "full-access",
...@@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => {
2154 type: "assistant",2094 type: "assistant",
2155 parent_tool_use_id: "toolu_agent_m",2095 parent_tool_use_id: "toolu_agent_m",
2156 message: {2096 message: {
2157 model: "claude-sonnet-5[1m]",2097 model: SYNTHETIC_SUBAGENT_MODEL,
2158 content: [],2098 content: [],
2159 },2099 },
2160 uuid: "subagent-snapshot-uuid",2100 uuid: "subagent-snapshot-uuid",
...@@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => {...@@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => {
2174 const started = taskEvents[0];2114 const started = taskEvents[0];
2175 assert.equal(started?.type, "task.started");2115 assert.equal(started?.type, "task.started");
2176 if (started?.type === "task.started") {2116 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);
2178 assert.equal(started.payload.effort, "max");2118 assert.equal(started.payload.effort, "max");
2179 }2119 }
2180 const progress = taskEvents[1];2120 const progress = taskEvents[1];
2181 assert.equal(progress?.type, "task.progress");2121 assert.equal(progress?.type, "task.progress");
2182 if (progress?.type === "task.progress") {2122 if (progress?.type === "task.progress") {
2183 assert.equal(progress.payload.model, "claude-sonnet-5[1m]");2123 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
2184 assert.equal(progress.payload.effort, "max");2124 assert.equal(progress.payload.effort, "max");
2185 }2125 }
2186 }).pipe(2126 }).pipe(
...@@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => {
2206 provider: ProviderDriverKind.make("claudeAgent"),2146 provider: ProviderDriverKind.make("claudeAgent"),
2207 modelSelection: createModelSelection(2147 modelSelection: createModelSelection(
2208 ProviderInstanceId.make("claudeAgent"),2148 ProviderInstanceId.make("claudeAgent"),
2209 "claude-opus-4-6",2149 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
2210 [{ id: "effort", value: "max" }],2150 [{ id: "effort", value: "max" }],
2211 ),2151 ),
2212 runtimeMode: "full-access",2152 runtimeMode: "full-access",
...@@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => {
2223 type: "assistant",2163 type: "assistant",
2224 parent_tool_use_id: "toolu_agent_early",2164 parent_tool_use_id: "toolu_agent_early",
2225 message: {2165 message: {
2226 model: "claude-sonnet-5[1m]",2166 model: SYNTHETIC_SUBAGENT_MODEL,
2227 content: [],2167 content: [],
2228 },2168 },
2229 uuid: "early-snapshot-uuid",2169 uuid: "early-snapshot-uuid",
...@@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => {...@@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => {
2253 const started = taskEvents[0];2193 const started = taskEvents[0];
2254 assert.equal(started?.type, "task.started");2194 assert.equal(started?.type, "task.started");
2255 if (started?.type === "task.started") {2195 if (started?.type === "task.started") {
2256 assert.equal(started.payload.model, "claude-sonnet-5[1m]");2196 assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL);
2257 assert.equal(started.payload.effort, "max");2197 assert.equal(started.payload.effort, "max");
2258 }2198 }
2259 const progress = taskEvents[1];2199 const progress = taskEvents[1];
2260 assert.equal(progress?.type, "task.progress");2200 assert.equal(progress?.type, "task.progress");
2261 if (progress?.type === "task.progress") {2201 if (progress?.type === "task.progress") {
2262 assert.equal(progress.payload.model, "claude-sonnet-5[1m]");2202 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
2263 }2203 }
2264 }).pipe(2204 }).pipe(
2265 Effect.provideService(Random.Random, makeDeterministicRandomService()),2205 Effect.provideService(Random.Random, makeDeterministicRandomService()),
...@@ -2884,7 +2824,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2884,7 +2824,7 @@ describe("ClaudeAdapterLive", () => {
2884 output_tokens: 679,2824 output_tokens: 679,
2885 },2825 },
2886 modelUsage: {2826 modelUsage: {
2887 "claude-opus-4-6": {2827 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
2888 contextWindow: 200000,2828 contextWindow: 200000,
2889 maxOutputTokens: 64000,2829 maxOutputTokens: 64000,
2890 },2830 },
...@@ -2948,7 +2888,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2948,7 +2888,7 @@ describe("ClaudeAdapterLive", () => {
2948 total_tokens: 535000,2888 total_tokens: 535000,
2949 },2889 },
2950 modelUsage: {2890 modelUsage: {
2951 "claude-opus-4-6": {2891 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
2952 contextWindow: 200000,2892 contextWindow: 200000,
2953 maxOutputTokens: 64000,2893 maxOutputTokens: 64000,
2954 },2894 },
...@@ -3025,7 +2965,7 @@ describe("ClaudeAdapterLive", () => {...@@ -3025,7 +2965,7 @@ describe("ClaudeAdapterLive", () => {
3025 total_tokens: 535000,2965 total_tokens: 535000,
3026 },2966 },
3027 modelUsage: {2967 modelUsage: {
3028 "claude-opus-4-6": {2968 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
3029 contextWindow: 200000,2969 contextWindow: 200000,
3030 maxOutputTokens: 64000,2970 maxOutputTokens: 64000,
3031 },2971 },
...@@ -4069,7 +4009,7 @@ describe("ClaudeAdapterLive", () => {...@@ -4069,7 +4009,7 @@ describe("ClaudeAdapterLive", () => {
4069 cwd: "/tmp/claude-adapter-test",4009 cwd: "/tmp/claude-adapter-test",
4070 tools: [],4010 tools: [],
4071 mcp_servers: [],4011 mcp_servers: [],
4072 model: "claude-sonnet-4-5",4012 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
4073 permissionMode: "bypassPermissions",4013 permissionMode: "bypassPermissions",
4074 slash_commands: [],4014 slash_commands: [],
4075 output_style: "default",4015 output_style: "default",
...@@ -4232,12 +4172,14 @@ describe("ClaudeAdapterLive", () => {...@@ -4232,12 +4172,14 @@ describe("ClaudeAdapterLive", () => {
4232 input: "hello",4172 input: "hello",
4233 modelSelection: {4173 modelSelection: {
4234 instanceId: ProviderInstanceId.make("claudeAgent"),4174 instanceId: ProviderInstanceId.make("claudeAgent"),
4235 model: "claude-opus-4-6",4175 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4236 },4176 },
4237 attachments: [],4177 attachments: [],
4238 });4178 });
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 ]);
4241 }).pipe(4183 }).pipe(
4242 Effect.provideService(Random.Random, makeDeterministicRandomService()),4184 Effect.provideService(Random.Random, makeDeterministicRandomService()),
4243 Effect.provide(harness.layer),4185 Effect.provide(harness.layer),
...@@ -4280,7 +4222,7 @@ describe("ClaudeAdapterLive", () => {...@@ -4280,7 +4222,7 @@ describe("ClaudeAdapterLive", () => {
4280 const adapter = yield* ClaudeAdapter;4222 const adapter = yield* ClaudeAdapter;
4281 const modelSelection = {4223 const modelSelection = {
4282 instanceId: ProviderInstanceId.make("claudeAgent"),4224 instanceId: ProviderInstanceId.make("claudeAgent"),
4283 model: "claude-opus-4-6",4225 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4284 };4226 };
42854227
4286 const session = yield* adapter.startSession({4228 const session = yield* adapter.startSession({
...@@ -4327,8 +4269,8 @@ describe("ClaudeAdapterLive", () => {...@@ -4327,8 +4269,8 @@ describe("ClaudeAdapterLive", () => {
4327 input: "hello",4269 input: "hello",
4328 modelSelection: createModelSelection(4270 modelSelection: createModelSelection(
4329 ProviderInstanceId.make("claudeAgent"),4271 ProviderInstanceId.make("claudeAgent"),
4330 "claude-opus-4-6",4272 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4331 [{ id: "contextWindow", value: "1m" }],4273 [{ id: "contextWindow", value: "expanded" }],
4332 ),4274 ),
4333 attachments: [],4275 attachments: [],
4334 });4276 });
...@@ -4337,13 +4279,16 @@ describe("ClaudeAdapterLive", () => {...@@ -4337,13 +4279,16 @@ describe("ClaudeAdapterLive", () => {
4337 input: "hello again",4279 input: "hello again",
4338 modelSelection: createModelSelection(4280 modelSelection: createModelSelection(
4339 ProviderInstanceId.make("claudeAgent"),4281 ProviderInstanceId.make("claudeAgent"),
4340 "claude-opus-4-6",4282 SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4341 [{ id: "contextWindow", value: "200k" }],4283 [{ id: "contextWindow", value: "standard" }],
4342 ),4284 ),
4343 attachments: [],4285 attachments: [],
4344 });4286 });
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 ]);
4347 }).pipe(4292 }).pipe(
4348 Effect.provideService(Random.Random, makeDeterministicRandomService()),4293 Effect.provideService(Random.Random, makeDeterministicRandomService()),
4349 Effect.provide(harness.layer),4294 Effect.provide(harness.layer),
...@@ -4553,7 +4498,7 @@ describe("ClaudeAdapterLive", () => {...@@ -4553,7 +4498,7 @@ describe("ClaudeAdapterLive", () => {
4553 uuid: "assistant-exit-plan",4498 uuid: "assistant-exit-plan",
4554 parent_tool_use_id: null,4499 parent_tool_use_id: null,
4555 message: {4500 message: {
4556 model: "claude-opus-4-6",4501 model: SYNTHETIC_CLAUDE_CAPABLE_MODEL,
4557 id: "msg-exit-plan",4502 id: "msg-exit-plan",
4558 type: "message",4503 type: "message",
4559 role: "assistant",4504 role: "assistant",
apps/server/src/provider/Layers/ClaudeAdapter.ts+56-38
...@@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";...@@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
82import {82import {
83 getClaudeModelCapabilities,83 BUNDLED_CLAUDE_MODEL_CATALOG,
84 isClaudeUltracodeEffort,84 type ClaudeModelCatalog,
85 normalizeClaudeCliEffort,85 getClaudeCatalogModelCapabilities,
86 resolveClaudeApiModelId,86 isClaudeCatalogUltracodeEffort,
87 resolveClaudeContextWindow,87 normalizeClaudeCatalogEffort,
88 resolveClaudeEffort,88 resolveClaudeCatalogApiModelId,
89} from "./ClaudeProvider.ts";89 resolveClaudeCatalogContextWindowTokens,
90 resolveClaudeCatalogEffort,
91 resolveClaudeModelSlug,
92 scopeClaudeModelCatalog,
93} from "../ClaudeModelCatalog.ts";
90import {94import {
91 ProviderAdapterProcessError,95 ProviderAdapterProcessError,
92 ProviderAdapterRequestError,96 ProviderAdapterRequestError,
...@@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions {...@@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions {
333 }) => ClaudeQueryRuntime;337 }) => ClaudeQueryRuntime;
334 readonly nativeEventLogPath?: string;338 readonly nativeEventLogPath?: string;
335 readonly nativeEventLogger?: EventNdjsonLogger;339 readonly nativeEventLogger?: EventNdjsonLogger;
340 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
336}341}
337342
338function isUuid(value: string): boolean {343function isUuid(value: string): boolean {
...@@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages(...@@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages(
381}386}
382387
383function getEffectiveClaudeAgentEffort(388function getEffectiveClaudeAgentEffort(
389 catalog: ClaudeModelCatalog,
384 effort: string | null | undefined,390 effort: string | null | undefined,
385 model: string | null | undefined,391 model: string | null | undefined,
386): ClaudeSdkEffort | null {392): ClaudeSdkEffort | null {
387 const normalized = normalizeClaudeCliEffort(effort, model);393 const normalized = normalizeClaudeCatalogEffort(catalog, effort, model);
388 return normalized ? (normalized as ClaudeSdkEffort) : null;394 return normalized ? (normalized as ClaudeSdkEffort) : null;
389}395}
390396
...@@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage(...@@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage(
470}476}
471477
472function selectedClaudeContextWindow(478function selectedClaudeContextWindow(
479 catalog: ClaudeModelCatalog,
473 modelSelection: ModelSelection | undefined,480 modelSelection: ModelSelection | undefined,
474): number | undefined {481): number | undefined {
475 switch (modelSelection?.model) {482 return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection);
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 }
490}483}
491484
492function finiteNonNegativeInteger(value: unknown): number | undefined {485function finiteNonNegativeInteger(value: unknown): number | undefined {
...@@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [...@@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [
1234function buildPromptText(1227function buildPromptText(
1235 input: ProviderSendTurnInput,1228 input: ProviderSendTurnInput,
1236 boundInstanceId: ProviderInstanceId,1229 boundInstanceId: ProviderInstanceId,
1230 catalog: ClaudeModelCatalog,
1237): string {1231): string {
1238 const rawEffort =1232 const rawEffort =
1239 input.modelSelection?.instanceId === boundInstanceId1233 input.modelSelection?.instanceId === boundInstanceId
...@@ -1241,7 +1235,7 @@ function buildPromptText(...@@ -1241,7 +1235,7 @@ function buildPromptText(
1241 : null;1235 : null;
1242 const claudeModel =1236 const claudeModel =
1243 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;1237 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined;
1244 const caps = getClaudeModelCapabilities(claudeModel);1238 const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel);
12451239
1246 const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);1240 const promptEffort = resolvePromptInjectedEffort(caps, rawEffort);
1247 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);1241 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
...@@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1281 readonly fileSystem: FileSystem.FileSystem;1275 readonly fileSystem: FileSystem.FileSystem;
1282 readonly attachmentsDir: string;1276 readonly attachmentsDir: string;
1283 readonly boundInstanceId: ProviderInstanceId;1277 readonly boundInstanceId: ProviderInstanceId;
1278 readonly modelCatalog: ClaudeModelCatalog;
1284 },1279 },
1285) {1280) {
1286 const text = buildPromptText(input, dependencies.boundInstanceId);1281 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);
1287 const sdkContent: Array<Record<string, unknown>> = [];1282 const sdkContent: Array<Record<string, unknown>> = [];
12881283
1289 if (text.length > 0) {1284 if (text.length > 0) {
...@@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1682 options?: ClaudeAdapterLiveOptions,1677 options?: ClaudeAdapterLiveOptions,
1683) {1678) {
1684 const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent");1679 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)));
1685 const fileSystem = yield* FileSystem.FileSystem;1683 const fileSystem = yield* FileSystem.FileSystem;
1686 const path = yield* Path.Path;1684 const path = yield* Path.Path;
1687 const serverConfig = yield* ServerConfig;1685 const serverConfig = yield* ServerConfig;
...@@ -3805,6 +3803,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3805,6 +3803,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
38053803
3806 const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")(3804 const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")(
3807 function* (input) {3805 function* (input) {
3806 const modelCatalog = yield* modelCatalogEffect;
3808 if (input.provider !== undefined && input.provider !== PROVIDER) {3807 if (input.provider !== undefined && input.provider !== PROVIDER) {
3809 return yield* new ProviderAdapterValidationError({3808 return yield* new ProviderAdapterValidationError({
3810 provider: PROVIDER,3809 provider: PROVIDER,
...@@ -4243,14 +4242,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4243,14 +4242,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
42434242
4244 const claudeBinaryPath = claudeSdkExecutablePath;4243 const claudeBinaryPath = claudeSdkExecutablePath;
4245 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;4244 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
4246 const modelSelection =4245 const selectedModel =
4247 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;4246 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);
4249 const descriptors = getProviderOptionDescriptors({ caps });4254 const descriptors = getProviderOptionDescriptors({ caps });
4250 const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;4255 const apiModelId = modelSelection
4251 const initialContextWindow = selectedClaudeContextWindow(modelSelection);4256 ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection)
4257 : undefined;
4258 const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection);
4252 const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort");4259 const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort");
4253 const effort = resolveClaudeEffort(caps, rawEffort) ?? null;4260 const effort =
4261 resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null;
4254 const fastModeSupported = descriptors.some(4262 const fastModeSupported = descriptors.some(
4255 (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",4263 (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
4256 );4264 );
...@@ -4263,8 +4271,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4263,8 +4271,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4263 const thinking = thinkingSupported4271 const thinking = thinkingSupported
4264 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")4272 ? getModelSelectionBooleanOptionValue(modelSelection, "thinking")
4265 : undefined;4273 : undefined;
4266 const ultracode = isClaudeUltracodeEffort(effort);4274 const ultracode = isClaudeCatalogUltracodeEffort(effort);
4267 const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model);4275 const effectiveEffort = getEffectiveClaudeAgentEffort(
4276 modelCatalog,
4277 effort,
4278 modelSelection?.model,
4279 );
4268 const runtimeModeToPermission: Record<string, PermissionMode> = {4280 const runtimeModeToPermission: Record<string, PermissionMode> = {
4269 "auto-accept-edits": "acceptEdits",4281 "auto-accept-edits": "acceptEdits",
4270 auto: "auto",4282 auto: "auto",
...@@ -4495,10 +4507,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4495,10 +4507,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
44954507
4496 const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {4508 const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
4497 const context = yield* requireSession(input.threadId);4509 const context = yield* requireSession(input.threadId);
4498 const modelSelection =4510 const modelCatalog = yield* modelCatalogEffect;
4511 const selectedModel =
4499 input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId4512 input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId
4500 ? input.modelSelection4513 ? input.modelSelection
4501 : undefined;4514 : undefined;
4515 const modelSelection = selectedModel
4516 ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) }
4517 : undefined;
45024518
4503 // A sendTurn while a real turn is running is a steer: the message is4519 // A sendTurn while a real turn is running is a steer: the message is
4504 // queued into the live SDK agent loop and the work continues as the same4520 // 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* (...@@ -4512,7 +4528,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4512 }4528 }
45134529
4514 if (modelSelection?.model) {4530 if (modelSelection?.model) {
4515 const apiModelId = resolveClaudeApiModelId(modelSelection);4531 const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection);
4516 if (context.currentApiModelId !== apiModelId) {4532 if (context.currentApiModelId !== apiModelId) {
4517 yield* Effect.tryPromise({4533 yield* Effect.tryPromise({
4518 try: () => context.query.setModel(apiModelId),4534 try: () => context.query.setModel(apiModelId),
...@@ -4524,13 +4540,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4524,13 +4540,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4524 ...context.session,4540 ...context.session,
4525 model: modelSelection.model,4541 model: modelSelection.model,
4526 };4542 };
4527 const turnCaps = getClaudeModelCapabilities(modelSelection.model);4543 const turnEffort = resolveClaudeCatalogEffort(
4528 const turnEffort = resolveClaudeEffort(4544 modelCatalog,
4529 turnCaps,4545 modelSelection.model,
4530 getModelSelectionStringOptionValue(modelSelection, "effort"),4546 getModelSelectionStringOptionValue(modelSelection, "effort"),
4531 );4547 );
4532 context.currentEffort =4548 context.currentEffort =
4533 getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined;4549 getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ??
4550 undefined;
4534 }4551 }
45354552
4536 // Apply interaction mode by switching the SDK's permission mode.4553 // Apply interaction mode by switching the SDK's permission mode.
...@@ -4589,6 +4606,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4589,6 +4606,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4589 fileSystem,4606 fileSystem,
4590 attachmentsDir: serverConfig.attachmentsDir,4607 attachmentsDir: serverConfig.attachmentsDir,
4591 boundInstanceId,4608 boundInstanceId,
4609 modelCatalog,
4592 });4610 });
45934611
4594 yield* Queue.offer(context.promptQueue, {4612 yield* Queue.offer(context.promptQueue, {
apps/server/src/provider/Layers/ClaudeProvider.ts+13-487
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1import {1import {
2 type ClaudeSettings,2 type ClaudeSettings,
3 type ModelCapabilities,3 type ModelCapabilities,
4 type ModelSelection,
5 type ServerProviderModel,
6 type ServerProviderSlashCommand,4 type ServerProviderSlashCommand,
7} from "@t3tools/contracts";5} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
...@@ -12,14 +10,8 @@ import * as Option from "effect/Option";...@@ -12,14 +10,8 @@ import * as Option from "effect/Option";
12import * as Path from "effect/Path";10import * as Path from "effect/Path";
13import * as Result from "effect/Result";11import * as Result from "effect/Result";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
15import {13import { createModelCapabilities } from "@t3tools/shared/model";
16 createModelCapabilities,
17 getModelSelectionStringOptionValue,
18 getProviderOptionCurrentValue,
19 getProviderOptionDescriptors,
20} from "@t3tools/shared/model";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";14import { resolveSpawnCommand } from "@t3tools/shared/shell";
22import { compareSemverVersions } from "@t3tools/shared/semver";
23import {15import {
24 query as claudeQuery,16 query as claudeQuery,
25 type Options as ClaudeQueryOptions,17 type Options as ClaudeQueryOptions,
...@@ -29,8 +21,6 @@ import {...@@ -29,8 +21,6 @@ import {
29} from "@anthropic-ai/claude-agent-sdk";21} from "@anthropic-ai/claude-agent-sdk";
3022
31import {23import {
32 buildBooleanOptionDescriptor,
33 buildSelectOptionDescriptor,
34 buildServerProvider,24 buildServerProvider,
35 DEFAULT_TIMEOUT_MS,25 DEFAULT_TIMEOUT_MS,
36 isCommandMissingCause,26 isCommandMissingCause,
...@@ -42,6 +32,12 @@ import {...@@ -42,6 +32,12 @@ import {
42import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";32import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
43import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";33import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
44import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";34import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
35import {
36 BUNDLED_CLAUDE_MODEL_CATALOG,
37 type ClaudeModelCatalog,
38 formatClaudeVersionUpgradeMessage,
39 resolveClaudeModelsForVersion,
40} from "../ClaudeModelCatalog.ts";
4541
46const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({42const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
47 optionDescriptors: [],43 optionDescriptors: [],
...@@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = {...@@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = {
51 displayName: "Claude",47 displayName: "Claude",
52 showInteractionModeToggle: true,48 showInteractionModeToggle: true,
53} as const;49} 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
516function toTitleCaseWords(value: string): string {50function toTitleCaseWords(value: string): string {
517 const parts: Array<string> = [];51 const parts: Array<string> = [];
518 for (const part of value.split(/[\s_-]+/g)) {52 for (const part of value.split(/[\s_-]+/g)) {
...@@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
860 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,394 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
861 environment?: NodeJS.ProcessEnv,395 environment?: NodeJS.ProcessEnv,
862 cwd?: string,396 cwd?: string,
397 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
863): Effect.fn.Return<398): Effect.fn.Return<
864 ServerProviderDraft,399 ServerProviderDraft,
865 never,400 never,
...@@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
868 const resolvedEnvironment = environment ?? process.env;403 const resolvedEnvironment = environment ?? process.env;
869 const checkedAt = DateTime.formatIso(yield* DateTime.now);404 const checkedAt = DateTime.formatIso(yield* DateTime.now);
870 const allModels = providerModelsFromSettings(405 const allModels = providerModelsFromSettings(
871 BUILT_IN_MODELS,406 modelCatalog.models.map((entry) => entry.model),
872 claudeSettings.customModels,407 claudeSettings.customModels,
873 DEFAULT_CLAUDE_MODEL_CAPABILITIES,408 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
874 );409 );
...@@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
958 }493 }
959494
960 const models = providerModelsFromSettings(495 const models = providerModelsFromSettings(
961 getBuiltInClaudeModelsForVersion(parsedVersion),496 resolveClaudeModelsForVersion(modelCatalog, parsedVersion),
962 claudeSettings.customModels,497 claudeSettings.customModels,
963 DEFAULT_CLAUDE_MODEL_CAPABILITIES,498 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
964 );499 );
965 const versionUpgradeMessage = supportsClaudeFable51(parsedVersion)500 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, 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);
976501
977 const capabilities = resolveCapabilities502 const capabilities = resolveCapabilities
978 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))503 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
...@@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);...@@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
1035560
1036export const makePendingClaudeProvider = (561export const makePendingClaudeProvider = (
1037 claudeSettings: ClaudeSettings,562 claudeSettings: ClaudeSettings,
563 modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
1038): Effect.Effect<ServerProviderDraft> =>564): Effect.Effect<ServerProviderDraft> =>
1039 Effect.gen(function* () {565 Effect.gen(function* () {
1040 const checkedAt = yield* nowIso;566 const checkedAt = yield* nowIso;
1041 const models = providerModelsFromSettings(567 const models = providerModelsFromSettings(
1042 BUILT_IN_MODELS,568 modelCatalog.models.map((entry) => entry.model),
1043 claudeSettings.customModels,569 claudeSettings.customModels,
1044 DEFAULT_CLAUDE_MODEL_CAPABILITIES,570 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
1045 );571 );
apps/server/src/provider/Layers/ProviderRegistry.test.ts-242
...@@ -2051,248 +2051,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2051,248 +2051,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2051 ),2051 ),
2052 );2052 );
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
2296 it.effect("returns a display label for claude subscription types", () =>2054 it.effect("returns a display label for claude subscription types", () =>
2297 Effect.gen(function* () {2055 Effect.gen(function* () {
2298 const status = yield* checkClaudeProviderStatus(2056 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";...@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
6import { HttpClient, HttpClientResponse } from "effect/unstable/http";7import { HttpClient, HttpClientResponse } from "effect/unstable/http";
78
8import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
...@@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts";
10import {11import {
11 BUNDLED_MODEL_MANIFEST,12 BUNDLED_MODEL_MANIFEST,
12 classifyModels,13 classifyModels,
13 isLegacyModel,
14 make,14 make,
15 resolveProviderCatalog,
15 type ModelManifestData,16 type ModelManifestData,
16} from "./ModelManifest.ts";17} from "./ModelManifest.ts";
1718
18const CODEX = ProviderDriverKind.make("codex");19/**
19const CLAUDE = ProviderDriverKind.make("claudeAgent");20 * Test policy: this file covers manifest machinery, not manifest contents.
20const CURSOR = ProviderDriverKind.make("cursor");21 * Do not add assertions for real model slugs, names, status, aliases, or
2122 * profiles when editing model-manifest.json. Add tests only when fetch/cache
22describe("isLegacyModel (bundled manifest)", () => {23 * behavior or the provider-neutral resolver semantics change, and use
23 it("keeps current Codex models out of legacy models", () => {24 * synthetic models for resolver coverage.
24 assert.deepStrictEqual(25 */
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});
6726
27const CODEX = ProviderDriverKind.make("codex");
68const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({28const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
69 slug: "gpt-test",29 slug: "gpt-test",
70 name: "GPT Test",30 name: "GPT Test",
...@@ -75,37 +35,227 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>...@@ -75,37 +35,227 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>
7535
76describe("classifyModels", () => {36describe("classifyModels", () => {
77 it("flags non-current models, clears stale flags, and skips custom models", () => {37 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 };
78 const models = [42 const models = [
79 model({ slug: "gpt-5.6-sol" }),43 model({ slug: "current-a" }),
80 // Stale flag from a previous classification pass must be cleared.44 // Stale flag from a previous classification pass must be cleared.
81 model({ slug: "gpt-5.6-luna", isLegacy: true }),45 model({ slug: "current-b", isLegacy: true }),
82 model({ slug: "gpt-5.4" }),46 model({ slug: "old-model" }),
83 // Custom models are user-defined and never reclassified.47 // Custom models are user-defined and never reclassified.
84 model({ slug: "my-own-model", isCustom: true }),48 model({ slug: "my-own-model", isCustom: true }),
85 ];49 ];
86 assert.deepStrictEqual(50 assert.deepStrictEqual(
87 classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [51 classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]),
88 entry.slug,
89 entry.isLegacy ?? false,
90 ]),
91 [52 [
92 ["gpt-5.6-sol", false],53 ["current-a", false],
93 ["gpt-5.6-luna", false],54 ["current-b", false],
94 ["gpt-5.4", true],55 ["old-model", true],
95 ["my-own-model", false],56 ["my-own-model", false],
96 ],57 ],
97 );58 );
98 });59 });
99});60});
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
101const REMOTE_MANIFEST: ModelManifestData = {158const REMOTE_MANIFEST: ModelManifestData = {
102 version: 1,159 version: 1,
103 currentModels: {160 currentModels: {
104 codex: ["gpt-5.4"],161 codex: ["remote-model"],
105 claudeAgent: ["claude-fable-5"],162 claudeAgent: ["remote-agent-model"],
106 },163 },
107};164};
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
109const httpClientLayer = (handler: () => Response) =>259const httpClientLayer = (handler: () => Response) =>
110 Layer.succeed(260 Layer.succeed(
111 HttpClient.HttpClient,261 HttpClient.HttpClient,
...@@ -129,8 +279,6 @@ describe("ModelManifest service", () => {...@@ -129,8 +279,6 @@ describe("ModelManifest service", () => {
129 const service = yield* make;279 const service = yield* make;
130 const refreshed = yield* service.refresh;280 const refreshed = yield* service.refresh;
131 assert.deepStrictEqual(refreshed, REMOTE_MANIFEST);281 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
135 // A fresh service instance sees the disk cache without another fetch:283 // A fresh service instance sees the disk cache without another fetch:
136 // its HTTP layer is still stubbed, but `current` never fetches at all.284 // its HTTP layer is still stubbed, but `current` never fetches at all.
...@@ -162,6 +310,33 @@ describe("ModelManifest service", () => {...@@ -162,6 +310,33 @@ describe("ModelManifest service", () => {
162 ),310 ),
163 );311 );
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
165 it.live("does not fetch when provider update checks are disabled", () =>340 it.live("does not fetch when provider update checks are disabled", () =>
166 Effect.gen(function* () {341 Effect.gen(function* () {
167 let fetchCount = 0;342 let fetchCount = 0;
apps/server/src/provider/ModelManifest.ts+139-18
...@@ -1,20 +1,24 @@...@@ -1,20 +1,24 @@
1/**1/**
2 * ModelManifest — decides which provider models are current and which belong2 * ModelManifest — remote provider-model metadata with a bundled offline
3 * in the model picker's legacy section.3 * fallback.
4 *4 *
5 * The classification data (current slugs per driver kind) lives in5 * Provider catalogs and legacy classification live in `model-manifest.json`.
6 * `model-manifest.json` next to this file. The bundled copy ships with every6 * The bundled copy ships with every release; at runtime the service refreshes
7 * release. At runtime the service refreshes it from the same file on `main`7 * it from the same file on `main`. Preference order is remote, then the last
8 * via raw.githubusercontent.com, so a new model can leave the legacy section8 * successful on-disk copy, then the bundle. A failed fetch never fails a
9 * with a commit to `main` instead of a release. Preference order is remote,9 * provider check.
10 * then the on-disk copy of the last successful fetch, then the bundle. A
11 * failed fetch never fails a provider check.
12 *10 *
13 * Drivers apply the manifest to snapshot drafts with `applyModelManifest`11 * Providers with authoritative discovery can use only the classification
14 * before publishing, so every path that produces models (pending, probe,12 * overlay. Providers with static catalogs can resolve presentation and
15 * error fallbacks) is classified the same way.13 * capabilities from `providers`, then decode their own allowlisted adapter
14 * payload separately.
16 */15 */
17import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";16import {
17 ModelCapabilities,
18 TrimmedNonEmptyString,
19 type ProviderDriverKind,
20 type ServerProviderModel,
21} from "@t3tools/contracts";
18import * as Clock from "effect/Clock";22import * as Clock from "effect/Clock";
19import * as Context from "effect/Context";23import * as Context from "effect/Context";
20import * as Effect from "effect/Effect";24import * as Effect from "effect/Effect";
...@@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";...@@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2731
28import { ServerConfig } from "../config.ts";32import { ServerConfig } from "../config.ts";
29import * as ServerSettings from "../serverSettings.ts";33import * as ServerSettings from "../serverSettings.ts";
34import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
30import bundledManifestJson from "./model-manifest.json" with { type: "json" };35import bundledManifestJson from "./model-manifest.json" with { type: "json" };
31import type { ServerProviderDraft } from "./providerSnapshot.ts";36import type { ServerProviderDraft } from "./providerSnapshot.ts";
3237
...@@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000;...@@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000;
4247
43const FETCH_TIMEOUT_MS = 10_000;48const 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
45/**79/**
46 * `version` gates breaking schema changes: a build only accepts remote80 * `version` gates breaking schema changes. Provider catalogs are additive so
47 * manifests whose version it understands, and keeps its bundled copy81 * clients that only understand `currentModels` keep accepting this v1 file.
48 * otherwise. `currentModels` is keyed by driver kind; kinds absent from the
49 * map have no legacy concept and their models are left unflagged.
50 */82 */
51const ModelManifestSchema = Schema.Struct({83const ModelManifestEnvelopeSchema = Schema.Struct({
52 version: Schema.Literal(1),84 version: Schema.Literal(1),
53 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),85 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
86 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
54});87});
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);
55export type ModelManifestData = typeof ModelManifestSchema.Type;114export 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
57const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);129const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
58130
59export const BUNDLED_MODEL_MANIFEST: ModelManifestData =131export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
60 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);132 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
62/** On-disk shape of the last successfully fetched manifest. */179/** On-disk shape of the last successfully fetched manifest. */
63const ManifestCacheFile = Schema.Struct({180const ManifestCacheFile = Schema.Struct({
64 fetchedAtMs: Schema.Number,181 fetchedAtMs: Schema.Number,
...@@ -81,6 +198,10 @@ export function isLegacyModel(...@@ -81,6 +198,10 @@ export function isLegacyModel(
81 driverKind: ProviderDriverKind,198 driverKind: ProviderDriverKind,
82 slug: string,199 slug: string,
83): boolean {200): boolean {
201 const catalogModel = manifest.providers?.[driverKind]?.models.find(
202 (model) => model.slug === slug,
203 );
204 if (catalogModel) return catalogModel.status === "legacy";
84 const currentModels = manifest.currentModels[driverKind];205 const currentModels = manifest.currentModels[driverKind];
85 if (!currentModels) return false;206 if (!currentModels) return false;
86 return !currentModels.includes(slug);207 return !currentModels.includes(slug);
apps/server/src/provider/model-manifest.json+359
...@@ -9,5 +9,364 @@...@@ -9,5 +9,364 @@
9 "gpt-daybreak-red-latest"9 "gpt-daybreak-red-latest"
10 ],10 ],
11 "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"]11 "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 }
12 }371 }
13}372}
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+79-20
...@@ -11,6 +11,13 @@ import * as Schema from "effect/Schema";...@@ -11,6 +11,13 @@ import * as Schema from "effect/Schema";
11import { expect } from "vite-plus/test";11import { expect } from "vite-plus/test";
1212
13import * as ServerConfig from "../config.ts";13import * 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";
14import * as TextGeneration from "./TextGeneration.ts";21import * as TextGeneration from "./TextGeneration.ts";
15import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";22import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
16import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";23import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
...@@ -219,13 +226,17 @@ function withFakeClaudeEnv<A, E, R>(...@@ -219,13 +226,17 @@ function withFakeClaudeEnv<A, E, R>(
219 );226 );
220227
221 const config = decodeClaudeSettings(input.claudeConfig ?? {});228 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 );
223 return yield* effectFn(textGeneration);234 return yield* effectFn(textGeneration);
224 }).pipe(Effect.scoped);235 }).pipe(Effect.scoped);
225}236}
226237
227it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {238it.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", () =>
229 withFakeClaudeEnv(240 withFakeClaudeEnv(
230 {241 {
231 output: JSON.stringify({242 output: JSON.stringify({
...@@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
245 stagedSummary: "M README.md",256 stagedSummary: "M README.md",
246 stagedPatch: "diff --git a/README.md b/README.md",257 stagedPatch: "diff --git a/README.md b/README.md",
247 modelSelection: {258 modelSelection: {
248 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [259 ...createModelSelection(
249 { id: "thinking", value: false },260 ProviderInstanceId.make("claudeAgent"),
250 { id: "effort", value: "high" },261 SYNTHETIC_CLAUDE_THINKING_MODEL,
251 ]),262 [
263 { id: "thinking", value: false },
264 { id: "effort", value: "high" },
265 ],
266 ),
252 },267 },
253 });268 });
254269
...@@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
257 ),272 ),
258 );273 );
259274
260 it.effect("forwards Claude fast mode and supported effort", () =>275 it.effect("keeps a configured custom alias opaque to the Claude CLI", () =>
261 withFakeClaudeEnv(276 withFakeClaudeEnv(
262 {277 {
263 output: JSON.stringify({278 output: JSON.stringify({
264 structured_output: {279 structured_output: {
265 title: "Improve orchestration flow",280 title: "Keep custom model",
266 body: "Body",281 body: "",
267 },282 },
268 }),283 }),
269 argsMustContain: '--effort max --settings {"fastMode":true}',284 argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`,
285 claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] },
270 },286 },
271 (textGeneration) =>287 (textGeneration) =>
272 Effect.gen(function* () {288 Effect.gen(function* () {
273 const generated = yield* textGeneration.generatePrContent({289 const generated = yield* textGeneration.generatePrContent({
274 cwd: process.cwd(),290 cwd: process.cwd(),
275 baseBranch: "main",291 baseBranch: "main",
276 headBranch: "feature/claude-effect",292 headBranch: "feature/custom-model",
277 commitSummary: "Improve orchestration",293 commitSummary: "Keep custom model",
278 diffSummary: "1 file changed",294 diffSummary: "1 file changed",
279 diffPatch: "diff --git a/README.md b/README.md",295 diffPatch: "diff --git a/README.md b/README.md",
280 modelSelection: {296 modelSelection: createModelSelection(
281 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [297 ProviderInstanceId.make("claudeAgent"),
298 SYNTHETIC_CLAUDE_COLLIDING_ALIAS,
299 [
282 { id: "effort", value: "max" },300 { id: "effort", value: "max" },
283 { id: "fastMode", value: true },301 { id: "fastMode", value: true },
284 ]),302 { id: "contextWindow", value: "expanded" },
285 },303 ],
304 ),
286 });305 });
287306
288 expect(generated.title).toBe("Improve orchestration flow");307 expect(generated.title).toBe("Keep custom model");
289 }),308 }),
290 ),309 ),
291 );310 );
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
293 it.effect("generates thread titles through the Claude provider", () =>352 it.effect("generates thread titles through the Claude provider", () =>
294 withFakeClaudeEnv(353 withFakeClaudeEnv(
295 {354 {
...@@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
308 message: "Please investigate reconnect failures after restarting the session.",367 message: "Please investigate reconnect failures after restarting the session.",
309 modelSelection: {368 modelSelection: {
310 instanceId: ProviderInstanceId.make("claudeAgent"),369 instanceId: ProviderInstanceId.make("claudeAgent"),
311 model: "claude-sonnet-4-6",370 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
312 },371 },
313 });372 });
314373
...@@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
343 message: "thread title",402 message: "thread title",
344 modelSelection: {403 modelSelection: {
345 instanceId: ProviderInstanceId.make("claudeAgent"),404 instanceId: ProviderInstanceId.make("claudeAgent"),
346 model: "claude-sonnet-4-6",405 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
347 },406 },
348 });407 });
349408
...@@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
369 message: "Name this thread.",428 message: "Name this thread.",
370 modelSelection: {429 modelSelection: {
371 instanceId: ProviderInstanceId.make("claudeAgent"),430 instanceId: ProviderInstanceId.make("claudeAgent"),
372 model: "claude-sonnet-4-6",431 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
373 },432 },
374 });433 });
375434
apps/server/src/textGeneration/ClaudeTextGeneration.ts+34-13
...@@ -37,12 +37,16 @@ import {...@@ -37,12 +37,16 @@ import {
37 getProviderOptionDescriptors,37 getProviderOptionDescriptors,
38} from "@t3tools/shared/model";38} from "@t3tools/shared/model";
39import {39import {
40 getClaudeModelCapabilities,40 BUNDLED_CLAUDE_MODEL_CATALOG,
41 isClaudeUltracodeEffort,41 type ClaudeModelCatalog,
42 normalizeClaudeCliEffort,42 getClaudeCatalogModelCapabilities,
43 resolveClaudeApiModelId,43 isClaudeCatalogUltracodeEffort,
44 resolveClaudeEffort,44 normalizeClaudeCatalogEffort,
45} from "../provider/Layers/ClaudeProvider.ts";45 resolveClaudeCatalogApiModelId,
46 resolveClaudeCatalogEffort,
47 resolveClaudeModelSlug,
48 scopeClaudeModelCatalog,
49} from "../provider/ClaudeModelCatalog.ts";
46import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";50import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
4751
48const CLAUDE_TIMEOUT_MS = 180_000;52const CLAUDE_TIMEOUT_MS = 180_000;
...@@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla...@@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla
61export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (65export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
62 claudeSettings: ClaudeSettings,66 claudeSettings: ClaudeSettings,
63 environment?: NodeJS.ProcessEnv,67 environment?: NodeJS.ProcessEnv,
68 modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG),
64) {69) {
65 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;70 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
66 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);71 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
72 const scopedModelCatalog = modelCatalog.pipe(
73 Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)),
74 );
6775
68 const readStreamAsString = <E>(76 const readStreamAsString = <E>(
69 operation: string,77 operation: string,
...@@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
121 outputSchemaJson: S;129 outputSchemaJson: S;
122 modelSelection: ModelSelection;130 modelSelection: ModelSelection;
123 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {131 }): 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 };
124 const jsonSchemaStr = yield* encodeJsonForOperation(137 const jsonSchemaStr = yield* encodeJsonForOperation(
125 operation,138 operation,
126 toJsonSchemaObject(outputSchemaJson),139 toJsonSchemaObject(outputSchemaJson),
127 "Failed to encode structured output schema.",140 "Failed to encode structured output schema.",
128 );141 );
129 const caps = getClaudeModelCapabilities(modelSelection.model);142 const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model);
130 const descriptors = getProviderOptionDescriptors({143 const descriptors = getProviderOptionDescriptors({
131 caps,144 caps,
132 selections: modelSelection.options,145 selections: resolvedModelSelection.options,
133 });146 });
134 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);147 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
135 const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort");148 const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort");
136 const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection);149 const resolvedEffort = resolveClaudeCatalogEffort(
137 const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model);150 catalog,
138 const ultracode = isClaudeUltracodeEffort(resolvedEffort);151 resolvedModelSelection.model,
152 rawEffortSelection,
153 );
154 const cliEffort = normalizeClaudeCatalogEffort(
155 catalog,
156 resolvedEffort,
157 resolvedModelSelection.model,
158 );
159 const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort);
139 const thinkingDescriptor = findDescriptor("thinking");160 const thinkingDescriptor = findDescriptor("thinking");
140 const fastModeDescriptor = findDescriptor("fastMode");161 const fastModeDescriptor = findDescriptor("fastMode");
141 const thinking =162 const thinking =
...@@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
166 "--json-schema",187 "--json-schema",
167 jsonSchemaStr,188 jsonSchemaStr,
168 "--model",189 "--model",
169 resolveClaudeApiModelId(modelSelection),190 resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection),
170 ...(cliEffort ? ["--effort", cliEffort] : []),191 ...(cliEffort ? ["--effort", cliEffort] : []),
171 ...(settingsJson ? ["--settings", settingsJson] : []),192 ...(settingsJson ? ["--settings", settingsJson] : []),
172 "--dangerously-skip-permissions",193 "--dangerously-skip-permissions",
apps/web/src/components/chat/ModelPickerContent.tsx+3-2
...@@ -15,7 +15,6 @@ import {...@@ -15,7 +15,6 @@ import {
15 parseModelPickerLegacySectionKey,15 parseModelPickerLegacySectionKey,
16 parseModelPickerModelKey,16 parseModelPickerModelKey,
17} from "./modelPickerKeys";17} from "./modelPickerKeys";
18import { isModelPickerNewModel } from "./modelPickerModelHighlights";
19import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";18import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
20import {19import {
21 Combobox,20 Combobox,
...@@ -47,6 +46,7 @@ type ModelPickerItem = {...@@ -47,6 +46,7 @@ type ModelPickerItem = {
47 name: string;46 name: string;
48 shortName?: string;47 shortName?: string;
49 subProvider?: string;48 subProvider?: string;
49 badge?: "new";
50 instanceId: ProviderInstanceId;50 instanceId: ProviderInstanceId;
51 driverKind: ProviderDriverKind;51 driverKind: ProviderDriverKind;
52 instanceDisplayName: string;52 instanceDisplayName: string;
...@@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
256 name: model.name,256 name: model.name,
257 ...(model.shortName ? { shortName: model.shortName } : {}),257 ...(model.shortName ? { shortName: model.shortName } : {}),
258 ...(model.subProvider ? { subProvider: model.subProvider } : {}),258 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
259 ...(model.badge ? { badge: model.badge } : {}),
259 ...(model.isLegacy ? { isLegacy: true } : {}),260 ...(model.isLegacy ? { isLegacy: true } : {}),
260 ...(model.isUnavailable ? { isUnavailable: true } : {}),261 ...(model.isUnavailable ? { isUnavailable: true } : {}),
261 instanceId,262 instanceId,
...@@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
804 showProvider805 showProvider
805 preferShortName={!isLocked}806 preferShortName={!isLocked}
806 useTriggerLabel={false}807 useTriggerLabel={false}
807 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}808 showNewBadge={model.badge === "new"}
808 unavailable={model.isUnavailable === true}809 unavailable={model.isUnavailable === true}
809 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}810 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
810 disabledReason={disabledReason}811 disabledReason={disabledReason}
apps/web/src/components/chat/composerProviderState.test.tsx+4-3
...@@ -291,11 +291,12 @@ describe("getComposerProviderState", () => {...@@ -291,11 +291,12 @@ describe("getComposerProviderState", () => {
291 it("validates options for a known model selected through a legacy alias", () => {291 it("validates options for a known model selected through a legacy alias", () => {
292 const state = getComposerProviderState({292 const state = getComposerProviderState({
293 provider: ProviderDriverKind.make("claudeAgent"),293 provider: ProviderDriverKind.make("claudeAgent"),
294 model: "opus",294 model: "legacy-test-model",
295 models: [295 models: [
296 {296 {
297 slug: "claude-opus-5",297 slug: "test-model",
298 name: "Claude Opus 5",298 name: "Test Model",
299 aliases: ["legacy-test-model"],
299 isCustom: false,300 isCustom: false,
300 capabilities: {301 capabilities: {
301 optionDescriptors: [302 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 = {...@@ -26,6 +26,7 @@ export type ModelEsque = {
26 name: string;26 name: string;
27 shortName?: string | undefined;27 shortName?: string | undefined;
28 subProvider?: string | undefined;28 subProvider?: string | undefined;
29 badge?: "new" | undefined;
29 isLegacy?: boolean | undefined;30 isLegacy?: boolean | undefined;
30 isUnavailable?: boolean | undefined;31 isUnavailable?: boolean | undefined;
31};32};
apps/web/src/modelSelection.ts+4
...@@ -75,6 +75,8 @@ export interface AppModelOption {...@@ -75,6 +75,8 @@ export interface AppModelOption {
75 name: string;75 name: string;
76 shortName?: string;76 shortName?: string;
77 subProvider?: string;77 subProvider?: string;
78 aliases?: ReadonlyArray<string>;
79 badge?: "new";
78 isCustom: boolean;80 isCustom: boolean;
79 isDefault?: boolean;81 isDefault?: boolean;
80 isLegacy?: boolean;82 isLegacy?: boolean;
...@@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti...@@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti
109 };111 };
110 if (model.shortName) option.shortName = model.shortName;112 if (model.shortName) option.shortName = model.shortName;
111 if (model.subProvider) option.subProvider = model.subProvider;113 if (model.subProvider) option.subProvider = model.subProvider;
114 if (model.aliases) option.aliases = model.aliases;
115 if (model.badge) option.badge = model.badge;
112 if (model.isDefault) option.isDefault = true;116 if (model.isDefault) option.isDefault = true;
113 if (model.isLegacy) option.isLegacy = true;117 if (model.isLegacy) option.isLegacy = true;
114 return option;118 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 {...@@ -8,7 +8,7 @@ import {
8 type ServerProvider,8 type ServerProvider,
9 type ServerProviderModel,9 type ServerProviderModel,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model";11import { createModelCapabilities, resolveSelectableModel } from "@t3tools/shared/model";
1212
13const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({13const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
14 optionDescriptors: [],14 optionDescriptors: [],
...@@ -83,9 +83,9 @@ export function getProviderModelCapabilities(...@@ -83,9 +83,9 @@ export function getProviderModelCapabilities(
83 provider: ProviderDriverKind,83 provider: ProviderDriverKind,
84 planModeEnabled = true,84 planModeEnabled = true,
85): ModelCapabilities {85): ModelCapabilities {
86 const slug = normalizeModelSlug(model, provider);86 const slug = resolveSelectableModel(provider, model, models);
87 const caps =87 const selectedModel = models.find((candidate) => candidate.slug === slug);
88 models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES;88 const caps = selectedModel?.capabilities ?? EMPTY_CAPABILITIES;
89 if (planModeEnabled) {89 if (planModeEnabled) {
90 return caps;90 return caps;
91 }91 }
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<...@@ -176,33 +176,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
176 "5.3-spark": "gpt-5.3-codex-spark",176 "5.3-spark": "gpt-5.3-codex-spark",
177 "gpt-5.3-spark": "gpt-5.3-codex-spark",177 "gpt-5.3-spark": "gpt-5.3-codex-spark",
178 },178 },
179 [CLAUDE_DRIVER_KIND]: {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 },
206 [CURSOR_DRIVER_KIND]: {180 [CURSOR_DRIVER_KIND]: {
207 composer: "composer-2",181 composer: "composer-2",
208 "composer-1.5": "composer-1.5",182 "composer-1.5": "composer-1.5",
packages/contracts/src/server.ts+2
...@@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({...@@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({
66 name: TrimmedNonEmptyString,66 name: TrimmedNonEmptyString,
67 shortName: Schema.optional(TrimmedNonEmptyString),67 shortName: Schema.optional(TrimmedNonEmptyString),
68 subProvider: Schema.optional(TrimmedNonEmptyString),68 subProvider: Schema.optional(TrimmedNonEmptyString),
69 aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
70 badge: Schema.optional(Schema.Literal("new")),
69 isCustom: Schema.Boolean,71 isCustom: Schema.Boolean,
70 isDefault: Schema.optional(Schema.Boolean),72 isDefault: Schema.optional(Schema.Boolean),
71 isLegacy: Schema.optional(Schema.Boolean),73 isLegacy: Schema.optional(Schema.Boolean),
packages/shared/src/model.test.ts+1-13
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";2import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
4import {4import {
5 applyClaudePromptEffortPrefix,5 applyClaudePromptEffortPrefix,
...@@ -11,8 +11,6 @@ import {...@@ -11,8 +11,6 @@ import {
11 getProviderOptionDescriptors,11 getProviderOptionDescriptors,
12 getProviderOptionBooleanSelectionValue,12 getProviderOptionBooleanSelectionValue,
13 getProviderOptionStringSelectionValue,13 getProviderOptionStringSelectionValue,
14 normalizeCustomModelSlug,
15 normalizeModelSlug,
16} from "./model.ts";14} from "./model.ts";
1715
18const codexCaps: ModelCapabilities = createModelCapabilities({16const codexCaps: ModelCapabilities = createModelCapabilities({
...@@ -148,16 +146,6 @@ describe("descriptor helpers", () => {...@@ -148,16 +146,6 @@ describe("descriptor helpers", () => {
148 });146 });
149});147});
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
161describe("applyClaudePromptEffortPrefix", () => {149describe("applyClaudePromptEffortPrefix", () => {
162 it("keeps slash commands intact when ultrathink is selected", () => {150 it("keeps slash commands intact when ultrathink is selected", () => {
163 expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");151 expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");
packages/shared/src/model.ts+8
...@@ -15,6 +15,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");...@@ -15,6 +15,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
15export interface SelectableModelOption {15export interface SelectableModelOption {
16 slug: string;16 slug: string;
17 name: string;17 name: string;
18 aliases?: ReadonlyArray<string> | undefined;
18}19}
1920
20export function createModelCapabilities(input: {21export function createModelCapabilities(input: {
...@@ -281,6 +282,13 @@ export function resolveSelectableModel(...@@ -281,6 +282,13 @@ export function resolveSelectableModel(
281 return byName.slug;282 return byName.slug;
282 }283 }
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
284 const normalized = normalizeModelSlug(trimmed, provider);292 const normalized = normalizeModelSlug(trimmed, provider);
285 if (!normalized) {293 if (!normalized) {
286 return null;294 return null;