| author | |
| committer | |
| log | 03542836834d008a097450cf24d0d9c6f965b859 |
| tree | 8866e08382183be2698ffaa92549ef719428d5a0 |
| parent | a924fbe08e681ef56d8cfb8b99e1e803d52aa463 |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 1774 insertions(+), 1159 deletions(-)
apps/server/src/provider/ClaudeModelCatalog.test.ts created+137| ... | ... | @@ -0,0 +1,137 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; | |
| 5 | import type { ModelManifestData } from "./ModelManifest.ts"; | |
| 6 | import { | |
| 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 | ||
| 22 | const 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 | ||
| 67 | describe("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 @@ |
| 1 | import 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. | |
| 5 | export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; | |
| 6 | export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; | |
| 7 | export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; | |
| 8 | export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; | |
| 9 | ||
| 10 | const 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 | ||
| 23 | const 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 | ||
| 33 | const runtime = { | |
| 34 | effortMap: { ultrathink: null }, | |
| 35 | modelSuffixes: { contextWindow: { expanded: "[expanded]" } }, | |
| 36 | contextWindowTokens: { standard: 200_000, expanded: 1_000_000 }, | |
| 37 | }; | |
| 38 | ||
| 39 | export 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 @@ |
| 1 | import { | |
| 2 | type ModelCapabilities, | |
| 3 | type ModelSelection, | |
| 4 | ProviderDriverKind, | |
| 5 | type ServerProviderModel, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import { | |
| 9 | getModelSelectionStringOptionValue, | |
| 10 | getProviderOptionCurrentValue, | |
| 11 | getProviderOptionDescriptors, | |
| 12 | normalizeCustomModelSlug, | |
| 13 | } from "@t3tools/shared/model"; | |
| 14 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 15 | ||
| 16 | import { | |
| 17 | type ClaudeCodeCompatibility, | |
| 18 | type ClaudeCodeProfile, | |
| 19 | decodeClaudeModelAdapter, | |
| 20 | decodeClaudeProfileAdapter, | |
| 21 | } from "./ClaudeModelManifest.ts"; | |
| 22 | import { | |
| 23 | BUNDLED_MODEL_MANIFEST, | |
| 24 | type ModelManifestData, | |
| 25 | resolveProviderCatalog, | |
| 26 | } from "./ModelManifest.ts"; | |
| 27 | ||
| 28 | const CLAUDE = ProviderDriverKind.make("claudeAgent"); | |
| 29 | const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; | |
| 30 | ||
| 31 | export interface ClaudeCatalogModel { | |
| 32 | readonly model: ServerProviderModel; | |
| 33 | readonly runtime: ClaudeCodeProfile; | |
| 34 | readonly compatibility: ClaudeCodeCompatibility; | |
| 35 | } | |
| 36 | ||
| 37 | export interface ClaudeModelCatalog { | |
| 38 | readonly models: ReadonlyArray<ClaudeCatalogModel>; | |
| 39 | } | |
| 40 | ||
| 41 | function 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 | ||
| 62 | export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { | |
| 63 | return ( | |
| 64 | tryResolveClaudeModelCatalog(manifest) ?? | |
| 65 | tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { | |
| 66 | models: [], | |
| 67 | } | |
| 68 | ); | |
| 69 | } | |
| 70 | ||
| 71 | export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); | |
| 72 | ||
| 73 | /** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ | |
| 74 | export 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 | ||
| 102 | export 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 | ||
| 116 | export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { | |
| 117 | return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; | |
| 118 | } | |
| 119 | ||
| 120 | export function getClaudeCatalogModelCapabilities( | |
| 121 | catalog: ClaudeModelCatalog, | |
| 122 | slugOrAlias: string | null | undefined, | |
| 123 | ): ModelCapabilities { | |
| 124 | return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES; | |
| 125 | } | |
| 126 | ||
| 127 | function 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 | ||
| 142 | export 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 | ||
| 151 | export 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 | ||
| 169 | export 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 | ||
| 184 | export 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 | ||
| 195 | export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean { | |
| 196 | return effort === "ultracode"; | |
| 197 | } | |
| 198 | ||
| 199 | export 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 | ||
| 214 | export 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 | ||
| 233 | export 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 @@ |
| 1 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 2 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | ||
| 6 | export 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 | ||
| 20 | export const ClaudeProfileAdapterSchema = Schema.Struct({ | |
| 21 | claudeCode: Schema.optional(ClaudeCodeProfileSchema), | |
| 22 | }); | |
| 23 | ||
| 24 | const ClaudeVersionSchema = TrimmedNonEmptyString.pipe( | |
| 25 | Schema.check( | |
| 26 | Schema.makeFilter((version) => parseSemver(version) !== null, { | |
| 27 | expected: "a supported semantic version", | |
| 28 | }), | |
| 29 | ), | |
| 30 | ); | |
| 31 | ||
| 32 | const 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 | ||
| 47 | export const ClaudeModelAdapterSchema = Schema.Struct({ | |
| 48 | claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema), | |
| 49 | }); | |
| 50 | ||
| 51 | export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type; | |
| 52 | export type ClaudeCodeCompatibility = NonNullable<typeof ClaudeModelAdapterSchema.Type.claudeCode>; | |
| 53 | ||
| 54 | export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema); | |
| 55 | export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema); | |
| 56 | ||
| 57 | interface 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 | ||
| 72 | export 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 | 35 | probeClaudeCapabilities, |
| 36 | 36 | } from "../Layers/ClaudeProvider.ts"; |
| 37 | 37 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 38 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; | |
| 38 | 39 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 39 | 40 | import * as ModelManifest from "../ModelManifest.ts"; |
| 40 | 41 | import { |
| ... | ... | @@ -128,6 +129,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 128 | 129 | const serverSettings = yield* ServerSettingsService; |
| 129 | 130 | const eventLoggers = yield* ProviderEventLoggers; |
| 130 | 131 | const modelManifest = yield* ModelManifest.ModelManifest; |
| 132 | const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); | |
| 131 | 133 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 132 | 134 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 133 | 135 | driverKind: DRIVER_KIND, |
| ... | ... | @@ -149,10 +151,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 149 | 151 | const adapterOptions = { |
| 150 | 152 | instanceId, |
| 151 | 153 | environment: processEnv, |
| 154 | modelCatalog, | |
| 152 | 155 | ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), |
| 153 | 156 | }; |
| 154 | 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 | ); | |
| 156 | 163 | |
| 157 | 164 | // Per-instance capabilities cache: keyed on binary + resolved HOME so |
| 158 | 165 | // account-specific probes never share auth metadata across instances. |
| ... | ... | @@ -166,22 +173,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 166 | 173 | }); |
| 167 | 174 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); |
| 168 | 175 | |
| 169 | // Kick the TTL-gated manifest refresh in the background and classify | |
| 170 | // with the in-memory manifest, so a slow or hung fetch never delays the | |
| 171 | // provider check. A refresh that lands mid-probe applies on the next one. | |
| 176 | // Start the TTL-gated refresh without delaying provider readiness. The | |
| 177 | // next check observes a remote manifest after the background fetch lands. | |
| 172 | 178 | const checkProvider = modelManifest.refreshInBackground.pipe( |
| 173 | 179 | Effect.andThen( |
| 174 | Effect.zipWith( | |
| 175 | checkClaudeProviderStatus( | |
| 176 | effectiveConfig, | |
| 177 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | |
| 178 | processEnv, | |
| 179 | cwd, | |
| 180 | modelManifest.current.pipe( | |
| 181 | Effect.flatMap((manifest) => | |
| 182 | checkClaudeProviderStatus( | |
| 183 | effectiveConfig, | |
| 184 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | |
| 185 | processEnv, | |
| 186 | cwd, | |
| 187 | resolveClaudeModelCatalog(manifest), | |
| 188 | ), | |
| 180 | 189 | ), |
| 181 | modelManifest.current, | |
| 182 | (draft, manifest) => | |
| 183 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 184 | { concurrent: true }, | |
| 190 | Effect.map(stampIdentity), | |
| 185 | 191 | ), |
| 186 | 192 | ), |
| 187 | 193 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| ... | ... | @@ -196,11 +202,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 196 | 202 | streamSettings: snapshotSettings.streamSettings, |
| 197 | 203 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 198 | 204 | initialSnapshot: (settings) => |
| 199 | Effect.zipWith( | |
| 200 | makePendingClaudeProvider(settings.provider), | |
| 201 | modelManifest.current, | |
| 202 | (draft, manifest) => | |
| 203 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 205 | modelManifest.current.pipe( | |
| 206 | Effect.flatMap((manifest) => | |
| 207 | makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), | |
| 208 | ), | |
| 209 | Effect.map(stampIdentity), | |
| 204 | 210 | ), |
| 205 | 211 | checkProvider, |
| 206 | 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 | 35 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| 36 | 36 | import { ServerConfig } from "../../config.ts"; |
| 37 | 37 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 38 | import { | |
| 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"; | |
| 38 | 45 | import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; |
| 39 | 46 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 40 | 47 | import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; |
| ... | ... | @@ -166,6 +173,7 @@ function makeHarness(config?: { |
| 166 | 173 | |
| 167 | 174 | const adapterOptions: ClaudeAdapterLiveOptions = { |
| 168 | 175 | ...(config?.instanceId ? { instanceId: config.instanceId } : {}), |
| 176 | modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), | |
| 169 | 177 | createQuery: (input) => { |
| 170 | 178 | createInput = input; |
| 171 | 179 | return query; |
| ... | ... | @@ -265,6 +273,7 @@ async function readFirstPromptMessage( |
| 265 | 273 | |
| 266 | 274 | const THREAD_ID = ThreadId.make("thread-claude-1"); |
| 267 | 275 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); |
| 276 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; | |
| 268 | 277 | |
| 269 | 278 | describe("ClaudeAdapterLive", () => { |
| 270 | 279 | it.effect("returns validation error for non-claude provider on startSession", () => { |
| ... | ... | @@ -441,7 +450,7 @@ describe("ClaudeAdapterLive", () => { |
| 441 | 450 | provider: ProviderDriverKind.make("claudeAgent"), |
| 442 | 451 | modelSelection: createModelSelection( |
| 443 | 452 | ProviderInstanceId.make("claudeAgent"), |
| 444 | "claude-opus-4-6", | |
| 453 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 445 | 454 | [{ id: "effort", value: "max" }], |
| 446 | 455 | ), |
| 447 | 456 | runtimeMode: "full-access", |
| ... | ... | @@ -464,7 +473,7 @@ describe("ClaudeAdapterLive", () => { |
| 464 | 473 | provider: ProviderDriverKind.make("claudeAgent"), |
| 465 | 474 | modelSelection: createModelSelection( |
| 466 | 475 | ProviderInstanceId.make("claudeAgent"), |
| 467 | "claude-opus-4-6", | |
| 476 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 468 | 477 | ), |
| 469 | 478 | runtimeMode: "full-access", |
| 470 | 479 | }); |
| ... | ... | @@ -480,167 +489,7 @@ describe("ClaudeAdapterLive", () => { |
| 480 | 489 | ); |
| 481 | 490 | }); |
| 482 | 491 | |
| 483 | it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { | |
| 484 | const harness = makeHarness(); | |
| 485 | return Effect.gen(function* () { | |
| 486 | const adapter = yield* ClaudeAdapter; | |
| 487 | yield* adapter.startSession({ | |
| 488 | threadId: THREAD_ID, | |
| 489 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 490 | modelSelection: { | |
| 491 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 492 | model: "claude-opus-4-7", | |
| 493 | }, | |
| 494 | runtimeMode: "full-access", | |
| 495 | }); | |
| 496 | ||
| 497 | const createInput = harness.getLastCreateQueryInput(); | |
| 498 | assert.equal(createInput?.options.effort, "max"); | |
| 499 | }).pipe( | |
| 500 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 501 | Effect.provide(harness.layer), | |
| 502 | ); | |
| 503 | }); | |
| 504 | ||
| 505 | it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { | |
| 506 | const harness = makeHarness(); | |
| 507 | return Effect.gen(function* () { | |
| 508 | const adapter = yield* ClaudeAdapter; | |
| 509 | yield* adapter.startSession({ | |
| 510 | threadId: THREAD_ID, | |
| 511 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 512 | modelSelection: createModelSelection( | |
| 513 | ProviderInstanceId.make("claudeAgent"), | |
| 514 | "claude-opus-4-7", | |
| 515 | [{ id: "effort", value: "xhigh" }], | |
| 516 | ), | |
| 517 | runtimeMode: "full-access", | |
| 518 | }); | |
| 519 | ||
| 520 | const createInput = harness.getLastCreateQueryInput(); | |
| 521 | assert.equal(createInput?.options.effort, "max"); | |
| 522 | }).pipe( | |
| 523 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 524 | Effect.provide(harness.layer), | |
| 525 | ); | |
| 526 | }); | |
| 527 | ||
| 528 | it.effect("preserves xhigh effort for Claude Fable 5.1", () => { | |
| 529 | const harness = makeHarness(); | |
| 530 | return Effect.gen(function* () { | |
| 531 | const adapter = yield* ClaudeAdapter; | |
| 532 | yield* adapter.startSession({ | |
| 533 | threadId: THREAD_ID, | |
| 534 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 535 | modelSelection: createModelSelection( | |
| 536 | ProviderInstanceId.make("claudeAgent"), | |
| 537 | "claude-fable-5-1", | |
| 538 | [{ id: "effort", value: "xhigh" }], | |
| 539 | ), | |
| 540 | runtimeMode: "full-access", | |
| 541 | }); | |
| 542 | ||
| 543 | const createInput = harness.getLastCreateQueryInput(); | |
| 544 | assert.equal(createInput?.options.effort, "xhigh"); | |
| 545 | }).pipe( | |
| 546 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 547 | Effect.provide(harness.layer), | |
| 548 | ); | |
| 549 | }); | |
| 550 | ||
| 551 | it.effect("preserves xhigh effort for Claude Fable 5", () => { | |
| 552 | const harness = makeHarness(); | |
| 553 | return Effect.gen(function* () { | |
| 554 | const adapter = yield* ClaudeAdapter; | |
| 555 | yield* adapter.startSession({ | |
| 556 | threadId: THREAD_ID, | |
| 557 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 558 | modelSelection: createModelSelection( | |
| 559 | ProviderInstanceId.make("claudeAgent"), | |
| 560 | "claude-fable-5", | |
| 561 | [{ id: "effort", value: "xhigh" }], | |
| 562 | ), | |
| 563 | runtimeMode: "full-access", | |
| 564 | }); | |
| 565 | ||
| 566 | const createInput = harness.getLastCreateQueryInput(); | |
| 567 | assert.equal(createInput?.options.effort, "xhigh"); | |
| 568 | }).pipe( | |
| 569 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 570 | Effect.provide(harness.layer), | |
| 571 | ); | |
| 572 | }); | |
| 573 | ||
| 574 | it.effect("preserves xhigh effort for Claude Opus 5", () => { | |
| 575 | const harness = makeHarness(); | |
| 576 | return Effect.gen(function* () { | |
| 577 | const adapter = yield* ClaudeAdapter; | |
| 578 | yield* adapter.startSession({ | |
| 579 | threadId: THREAD_ID, | |
| 580 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 581 | modelSelection: createModelSelection( | |
| 582 | ProviderInstanceId.make("claudeAgent"), | |
| 583 | "claude-opus-5", | |
| 584 | [{ id: "effort", value: "xhigh" }], | |
| 585 | ), | |
| 586 | runtimeMode: "full-access", | |
| 587 | }); | |
| 588 | ||
| 589 | const createInput = harness.getLastCreateQueryInput(); | |
| 590 | assert.equal(createInput?.options.effort, "xhigh"); | |
| 591 | }).pipe( | |
| 592 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 593 | Effect.provide(harness.layer), | |
| 594 | ); | |
| 595 | }); | |
| 596 | ||
| 597 | it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { | |
| 598 | const harness = makeHarness(); | |
| 599 | return Effect.gen(function* () { | |
| 600 | const adapter = yield* ClaudeAdapter; | |
| 601 | yield* adapter.startSession({ | |
| 602 | threadId: THREAD_ID, | |
| 603 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 604 | modelSelection: createModelSelection( | |
| 605 | ProviderInstanceId.make("claudeAgent"), | |
| 606 | "claude-sonnet-4-6", | |
| 607 | [{ id: "effort", value: "max" }], | |
| 608 | ), | |
| 609 | runtimeMode: "full-access", | |
| 610 | }); | |
| 611 | ||
| 612 | const createInput = harness.getLastCreateQueryInput(); | |
| 613 | assert.equal(createInput?.options.effort, "high"); | |
| 614 | }).pipe( | |
| 615 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 616 | Effect.provide(harness.layer), | |
| 617 | ); | |
| 618 | }); | |
| 619 | ||
| 620 | it.effect("ignores adaptive effort for Haiku 4.5", () => { | |
| 621 | const harness = makeHarness(); | |
| 622 | return Effect.gen(function* () { | |
| 623 | const adapter = yield* ClaudeAdapter; | |
| 624 | yield* adapter.startSession({ | |
| 625 | threadId: THREAD_ID, | |
| 626 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 627 | modelSelection: createModelSelection( | |
| 628 | ProviderInstanceId.make("claudeAgent"), | |
| 629 | "claude-haiku-4-5", | |
| 630 | [{ id: "effort", value: "high" }], | |
| 631 | ), | |
| 632 | runtimeMode: "full-access", | |
| 633 | }); | |
| 634 | ||
| 635 | const createInput = harness.getLastCreateQueryInput(); | |
| 636 | assert.equal(createInput?.options.effort, undefined); | |
| 637 | }).pipe( | |
| 638 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 639 | Effect.provide(harness.layer), | |
| 640 | ); | |
| 641 | }); | |
| 642 | ||
| 643 | it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => { | |
| 492 | it.effect("forwards Claude thinking toggle for models that support it", () => { | |
| 644 | 493 | const harness = makeHarness(); |
| 645 | 494 | return Effect.gen(function* () { |
| 646 | 495 | const adapter = yield* ClaudeAdapter; |
| ... | ... | @@ -649,7 +498,7 @@ describe("ClaudeAdapterLive", () => { |
| 649 | 498 | provider: ProviderDriverKind.make("claudeAgent"), |
| 650 | 499 | modelSelection: createModelSelection( |
| 651 | 500 | ProviderInstanceId.make("claudeAgent"), |
| 652 | "claude-haiku-4-5", | |
| 501 | SYNTHETIC_CLAUDE_THINKING_MODEL, | |
| 653 | 502 | [{ id: "thinking", value: false }], |
| 654 | 503 | ), |
| 655 | 504 | runtimeMode: "full-access", |
| ... | ... | @@ -665,7 +514,7 @@ describe("ClaudeAdapterLive", () => { |
| 665 | 514 | ); |
| 666 | 515 | }); |
| 667 | 516 | |
| 668 | it.effect("ignores Claude thinking toggle for non-Haiku models", () => { | |
| 517 | it.effect("ignores Claude thinking toggle for models without it", () => { | |
| 669 | 518 | const harness = makeHarness(); |
| 670 | 519 | return Effect.gen(function* () { |
| 671 | 520 | const adapter = yield* ClaudeAdapter; |
| ... | ... | @@ -674,7 +523,7 @@ describe("ClaudeAdapterLive", () => { |
| 674 | 523 | provider: ProviderDriverKind.make("claudeAgent"), |
| 675 | 524 | modelSelection: createModelSelection( |
| 676 | 525 | ProviderInstanceId.make("claudeAgent"), |
| 677 | "claude-sonnet-4-6", | |
| 526 | SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 678 | 527 | [{ id: "thinking", value: false }], |
| 679 | 528 | ), |
| 680 | 529 | runtimeMode: "full-access", |
| ... | ... | @@ -697,7 +546,7 @@ describe("ClaudeAdapterLive", () => { |
| 697 | 546 | provider: ProviderDriverKind.make("claudeAgent"), |
| 698 | 547 | modelSelection: createModelSelection( |
| 699 | 548 | ProviderInstanceId.make("claudeAgent"), |
| 700 | "claude-opus-4-6", | |
| 549 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 701 | 550 | [{ id: "fastMode", value: true }], |
| 702 | 551 | ), |
| 703 | 552 | runtimeMode: "full-access", |
| ... | ... | @@ -713,7 +562,7 @@ describe("ClaudeAdapterLive", () => { |
| 713 | 562 | ); |
| 714 | 563 | }); |
| 715 | 564 | |
| 716 | it.effect("ignores claude fast mode for non-opus models", () => { | |
| 565 | it.effect("ignores claude fast mode for models without it", () => { | |
| 717 | 566 | const harness = makeHarness(); |
| 718 | 567 | return Effect.gen(function* () { |
| 719 | 568 | const adapter = yield* ClaudeAdapter; |
| ... | ... | @@ -722,7 +571,7 @@ describe("ClaudeAdapterLive", () => { |
| 722 | 571 | provider: ProviderDriverKind.make("claudeAgent"), |
| 723 | 572 | modelSelection: createModelSelection( |
| 724 | 573 | ProviderInstanceId.make("claudeAgent"), |
| 725 | "claude-sonnet-4-6", | |
| 574 | SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 726 | 575 | [{ id: "fastMode", value: true }], |
| 727 | 576 | ), |
| 728 | 577 | runtimeMode: "full-access", |
| ... | ... | @@ -736,6 +585,97 @@ describe("ClaudeAdapterLive", () => { |
| 736 | 585 | ); |
| 737 | 586 | }); |
| 738 | 587 | |
| 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 | 679 | it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { |
| 740 | 680 | const harness = makeHarness(); |
| 741 | 681 | return Effect.gen(function* () { |
| ... | ... | @@ -745,7 +685,7 @@ describe("ClaudeAdapterLive", () => { |
| 745 | 685 | provider: ProviderDriverKind.make("claudeAgent"), |
| 746 | 686 | modelSelection: createModelSelection( |
| 747 | 687 | ProviderInstanceId.make("claudeAgent"), |
| 748 | "claude-sonnet-4-6", | |
| 688 | SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 749 | 689 | [{ id: "effort", value: "ultrathink" }], |
| 750 | 690 | ), |
| 751 | 691 | runtimeMode: "full-access", |
| ... | ... | @@ -757,7 +697,7 @@ describe("ClaudeAdapterLive", () => { |
| 757 | 697 | attachments: [], |
| 758 | 698 | modelSelection: createModelSelection( |
| 759 | 699 | ProviderInstanceId.make("claudeAgent"), |
| 760 | "claude-sonnet-4-6", | |
| 700 | SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 761 | 701 | [{ id: "effort", value: "ultrathink" }], |
| 762 | 702 | ), |
| 763 | 703 | }); |
| ... | ... | @@ -778,7 +718,7 @@ describe("ClaudeAdapterLive", () => { |
| 778 | 718 | const adapter = yield* ClaudeAdapter; |
| 779 | 719 | const modelSelection = createModelSelection( |
| 780 | 720 | ProviderInstanceId.make("claudeAgent"), |
| 781 | "claude-sonnet-4-6", | |
| 721 | SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 782 | 722 | [{ id: "effort", value: "ultrathink" }], |
| 783 | 723 | ); |
| 784 | 724 | const session = yield* adapter.startSession({ |
| ... | ... | @@ -885,7 +825,7 @@ describe("ClaudeAdapterLive", () => { |
| 885 | 825 | provider: ProviderDriverKind.make("claudeAgent"), |
| 886 | 826 | modelSelection: { |
| 887 | 827 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 888 | model: "claude-sonnet-4-5", | |
| 828 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 889 | 829 | }, |
| 890 | 830 | runtimeMode: "full-access", |
| 891 | 831 | }); |
| ... | ... | @@ -1900,7 +1840,7 @@ describe("ClaudeAdapterLive", () => { |
| 1900 | 1840 | output_tokens: 50, |
| 1901 | 1841 | }, |
| 1902 | 1842 | modelUsage: { |
| 1903 | "claude-opus-4-6": { | |
| 1843 | [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { | |
| 1904 | 1844 | contextWindow: 200000, |
| 1905 | 1845 | maxOutputTokens: 64000, |
| 1906 | 1846 | }, |
| ... | ... | @@ -1990,7 +1930,7 @@ describe("ClaudeAdapterLive", () => { |
| 1990 | 1930 | output_tokens: 50, |
| 1991 | 1931 | }, |
| 1992 | 1932 | modelUsage: { |
| 1993 | "claude-opus-4-6": { | |
| 1933 | [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { | |
| 1994 | 1934 | contextWindow: 200000, |
| 1995 | 1935 | maxOutputTokens: 64000, |
| 1996 | 1936 | }, |
| ... | ... | @@ -2125,7 +2065,7 @@ describe("ClaudeAdapterLive", () => { |
| 2125 | 2065 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2126 | 2066 | modelSelection: createModelSelection( |
| 2127 | 2067 | ProviderInstanceId.make("claudeAgent"), |
| 2128 | "claude-opus-4-6", | |
| 2068 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 2129 | 2069 | [{ id: "effort", value: "max" }], |
| 2130 | 2070 | ), |
| 2131 | 2071 | runtimeMode: "full-access", |
| ... | ... | @@ -2154,7 +2094,7 @@ describe("ClaudeAdapterLive", () => { |
| 2154 | 2094 | type: "assistant", |
| 2155 | 2095 | parent_tool_use_id: "toolu_agent_m", |
| 2156 | 2096 | message: { |
| 2157 | model: "claude-sonnet-5[1m]", | |
| 2097 | model: SYNTHETIC_SUBAGENT_MODEL, | |
| 2158 | 2098 | content: [], |
| 2159 | 2099 | }, |
| 2160 | 2100 | uuid: "subagent-snapshot-uuid", |
| ... | ... | @@ -2174,13 +2114,13 @@ describe("ClaudeAdapterLive", () => { |
| 2174 | 2114 | const started = taskEvents[0]; |
| 2175 | 2115 | assert.equal(started?.type, "task.started"); |
| 2176 | 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 | 2118 | assert.equal(started.payload.effort, "max"); |
| 2179 | 2119 | } |
| 2180 | 2120 | const progress = taskEvents[1]; |
| 2181 | 2121 | assert.equal(progress?.type, "task.progress"); |
| 2182 | 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 | 2124 | assert.equal(progress.payload.effort, "max"); |
| 2185 | 2125 | } |
| 2186 | 2126 | }).pipe( |
| ... | ... | @@ -2206,7 +2146,7 @@ describe("ClaudeAdapterLive", () => { |
| 2206 | 2146 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2207 | 2147 | modelSelection: createModelSelection( |
| 2208 | 2148 | ProviderInstanceId.make("claudeAgent"), |
| 2209 | "claude-opus-4-6", | |
| 2149 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 2210 | 2150 | [{ id: "effort", value: "max" }], |
| 2211 | 2151 | ), |
| 2212 | 2152 | runtimeMode: "full-access", |
| ... | ... | @@ -2223,7 +2163,7 @@ describe("ClaudeAdapterLive", () => { |
| 2223 | 2163 | type: "assistant", |
| 2224 | 2164 | parent_tool_use_id: "toolu_agent_early", |
| 2225 | 2165 | message: { |
| 2226 | model: "claude-sonnet-5[1m]", | |
| 2166 | model: SYNTHETIC_SUBAGENT_MODEL, | |
| 2227 | 2167 | content: [], |
| 2228 | 2168 | }, |
| 2229 | 2169 | uuid: "early-snapshot-uuid", |
| ... | ... | @@ -2253,13 +2193,13 @@ describe("ClaudeAdapterLive", () => { |
| 2253 | 2193 | const started = taskEvents[0]; |
| 2254 | 2194 | assert.equal(started?.type, "task.started"); |
| 2255 | 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 | 2197 | assert.equal(started.payload.effort, "max"); |
| 2258 | 2198 | } |
| 2259 | 2199 | const progress = taskEvents[1]; |
| 2260 | 2200 | assert.equal(progress?.type, "task.progress"); |
| 2261 | 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 | 2204 | }).pipe( |
| 2265 | 2205 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| ... | ... | @@ -2884,7 +2824,7 @@ describe("ClaudeAdapterLive", () => { |
| 2884 | 2824 | output_tokens: 679, |
| 2885 | 2825 | }, |
| 2886 | 2826 | modelUsage: { |
| 2887 | "claude-opus-4-6": { | |
| 2827 | [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { | |
| 2888 | 2828 | contextWindow: 200000, |
| 2889 | 2829 | maxOutputTokens: 64000, |
| 2890 | 2830 | }, |
| ... | ... | @@ -2948,7 +2888,7 @@ describe("ClaudeAdapterLive", () => { |
| 2948 | 2888 | total_tokens: 535000, |
| 2949 | 2889 | }, |
| 2950 | 2890 | modelUsage: { |
| 2951 | "claude-opus-4-6": { | |
| 2891 | [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { | |
| 2952 | 2892 | contextWindow: 200000, |
| 2953 | 2893 | maxOutputTokens: 64000, |
| 2954 | 2894 | }, |
| ... | ... | @@ -3025,7 +2965,7 @@ describe("ClaudeAdapterLive", () => { |
| 3025 | 2965 | total_tokens: 535000, |
| 3026 | 2966 | }, |
| 3027 | 2967 | modelUsage: { |
| 3028 | "claude-opus-4-6": { | |
| 2968 | [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { | |
| 3029 | 2969 | contextWindow: 200000, |
| 3030 | 2970 | maxOutputTokens: 64000, |
| 3031 | 2971 | }, |
| ... | ... | @@ -4069,7 +4009,7 @@ describe("ClaudeAdapterLive", () => { |
| 4069 | 4009 | cwd: "/tmp/claude-adapter-test", |
| 4070 | 4010 | tools: [], |
| 4071 | 4011 | mcp_servers: [], |
| 4072 | model: "claude-sonnet-4-5", | |
| 4012 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 4073 | 4013 | permissionMode: "bypassPermissions", |
| 4074 | 4014 | slash_commands: [], |
| 4075 | 4015 | output_style: "default", |
| ... | ... | @@ -4232,12 +4172,14 @@ describe("ClaudeAdapterLive", () => { |
| 4232 | 4172 | input: "hello", |
| 4233 | 4173 | modelSelection: { |
| 4234 | 4174 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 4235 | model: "claude-opus-4-6", | |
| 4175 | model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 4236 | 4176 | }, |
| 4237 | 4177 | attachments: [], |
| 4238 | 4178 | }); |
| 4239 | 4179 | |
| 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 | 4183 | }).pipe( |
| 4242 | 4184 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 4243 | 4185 | Effect.provide(harness.layer), |
| ... | ... | @@ -4280,7 +4222,7 @@ describe("ClaudeAdapterLive", () => { |
| 4280 | 4222 | const adapter = yield* ClaudeAdapter; |
| 4281 | 4223 | const modelSelection = { |
| 4282 | 4224 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 4283 | model: "claude-opus-4-6", | |
| 4225 | model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 4284 | 4226 | }; |
| 4285 | 4227 | |
| 4286 | 4228 | const session = yield* adapter.startSession({ |
| ... | ... | @@ -4327,8 +4269,8 @@ describe("ClaudeAdapterLive", () => { |
| 4327 | 4269 | input: "hello", |
| 4328 | 4270 | modelSelection: createModelSelection( |
| 4329 | 4271 | ProviderInstanceId.make("claudeAgent"), |
| 4330 | "claude-opus-4-6", | |
| 4331 | [{ id: "contextWindow", value: "1m" }], | |
| 4272 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 4273 | [{ id: "contextWindow", value: "expanded" }], | |
| 4332 | 4274 | ), |
| 4333 | 4275 | attachments: [], |
| 4334 | 4276 | }); |
| ... | ... | @@ -4337,13 +4279,16 @@ describe("ClaudeAdapterLive", () => { |
| 4337 | 4279 | input: "hello again", |
| 4338 | 4280 | modelSelection: createModelSelection( |
| 4339 | 4281 | ProviderInstanceId.make("claudeAgent"), |
| 4340 | "claude-opus-4-6", | |
| 4341 | [{ id: "contextWindow", value: "200k" }], | |
| 4282 | SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 4283 | [{ id: "contextWindow", value: "standard" }], | |
| 4342 | 4284 | ), |
| 4343 | 4285 | attachments: [], |
| 4344 | 4286 | }); |
| 4345 | 4287 | |
| 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 | 4292 | }).pipe( |
| 4348 | 4293 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 4349 | 4294 | Effect.provide(harness.layer), |
| ... | ... | @@ -4553,7 +4498,7 @@ describe("ClaudeAdapterLive", () => { |
| 4553 | 4498 | uuid: "assistant-exit-plan", |
| 4554 | 4499 | parent_tool_use_id: null, |
| 4555 | 4500 | message: { |
| 4556 | model: "claude-opus-4-6", | |
| 4501 | model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, | |
| 4557 | 4502 | id: "msg-exit-plan", |
| 4558 | 4503 | type: "message", |
| 4559 | 4504 | role: "assistant", |
apps/server/src/provider/Layers/ClaudeAdapter.ts+56-38| ... | ... | @@ -80,13 +80,17 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 80 | 80 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 81 | 81 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 82 | 82 | import { |
| 83 | getClaudeModelCapabilities, | |
| 84 | isClaudeUltracodeEffort, | |
| 85 | normalizeClaudeCliEffort, | |
| 86 | resolveClaudeApiModelId, | |
| 87 | resolveClaudeContextWindow, | |
| 88 | resolveClaudeEffort, | |
| 89 | } from "./ClaudeProvider.ts"; | |
| 83 | BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 84 | type ClaudeModelCatalog, | |
| 85 | getClaudeCatalogModelCapabilities, | |
| 86 | isClaudeCatalogUltracodeEffort, | |
| 87 | normalizeClaudeCatalogEffort, | |
| 88 | resolveClaudeCatalogApiModelId, | |
| 89 | resolveClaudeCatalogContextWindowTokens, | |
| 90 | resolveClaudeCatalogEffort, | |
| 91 | resolveClaudeModelSlug, | |
| 92 | scopeClaudeModelCatalog, | |
| 93 | } from "../ClaudeModelCatalog.ts"; | |
| 90 | 94 | import { |
| 91 | 95 | ProviderAdapterProcessError, |
| 92 | 96 | ProviderAdapterRequestError, |
| ... | ... | @@ -333,6 +337,7 @@ export interface ClaudeAdapterLiveOptions { |
| 333 | 337 | }) => ClaudeQueryRuntime; |
| 334 | 338 | readonly nativeEventLogPath?: string; |
| 335 | 339 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 340 | readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>; | |
| 336 | 341 | } |
| 337 | 342 | |
| 338 | 343 | function isUuid(value: string): boolean { |
| ... | ... | @@ -381,10 +386,11 @@ function normalizeClaudeStreamMessages( |
| 381 | 386 | } |
| 382 | 387 | |
| 383 | 388 | function getEffectiveClaudeAgentEffort( |
| 389 | catalog: ClaudeModelCatalog, | |
| 384 | 390 | effort: string | null | undefined, |
| 385 | 391 | model: string | null | undefined, |
| 386 | 392 | ): ClaudeSdkEffort | null { |
| 387 | const normalized = normalizeClaudeCliEffort(effort, model); | |
| 393 | const normalized = normalizeClaudeCatalogEffort(catalog, effort, model); | |
| 388 | 394 | return normalized ? (normalized as ClaudeSdkEffort) : null; |
| 389 | 395 | } |
| 390 | 396 | |
| ... | ... | @@ -470,23 +476,10 @@ function maxClaudeContextWindowFromModelUsage( |
| 470 | 476 | } |
| 471 | 477 | |
| 472 | 478 | function selectedClaudeContextWindow( |
| 479 | catalog: ClaudeModelCatalog, | |
| 473 | 480 | modelSelection: ModelSelection | undefined, |
| 474 | 481 | ): number | undefined { |
| 475 | switch (modelSelection?.model) { | |
| 476 | case "claude-opus-4-8": | |
| 477 | case "claude-opus-4-7": | |
| 478 | // Always 1M at the API; these models expose no contextWindow option. | |
| 479 | return 1_000_000; | |
| 480 | } | |
| 481 | ||
| 482 | switch (resolveClaudeContextWindow(modelSelection)) { | |
| 483 | case "1m": | |
| 484 | return 1_000_000; | |
| 485 | case "200k": | |
| 486 | return 200_000; | |
| 487 | default: | |
| 488 | return undefined; | |
| 489 | } | |
| 482 | return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection); | |
| 490 | 483 | } |
| 491 | 484 | |
| 492 | 485 | function finiteNonNegativeInteger(value: unknown): number | undefined { |
| ... | ... | @@ -1234,6 +1227,7 @@ const CLAUDE_SETTING_SOURCES = [ |
| 1234 | 1227 | function buildPromptText( |
| 1235 | 1228 | input: ProviderSendTurnInput, |
| 1236 | 1229 | boundInstanceId: ProviderInstanceId, |
| 1230 | catalog: ClaudeModelCatalog, | |
| 1237 | 1231 | ): string { |
| 1238 | 1232 | const rawEffort = |
| 1239 | 1233 | input.modelSelection?.instanceId === boundInstanceId |
| ... | ... | @@ -1241,7 +1235,7 @@ function buildPromptText( |
| 1241 | 1235 | : null; |
| 1242 | 1236 | const claudeModel = |
| 1243 | 1237 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; |
| 1244 | const caps = getClaudeModelCapabilities(claudeModel); | |
| 1238 | const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel); | |
| 1245 | 1239 | |
| 1246 | 1240 | const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); |
| 1247 | 1241 | return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); |
| ... | ... | @@ -1281,9 +1275,10 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( |
| 1281 | 1275 | readonly fileSystem: FileSystem.FileSystem; |
| 1282 | 1276 | readonly attachmentsDir: string; |
| 1283 | 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 | 1282 | const sdkContent: Array<Record<string, unknown>> = []; |
| 1288 | 1283 | |
| 1289 | 1284 | if (text.length > 0) { |
| ... | ... | @@ -1682,6 +1677,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1682 | 1677 | options?: ClaudeAdapterLiveOptions, |
| 1683 | 1678 | ) { |
| 1684 | 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 | 1683 | const fileSystem = yield* FileSystem.FileSystem; |
| 1686 | 1684 | const path = yield* Path.Path; |
| 1687 | 1685 | const serverConfig = yield* ServerConfig; |
| ... | ... | @@ -3805,6 +3803,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3805 | 3803 | |
| 3806 | 3804 | const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( |
| 3807 | 3805 | function* (input) { |
| 3806 | const modelCatalog = yield* modelCatalogEffect; | |
| 3808 | 3807 | if (input.provider !== undefined && input.provider !== PROVIDER) { |
| 3809 | 3808 | return yield* new ProviderAdapterValidationError({ |
| 3810 | 3809 | provider: PROVIDER, |
| ... | ... | @@ -4243,14 +4242,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4243 | 4242 | |
| 4244 | 4243 | const claudeBinaryPath = claudeSdkExecutablePath; |
| 4245 | 4244 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; |
| 4246 | const modelSelection = | |
| 4245 | const selectedModel = | |
| 4247 | 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 | 4254 | const descriptors = getProviderOptionDescriptors({ caps }); |
| 4250 | const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; | |
| 4251 | const initialContextWindow = selectedClaudeContextWindow(modelSelection); | |
| 4255 | const apiModelId = modelSelection | |
| 4256 | ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) | |
| 4257 | : undefined; | |
| 4258 | const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); | |
| 4252 | 4259 | const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); |
| 4253 | const effort = resolveClaudeEffort(caps, rawEffort) ?? null; | |
| 4260 | const effort = | |
| 4261 | resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null; | |
| 4254 | 4262 | const fastModeSupported = descriptors.some( |
| 4255 | 4263 | (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", |
| 4256 | 4264 | ); |
| ... | ... | @@ -4263,8 +4271,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4263 | 4271 | const thinking = thinkingSupported |
| 4264 | 4272 | ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") |
| 4265 | 4273 | : undefined; |
| 4266 | const ultracode = isClaudeUltracodeEffort(effort); | |
| 4267 | const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); | |
| 4274 | const ultracode = isClaudeCatalogUltracodeEffort(effort); | |
| 4275 | const effectiveEffort = getEffectiveClaudeAgentEffort( | |
| 4276 | modelCatalog, | |
| 4277 | effort, | |
| 4278 | modelSelection?.model, | |
| 4279 | ); | |
| 4268 | 4280 | const runtimeModeToPermission: Record<string, PermissionMode> = { |
| 4269 | 4281 | "auto-accept-edits": "acceptEdits", |
| 4270 | 4282 | auto: "auto", |
| ... | ... | @@ -4495,10 +4507,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4495 | 4507 | |
| 4496 | 4508 | const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { |
| 4497 | 4509 | const context = yield* requireSession(input.threadId); |
| 4498 | const modelSelection = | |
| 4510 | const modelCatalog = yield* modelCatalogEffect; | |
| 4511 | const selectedModel = | |
| 4499 | 4512 | input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId |
| 4500 | 4513 | ? input.modelSelection |
| 4501 | 4514 | : undefined; |
| 4515 | const modelSelection = selectedModel | |
| 4516 | ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } | |
| 4517 | : undefined; | |
| 4502 | 4518 | |
| 4503 | 4519 | // A sendTurn while a real turn is running is a steer: the message is |
| 4504 | 4520 | // 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 | 4528 | } |
| 4513 | 4529 | |
| 4514 | 4530 | if (modelSelection?.model) { |
| 4515 | const apiModelId = resolveClaudeApiModelId(modelSelection); | |
| 4531 | const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); | |
| 4516 | 4532 | if (context.currentApiModelId !== apiModelId) { |
| 4517 | 4533 | yield* Effect.tryPromise({ |
| 4518 | 4534 | try: () => context.query.setModel(apiModelId), |
| ... | ... | @@ -4524,13 +4540,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4524 | 4540 | ...context.session, |
| 4525 | 4541 | model: modelSelection.model, |
| 4526 | 4542 | }; |
| 4527 | const turnCaps = getClaudeModelCapabilities(modelSelection.model); | |
| 4528 | const turnEffort = resolveClaudeEffort( | |
| 4529 | turnCaps, | |
| 4543 | const turnEffort = resolveClaudeCatalogEffort( | |
| 4544 | modelCatalog, | |
| 4545 | modelSelection.model, | |
| 4530 | 4546 | getModelSelectionStringOptionValue(modelSelection, "effort"), |
| 4531 | 4547 | ); |
| 4532 | 4548 | context.currentEffort = |
| 4533 | getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; | |
| 4549 | getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ?? | |
| 4550 | undefined; | |
| 4534 | 4551 | } |
| 4535 | 4552 | |
| 4536 | 4553 | // Apply interaction mode by switching the SDK's permission mode. |
| ... | ... | @@ -4589,6 +4606,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4589 | 4606 | fileSystem, |
| 4590 | 4607 | attachmentsDir: serverConfig.attachmentsDir, |
| 4591 | 4608 | boundInstanceId, |
| 4609 | modelCatalog, | |
| 4592 | 4610 | }); |
| 4593 | 4611 | |
| 4594 | 4612 | yield* Queue.offer(context.promptQueue, { |
apps/server/src/provider/Layers/ClaudeProvider.ts+13-487| ... | ... | @@ -1,8 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ClaudeSettings, |
| 3 | 3 | type ModelCapabilities, |
| 4 | type ModelSelection, | |
| 5 | type ServerProviderModel, | |
| 6 | 4 | type ServerProviderSlashCommand, |
| 7 | 5 | } from "@t3tools/contracts"; |
| 8 | 6 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -12,14 +10,8 @@ import * as Option from "effect/Option"; |
| 12 | 10 | import * as Path from "effect/Path"; |
| 13 | 11 | import * as Result from "effect/Result"; |
| 14 | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | import { | |
| 16 | createModelCapabilities, | |
| 17 | getModelSelectionStringOptionValue, | |
| 18 | getProviderOptionCurrentValue, | |
| 19 | getProviderOptionDescriptors, | |
| 20 | } from "@t3tools/shared/model"; | |
| 13 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 21 | 14 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 22 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 23 | 15 | import { |
| 24 | 16 | query as claudeQuery, |
| 25 | 17 | type Options as ClaudeQueryOptions, |
| ... | ... | @@ -29,8 +21,6 @@ import { |
| 29 | 21 | } from "@anthropic-ai/claude-agent-sdk"; |
| 30 | 22 | |
| 31 | 23 | import { |
| 32 | buildBooleanOptionDescriptor, | |
| 33 | buildSelectOptionDescriptor, | |
| 34 | 24 | buildServerProvider, |
| 35 | 25 | DEFAULT_TIMEOUT_MS, |
| 36 | 26 | isCommandMissingCause, |
| ... | ... | @@ -42,6 +32,12 @@ import { |
| 42 | 32 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 43 | 33 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 44 | 34 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; |
| 35 | import { | |
| 36 | BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 37 | type ClaudeModelCatalog, | |
| 38 | formatClaudeVersionUpgradeMessage, | |
| 39 | resolveClaudeModelsForVersion, | |
| 40 | } from "../ClaudeModelCatalog.ts"; | |
| 45 | 41 | |
| 46 | 42 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 47 | 43 | optionDescriptors: [], |
| ... | ... | @@ -51,468 +47,6 @@ const CLAUDE_PRESENTATION = { |
| 51 | 47 | displayName: "Claude", |
| 52 | 48 | showInteractionModeToggle: true, |
| 53 | 49 | } as const; |
| 54 | const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; | |
| 55 | const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; | |
| 56 | const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; | |
| 57 | const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; | |
| 58 | const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; | |
| 59 | ||
| 60 | const 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. | |
| 362 | const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG; | |
| 363 | ||
| 364 | function supportsClaudeFable51(version: string | null | undefined): boolean { | |
| 365 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; | |
| 366 | } | |
| 367 | ||
| 368 | function supportsClaudeOpus5(version: string | null | undefined): boolean { | |
| 369 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; | |
| 370 | } | |
| 371 | ||
| 372 | function supportsClaudeFable5(version: string | null | undefined): boolean { | |
| 373 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; | |
| 374 | } | |
| 375 | ||
| 376 | function supportsClaudeOpus48(version: string | null | undefined): boolean { | |
| 377 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; | |
| 378 | } | |
| 379 | ||
| 380 | function supportsClaudeOpus47(version: string | null | undefined): boolean { | |
| 381 | return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; | |
| 382 | } | |
| 383 | ||
| 384 | function 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 | ||
| 407 | function 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 | ||
| 412 | function 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 | ||
| 417 | function 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 | ||
| 422 | function 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 | ||
| 427 | function 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 | ||
| 432 | export 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 | ||
| 440 | export 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 | */ | |
| 463 | export 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 | ||
| 489 | export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean { | |
| 490 | return effort === "ultracode"; | |
| 491 | } | |
| 492 | ||
| 493 | export 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 | ||
| 507 | export 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 | ||
| 516 | 50 | function toTitleCaseWords(value: string): string { |
| 517 | 51 | const parts: Array<string> = []; |
| 518 | 52 | for (const part of value.split(/[\s_-]+/g)) { |
| ... | ... | @@ -860,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 860 | 394 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, |
| 861 | 395 | environment?: NodeJS.ProcessEnv, |
| 862 | 396 | cwd?: string, |
| 397 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 863 | 398 | ): Effect.fn.Return< |
| 864 | 399 | ServerProviderDraft, |
| 865 | 400 | never, |
| ... | ... | @@ -868,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 868 | 403 | const resolvedEnvironment = environment ?? process.env; |
| 869 | 404 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 870 | 405 | const allModels = providerModelsFromSettings( |
| 871 | BUILT_IN_MODELS, | |
| 406 | modelCatalog.models.map((entry) => entry.model), | |
| 872 | 407 | claudeSettings.customModels, |
| 873 | 408 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, |
| 874 | 409 | ); |
| ... | ... | @@ -958,21 +493,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 958 | 493 | } |
| 959 | 494 | |
| 960 | 495 | const models = providerModelsFromSettings( |
| 961 | getBuiltInClaudeModelsForVersion(parsedVersion), | |
| 496 | resolveClaudeModelsForVersion(modelCatalog, parsedVersion), | |
| 962 | 497 | claudeSettings.customModels, |
| 963 | 498 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, |
| 964 | 499 | ); |
| 965 | const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) | |
| 966 | ? undefined | |
| 967 | : supportsClaudeOpus5(parsedVersion) | |
| 968 | ? formatClaudeFable51UpgradeMessage(parsedVersion) | |
| 969 | : supportsClaudeFable5(parsedVersion) | |
| 970 | ? formatClaudeOpus5UpgradeMessage(parsedVersion) | |
| 971 | : supportsClaudeOpus48(parsedVersion) | |
| 972 | ? formatClaudeFable5UpgradeMessage(parsedVersion) | |
| 973 | : supportsClaudeOpus47(parsedVersion) | |
| 974 | ? formatClaudeOpus48UpgradeMessage(parsedVersion) | |
| 975 | : formatClaudeOpus47UpgradeMessage(parsedVersion); | |
| 500 | const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); | |
| 976 | 501 | |
| 977 | 502 | const capabilities = resolveCapabilities |
| 978 | 503 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) |
| ... | ... | @@ -1035,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 1035 | 560 | |
| 1036 | 561 | export const makePendingClaudeProvider = ( |
| 1037 | 562 | claudeSettings: ClaudeSettings, |
| 563 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 1038 | 564 | ): Effect.Effect<ServerProviderDraft> => |
| 1039 | 565 | Effect.gen(function* () { |
| 1040 | 566 | const checkedAt = yield* nowIso; |
| 1041 | 567 | const models = providerModelsFromSettings( |
| 1042 | BUILT_IN_MODELS, | |
| 568 | modelCatalog.models.map((entry) => entry.model), | |
| 1043 | 569 | claudeSettings.customModels, |
| 1044 | 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 | 2051 | ), |
| 2052 | 2052 | ); |
| 2053 | 2053 | |
| 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 | 2054 | it.effect("returns a display label for claude subscription types", () => |
| 2297 | 2055 | Effect.gen(function* () { |
| 2298 | 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 | 3 | import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 6 | 7 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 7 | 8 | |
| 8 | 9 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -10,61 +11,20 @@ import * as ServerSettings from "../serverSettings.ts"; |
| 10 | 11 | import { |
| 11 | 12 | BUNDLED_MODEL_MANIFEST, |
| 12 | 13 | classifyModels, |
| 13 | isLegacyModel, | |
| 14 | 14 | make, |
| 15 | resolveProviderCatalog, | |
| 15 | 16 | type ModelManifestData, |
| 16 | 17 | } from "./ModelManifest.ts"; |
| 17 | 18 | |
| 18 | const CODEX = ProviderDriverKind.make("codex"); | |
| 19 | const CLAUDE = ProviderDriverKind.make("claudeAgent"); | |
| 20 | const CURSOR = ProviderDriverKind.make("cursor"); | |
| 21 | ||
| 22 | describe("isLegacyModel (bundled manifest)", () => { | |
| 23 | it("keeps current Codex models out of legacy models", () => { | |
| 24 | assert.deepStrictEqual( | |
| 25 | [ | |
| 26 | "gpt-5.6-luna", | |
| 27 | "gpt-5.6-terra", | |
| 28 | "gpt-5.6-sol", | |
| 29 | "gpt-daybreak-blue-latest", | |
| 30 | "gpt-daybreak-red-latest", | |
| 31 | "gpt-5.4", | |
| 32 | ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), | |
| 33 | [ | |
| 34 | ["gpt-5.6-luna", false], | |
| 35 | ["gpt-5.6-terra", false], | |
| 36 | ["gpt-5.6-sol", false], | |
| 37 | ["gpt-daybreak-blue-latest", false], | |
| 38 | ["gpt-daybreak-red-latest", false], | |
| 39 | ["gpt-5.4", true], | |
| 40 | ], | |
| 41 | ); | |
| 42 | }); | |
| 43 | ||
| 44 | it("keeps only the Claude 5 family out of legacy models", () => { | |
| 45 | assert.deepStrictEqual( | |
| 46 | [ | |
| 47 | "claude-fable-5-1", | |
| 48 | "claude-fable-5", | |
| 49 | "claude-opus-5", | |
| 50 | "claude-sonnet-5", | |
| 51 | "claude-opus-4-8", | |
| 52 | ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), | |
| 53 | [ | |
| 54 | ["claude-fable-5-1", false], | |
| 55 | ["claude-fable-5", true], | |
| 56 | ["claude-opus-5", false], | |
| 57 | ["claude-sonnet-5", false], | |
| 58 | ["claude-opus-4-8", true], | |
| 59 | ], | |
| 60 | ); | |
| 61 | }); | |
| 62 | ||
| 63 | it("leaves driver kinds without a manifest entry unflagged", () => { | |
| 64 | assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); | |
| 65 | }); | |
| 66 | }); | |
| 19 | /** | |
| 20 | * Test policy: this file covers manifest machinery, not manifest contents. | |
| 21 | * Do not add assertions for real model slugs, names, status, aliases, or | |
| 22 | * profiles when editing model-manifest.json. Add tests only when fetch/cache | |
| 23 | * behavior or the provider-neutral resolver semantics change, and use | |
| 24 | * synthetic models for resolver coverage. | |
| 25 | */ | |
| 67 | 26 | |
| 27 | const CODEX = ProviderDriverKind.make("codex"); | |
| 68 | 28 | const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({ |
| 69 | 29 | slug: "gpt-test", |
| 70 | 30 | name: "GPT Test", |
| ... | ... | @@ -75,37 +35,227 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => |
| 75 | 35 | |
| 76 | 36 | describe("classifyModels", () => { |
| 77 | 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 | 42 | const models = [ |
| 79 | model({ slug: "gpt-5.6-sol" }), | |
| 43 | model({ slug: "current-a" }), | |
| 80 | 44 | // Stale flag from a previous classification pass must be cleared. |
| 81 | model({ slug: "gpt-5.6-luna", isLegacy: true }), | |
| 82 | model({ slug: "gpt-5.4" }), | |
| 45 | model({ slug: "current-b", isLegacy: true }), | |
| 46 | model({ slug: "old-model" }), | |
| 83 | 47 | // Custom models are user-defined and never reclassified. |
| 84 | 48 | model({ slug: "my-own-model", isCustom: true }), |
| 85 | 49 | ]; |
| 86 | 50 | assert.deepStrictEqual( |
| 87 | classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ | |
| 88 | entry.slug, | |
| 89 | entry.isLegacy ?? false, | |
| 90 | ]), | |
| 51 | classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), | |
| 91 | 52 | [ |
| 92 | ["gpt-5.6-sol", false], | |
| 93 | ["gpt-5.6-luna", false], | |
| 94 | ["gpt-5.4", true], | |
| 53 | ["current-a", false], | |
| 54 | ["current-b", false], | |
| 55 | ["old-model", true], | |
| 95 | 56 | ["my-own-model", false], |
| 96 | 57 | ], |
| 97 | 58 | ); |
| 98 | 59 | }); |
| 99 | 60 | }); |
| 100 | 61 | |
| 62 | describe("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 | ||
| 101 | 158 | const REMOTE_MANIFEST: ModelManifestData = { |
| 102 | 159 | version: 1, |
| 103 | 160 | currentModels: { |
| 104 | codex: ["gpt-5.4"], | |
| 105 | claudeAgent: ["claude-fable-5"], | |
| 161 | codex: ["remote-model"], | |
| 162 | claudeAgent: ["remote-agent-model"], | |
| 106 | 163 | }, |
| 107 | 164 | }; |
| 108 | 165 | |
| 166 | const 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 | ||
| 188 | const 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 | ||
| 201 | const 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 | ||
| 109 | 259 | const httpClientLayer = (handler: () => Response) => |
| 110 | 260 | Layer.succeed( |
| 111 | 261 | HttpClient.HttpClient, |
| ... | ... | @@ -129,8 +279,6 @@ describe("ModelManifest service", () => { |
| 129 | 279 | const service = yield* make; |
| 130 | 280 | const refreshed = yield* service.refresh; |
| 131 | 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")); | |
| 134 | 282 | |
| 135 | 283 | // A fresh service instance sees the disk cache without another fetch: |
| 136 | 284 | // its HTTP layer is still stubbed, but `current` never fetches at all. |
| ... | ... | @@ -162,6 +310,33 @@ describe("ModelManifest service", () => { |
| 162 | 310 | ), |
| 163 | 311 | ); |
| 164 | 312 | |
| 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 | 340 | it.live("does not fetch when provider update checks are disabled", () => |
| 166 | 341 | Effect.gen(function* () { |
| 167 | 342 | let fetchCount = 0; |
apps/server/src/provider/ModelManifest.ts+139-18| ... | ... | @@ -1,20 +1,24 @@ |
| 1 | 1 | /** |
| 2 | * ModelManifest — decides which provider models are current and which belong | |
| 3 | * in the model picker's legacy section. | |
| 2 | * ModelManifest — remote provider-model metadata with a bundled offline | |
| 3 | * fallback. | |
| 4 | 4 | * |
| 5 | * The classification data (current slugs per driver kind) lives in | |
| 6 | * `model-manifest.json` next to this file. The bundled copy ships with every | |
| 7 | * release. At runtime the service refreshes it from the same file on `main` | |
| 8 | * via raw.githubusercontent.com, so a new model can leave the legacy section | |
| 9 | * with a commit to `main` instead of a release. Preference order is remote, | |
| 10 | * then the on-disk copy of the last successful fetch, then the bundle. A | |
| 11 | * failed fetch never fails a provider check. | |
| 5 | * Provider catalogs and legacy classification live in `model-manifest.json`. | |
| 6 | * The bundled copy ships with every release; at runtime the service refreshes | |
| 7 | * it from the same file on `main`. Preference order is remote, then the last | |
| 8 | * successful on-disk copy, then the bundle. A failed fetch never fails a | |
| 9 | * provider check. | |
| 12 | 10 | * |
| 13 | * Drivers apply the manifest to snapshot drafts with `applyModelManifest` | |
| 14 | * before publishing, so every path that produces models (pending, probe, | |
| 15 | * error fallbacks) is classified the same way. | |
| 11 | * Providers with authoritative discovery can use only the classification | |
| 12 | * overlay. Providers with static catalogs can resolve presentation and | |
| 13 | * capabilities from `providers`, then decode their own allowlisted adapter | |
| 14 | * payload separately. | |
| 16 | 15 | */ |
| 17 | import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; | |
| 16 | import { | |
| 17 | ModelCapabilities, | |
| 18 | TrimmedNonEmptyString, | |
| 19 | type ProviderDriverKind, | |
| 20 | type ServerProviderModel, | |
| 21 | } from "@t3tools/contracts"; | |
| 18 | 22 | import * as Clock from "effect/Clock"; |
| 19 | 23 | import * as Context from "effect/Context"; |
| 20 | 24 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 27 | 31 | |
| 28 | 32 | import { ServerConfig } from "../config.ts"; |
| 29 | 33 | import * as ServerSettings from "../serverSettings.ts"; |
| 34 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; | |
| 30 | 35 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; |
| 31 | 36 | import type { ServerProviderDraft } from "./providerSnapshot.ts"; |
| 32 | 37 | |
| ... | ... | @@ -42,23 +47,135 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000; |
| 42 | 47 | |
| 43 | 48 | const FETCH_TIMEOUT_MS = 10_000; |
| 44 | 49 | |
| 50 | const ManifestModelStatus = Schema.Literals(["current", "legacy"]); | |
| 51 | ||
| 52 | const ManifestModelProfile = Schema.Struct({ | |
| 53 | capabilities: Schema.optional(ModelCapabilities), | |
| 54 | adapter: Schema.optional(Schema.Unknown), | |
| 55 | }); | |
| 56 | ||
| 57 | const 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 | ||
| 69 | const 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 remote | |
| 47 | * manifests whose version it understands, and keeps its bundled copy | |
| 48 | * otherwise. `currentModels` is keyed by driver kind; kinds absent from the | |
| 49 | * map have no legacy concept and their models are left unflagged. | |
| 80 | * `version` gates breaking schema changes. Provider catalogs are additive so | |
| 81 | * clients that only understand `currentModels` keep accepting this v1 file. | |
| 50 | 82 | */ |
| 51 | const ModelManifestSchema = Schema.Struct({ | |
| 83 | const ModelManifestEnvelopeSchema = Schema.Struct({ | |
| 52 | 84 | version: Schema.Literal(1), |
| 53 | 85 | currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), |
| 86 | providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), | |
| 54 | 87 | }); |
| 88 | ||
| 89 | const 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 | ||
| 104 | const 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 | ); | |
| 55 | 114 | export type ModelManifestData = typeof ModelManifestSchema.Type; |
| 56 | 115 | |
| 116 | export interface ResolvedManifestModel { | |
| 117 | readonly model: ServerProviderModel; | |
| 118 | readonly adapter: unknown; | |
| 119 | readonly profileAdapter: unknown; | |
| 120 | } | |
| 121 | ||
| 122 | export interface ResolvedProviderCatalog { | |
| 123 | readonly models: ReadonlyArray<ResolvedManifestModel>; | |
| 124 | readonly defaults: { | |
| 125 | readonly chat: string | undefined; | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 57 | 129 | const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); |
| 58 | 130 | |
| 59 | 131 | export const BUNDLED_MODEL_MANIFEST: ModelManifestData = |
| 60 | 132 | Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); |
| 61 | 133 | |
| 134 | /** Resolve provider-neutral model presentation and capability data. */ | |
| 135 | export 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 | 179 | /** On-disk shape of the last successfully fetched manifest. */ |
| 63 | 180 | const ManifestCacheFile = Schema.Struct({ |
| 64 | 181 | fetchedAtMs: Schema.Number, |
| ... | ... | @@ -81,6 +198,10 @@ export function isLegacyModel( |
| 81 | 198 | driverKind: ProviderDriverKind, |
| 82 | 199 | slug: string, |
| 83 | 200 | ): boolean { |
| 201 | const catalogModel = manifest.providers?.[driverKind]?.models.find( | |
| 202 | (model) => model.slug === slug, | |
| 203 | ); | |
| 204 | if (catalogModel) return catalogModel.status === "legacy"; | |
| 84 | 205 | const currentModels = manifest.currentModels[driverKind]; |
| 85 | 206 | if (!currentModels) return false; |
| 86 | 207 | return !currentModels.includes(slug); |
apps/server/src/provider/model-manifest.json+359| ... | ... | @@ -9,5 +9,364 @@ |
| 9 | 9 | "gpt-daybreak-red-latest" |
| 10 | 10 | ], |
| 11 | 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 | 11 | import { expect } from "vite-plus/test"; |
| 12 | 12 | |
| 13 | 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import { | |
| 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"; | |
| 14 | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 15 | 22 | import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; |
| 16 | 23 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; |
| ... | ... | @@ -219,13 +226,17 @@ function withFakeClaudeEnv<A, E, R>( |
| 219 | 226 | ); |
| 220 | 227 | |
| 221 | 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 | 234 | return yield* effectFn(textGeneration); |
| 224 | 235 | }).pipe(Effect.scoped); |
| 225 | 236 | } |
| 226 | 237 | |
| 227 | 238 | it.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 | 240 | withFakeClaudeEnv( |
| 230 | 241 | { |
| 231 | 242 | output: JSON.stringify({ |
| ... | ... | @@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 245 | 256 | stagedSummary: "M README.md", |
| 246 | 257 | stagedPatch: "diff --git a/README.md b/README.md", |
| 247 | 258 | modelSelection: { |
| 248 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ | |
| 249 | { id: "thinking", value: false }, | |
| 250 | { id: "effort", value: "high" }, | |
| 251 | ]), | |
| 259 | ...createModelSelection( | |
| 260 | ProviderInstanceId.make("claudeAgent"), | |
| 261 | SYNTHETIC_CLAUDE_THINKING_MODEL, | |
| 262 | [ | |
| 263 | { id: "thinking", value: false }, | |
| 264 | { id: "effort", value: "high" }, | |
| 265 | ], | |
| 266 | ), | |
| 252 | 267 | }, |
| 253 | 268 | }); |
| 254 | 269 | |
| ... | ... | @@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 257 | 272 | ), |
| 258 | 273 | ); |
| 259 | 274 | |
| 260 | it.effect("forwards Claude fast mode and supported effort", () => | |
| 275 | it.effect("keeps a configured custom alias opaque to the Claude CLI", () => | |
| 261 | 276 | withFakeClaudeEnv( |
| 262 | 277 | { |
| 263 | 278 | output: JSON.stringify({ |
| 264 | 279 | structured_output: { |
| 265 | title: "Improve orchestration flow", | |
| 266 | body: "Body", | |
| 280 | title: "Keep custom model", | |
| 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 | 287 | (textGeneration) => |
| 272 | 288 | Effect.gen(function* () { |
| 273 | 289 | const generated = yield* textGeneration.generatePrContent({ |
| 274 | 290 | cwd: process.cwd(), |
| 275 | 291 | baseBranch: "main", |
| 276 | headBranch: "feature/claude-effect", | |
| 277 | commitSummary: "Improve orchestration", | |
| 292 | headBranch: "feature/custom-model", | |
| 293 | commitSummary: "Keep custom model", | |
| 278 | 294 | diffSummary: "1 file changed", |
| 279 | 295 | diffPatch: "diff --git a/README.md b/README.md", |
| 280 | modelSelection: { | |
| 281 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ | |
| 296 | modelSelection: createModelSelection( | |
| 297 | ProviderInstanceId.make("claudeAgent"), | |
| 298 | SYNTHETIC_CLAUDE_COLLIDING_ALIAS, | |
| 299 | [ | |
| 282 | 300 | { id: "effort", value: "max" }, |
| 283 | 301 | { id: "fastMode", value: true }, |
| 284 | ]), | |
| 285 | }, | |
| 302 | { id: "contextWindow", value: "expanded" }, | |
| 303 | ], | |
| 304 | ), | |
| 286 | 305 | }); |
| 287 | 306 | |
| 288 | expect(generated.title).toBe("Improve orchestration flow"); | |
| 307 | expect(generated.title).toBe("Keep custom model"); | |
| 289 | 308 | }), |
| 290 | 309 | ), |
| 291 | 310 | ); |
| 292 | 311 | |
| 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 | 352 | it.effect("generates thread titles through the Claude provider", () => |
| 294 | 353 | withFakeClaudeEnv( |
| 295 | 354 | { |
| ... | ... | @@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 308 | 367 | message: "Please investigate reconnect failures after restarting the session.", |
| 309 | 368 | modelSelection: { |
| 310 | 369 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 311 | model: "claude-sonnet-4-6", | |
| 370 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 312 | 371 | }, |
| 313 | 372 | }); |
| 314 | 373 | |
| ... | ... | @@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 343 | 402 | message: "thread title", |
| 344 | 403 | modelSelection: { |
| 345 | 404 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 346 | model: "claude-sonnet-4-6", | |
| 405 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 347 | 406 | }, |
| 348 | 407 | }); |
| 349 | 408 | |
| ... | ... | @@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 369 | 428 | message: "Name this thread.", |
| 370 | 429 | modelSelection: { |
| 371 | 430 | instanceId: ProviderInstanceId.make("claudeAgent"), |
| 372 | model: "claude-sonnet-4-6", | |
| 431 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | |
| 373 | 432 | }, |
| 374 | 433 | }); |
| 375 | 434 |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+34-13| ... | ... | @@ -37,12 +37,16 @@ import { |
| 37 | 37 | getProviderOptionDescriptors, |
| 38 | 38 | } from "@t3tools/shared/model"; |
| 39 | 39 | import { |
| 40 | getClaudeModelCapabilities, | |
| 41 | isClaudeUltracodeEffort, | |
| 42 | normalizeClaudeCliEffort, | |
| 43 | resolveClaudeApiModelId, | |
| 44 | resolveClaudeEffort, | |
| 45 | } from "../provider/Layers/ClaudeProvider.ts"; | |
| 40 | BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 41 | type ClaudeModelCatalog, | |
| 42 | getClaudeCatalogModelCapabilities, | |
| 43 | isClaudeCatalogUltracodeEffort, | |
| 44 | normalizeClaudeCatalogEffort, | |
| 45 | resolveClaudeCatalogApiModelId, | |
| 46 | resolveClaudeCatalogEffort, | |
| 47 | resolveClaudeModelSlug, | |
| 48 | scopeClaudeModelCatalog, | |
| 49 | } from "../provider/ClaudeModelCatalog.ts"; | |
| 46 | 50 | import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; |
| 47 | 51 | |
| 48 | 52 | const CLAUDE_TIMEOUT_MS = 180_000; |
| ... | ... | @@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla |
| 61 | 65 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 62 | 66 | claudeSettings: ClaudeSettings, |
| 63 | 67 | environment?: NodeJS.ProcessEnv, |
| 68 | modelCatalog: Effect.Effect<ClaudeModelCatalog> = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), | |
| 64 | 69 | ) { |
| 65 | 70 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 66 | 71 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 72 | const scopedModelCatalog = modelCatalog.pipe( | |
| 73 | Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), | |
| 74 | ); | |
| 67 | 75 | |
| 68 | 76 | const readStreamAsString = <E>( |
| 69 | 77 | operation: string, |
| ... | ... | @@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 121 | 129 | outputSchemaJson: S; |
| 122 | 130 | modelSelection: ModelSelection; |
| 123 | 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 | 137 | const jsonSchemaStr = yield* encodeJsonForOperation( |
| 125 | 138 | operation, |
| 126 | 139 | toJsonSchemaObject(outputSchemaJson), |
| 127 | 140 | "Failed to encode structured output schema.", |
| 128 | 141 | ); |
| 129 | const caps = getClaudeModelCapabilities(modelSelection.model); | |
| 142 | const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model); | |
| 130 | 143 | const descriptors = getProviderOptionDescriptors({ |
| 131 | 144 | caps, |
| 132 | selections: modelSelection.options, | |
| 145 | selections: resolvedModelSelection.options, | |
| 133 | 146 | }); |
| 134 | 147 | const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); |
| 135 | const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); | |
| 136 | const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); | |
| 137 | const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); | |
| 138 | const ultracode = isClaudeUltracodeEffort(resolvedEffort); | |
| 148 | const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort"); | |
| 149 | const resolvedEffort = resolveClaudeCatalogEffort( | |
| 150 | catalog, | |
| 151 | resolvedModelSelection.model, | |
| 152 | rawEffortSelection, | |
| 153 | ); | |
| 154 | const cliEffort = normalizeClaudeCatalogEffort( | |
| 155 | catalog, | |
| 156 | resolvedEffort, | |
| 157 | resolvedModelSelection.model, | |
| 158 | ); | |
| 159 | const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort); | |
| 139 | 160 | const thinkingDescriptor = findDescriptor("thinking"); |
| 140 | 161 | const fastModeDescriptor = findDescriptor("fastMode"); |
| 141 | 162 | const thinking = |
| ... | ... | @@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 166 | 187 | "--json-schema", |
| 167 | 188 | jsonSchemaStr, |
| 168 | 189 | "--model", |
| 169 | resolveClaudeApiModelId(modelSelection), | |
| 190 | resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), | |
| 170 | 191 | ...(cliEffort ? ["--effort", cliEffort] : []), |
| 171 | 192 | ...(settingsJson ? ["--settings", settingsJson] : []), |
| 172 | 193 | "--dangerously-skip-permissions", |
apps/web/src/components/chat/ModelPickerContent.tsx+3-2| ... | ... | @@ -15,7 +15,6 @@ import { |
| 15 | 15 | parseModelPickerLegacySectionKey, |
| 16 | 16 | parseModelPickerModelKey, |
| 17 | 17 | } from "./modelPickerKeys"; |
| 18 | import { isModelPickerNewModel } from "./modelPickerModelHighlights"; | |
| 19 | 18 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; |
| 20 | 19 | import { |
| 21 | 20 | Combobox, |
| ... | ... | @@ -47,6 +46,7 @@ type ModelPickerItem = { |
| 47 | 46 | name: string; |
| 48 | 47 | shortName?: string; |
| 49 | 48 | subProvider?: string; |
| 49 | badge?: "new"; | |
| 50 | 50 | instanceId: ProviderInstanceId; |
| 51 | 51 | driverKind: ProviderDriverKind; |
| 52 | 52 | instanceDisplayName: string; |
| ... | ... | @@ -256,6 +256,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 256 | 256 | name: model.name, |
| 257 | 257 | ...(model.shortName ? { shortName: model.shortName } : {}), |
| 258 | 258 | ...(model.subProvider ? { subProvider: model.subProvider } : {}), |
| 259 | ...(model.badge ? { badge: model.badge } : {}), | |
| 259 | 260 | ...(model.isLegacy ? { isLegacy: true } : {}), |
| 260 | 261 | ...(model.isUnavailable ? { isUnavailable: true } : {}), |
| 261 | 262 | instanceId, |
| ... | ... | @@ -804,7 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 804 | 805 | showProvider |
| 805 | 806 | preferShortName={!isLocked} |
| 806 | 807 | useTriggerLabel={false} |
| 807 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} | |
| 808 | showNewBadge={model.badge === "new"} | |
| 808 | 809 | unavailable={model.isUnavailable === true} |
| 809 | 810 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} |
| 810 | 811 | disabledReason={disabledReason} |
apps/web/src/components/chat/composerProviderState.test.tsx+4-3| ... | ... | @@ -291,11 +291,12 @@ describe("getComposerProviderState", () => { |
| 291 | 291 | it("validates options for a known model selected through a legacy alias", () => { |
| 292 | 292 | const state = getComposerProviderState({ |
| 293 | 293 | provider: ProviderDriverKind.make("claudeAgent"), |
| 294 | model: "opus", | |
| 294 | model: "legacy-test-model", | |
| 295 | 295 | models: [ |
| 296 | 296 | { |
| 297 | slug: "claude-opus-5", | |
| 298 | name: "Claude Opus 5", | |
| 297 | slug: "test-model", | |
| 298 | name: "Test Model", | |
| 299 | aliases: ["legacy-test-model"], | |
| 299 | 300 | isCustom: false, |
| 300 | 301 | capabilities: { |
| 301 | 302 | optionDescriptors: [ |
apps/web/src/components/chat/modelPickerModelHighlights.ts deleted-13| ... | ... | @@ -1,13 +0,0 @@ |
| 1 | import 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 | */ | |
| 7 | const NEW_MODEL_KEYS = new Set<string>([ | |
| 8 | // Example: "claudeAgent:claude-opus-4-7", | |
| 9 | ]); | |
| 10 | ||
| 11 | export 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 | 26 | name: string; |
| 27 | 27 | shortName?: string | undefined; |
| 28 | 28 | subProvider?: string | undefined; |
| 29 | badge?: "new" | undefined; | |
| 29 | 30 | isLegacy?: boolean | undefined; |
| 30 | 31 | isUnavailable?: boolean | undefined; |
| 31 | 32 | }; |
apps/web/src/modelSelection.ts+4| ... | ... | @@ -75,6 +75,8 @@ export interface AppModelOption { |
| 75 | 75 | name: string; |
| 76 | 76 | shortName?: string; |
| 77 | 77 | subProvider?: string; |
| 78 | aliases?: ReadonlyArray<string>; | |
| 79 | badge?: "new"; | |
| 78 | 80 | isCustom: boolean; |
| 79 | 81 | isDefault?: boolean; |
| 80 | 82 | isLegacy?: boolean; |
| ... | ... | @@ -109,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti |
| 109 | 111 | }; |
| 110 | 112 | if (model.shortName) option.shortName = model.shortName; |
| 111 | 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 | 116 | if (model.isDefault) option.isDefault = true; |
| 113 | 117 | if (model.isLegacy) option.isLegacy = true; |
| 114 | 118 | return option; |
apps/web/src/providerModels.test.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { | |
| 2 | ProviderDriverKind, | |
| 3 | type ModelCapabilities, | |
| 4 | type ServerProviderModel, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { getProviderModelCapabilities } from "./providerModels"; | |
| 9 | ||
| 10 | const PROVIDER = ProviderDriverKind.make("claudeAgent"); | |
| 11 | ||
| 12 | function capabilities(id: string): ModelCapabilities { | |
| 13 | return { | |
| 14 | optionDescriptors: [{ id, label: id, type: "boolean" }], | |
| 15 | }; | |
| 16 | } | |
| 17 | ||
| 18 | function 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 | ||
| 33 | describe("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 | 8 | type ServerProvider, |
| 9 | 9 | type ServerProviderModel, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 11 | import { createModelCapabilities, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 12 | 12 | |
| 13 | 13 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 14 | 14 | optionDescriptors: [], |
| ... | ... | @@ -83,9 +83,9 @@ export function getProviderModelCapabilities( |
| 83 | 83 | provider: ProviderDriverKind, |
| 84 | 84 | planModeEnabled = true, |
| 85 | 85 | ): ModelCapabilities { |
| 86 | const slug = normalizeModelSlug(model, provider); | |
| 87 | const caps = | |
| 88 | models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; | |
| 86 | const slug = resolveSelectableModel(provider, model, models); | |
| 87 | const selectedModel = models.find((candidate) => candidate.slug === slug); | |
| 88 | const caps = selectedModel?.capabilities ?? EMPTY_CAPABILITIES; | |
| 89 | 89 | if (planModeEnabled) { |
| 90 | 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 | |
| 5 | catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the | |
| 6 | last successful remote manifest. The bundle is used only when no valid remote cache exists. | |
| 7 | ||
| 8 | The top-level provider catalog is generic: models contain presentation metadata, aliases, status, | |
| 9 | an optional badge, and a reusable capability profile. The profile and model `adapter` fields are | |
| 10 | opaque until the owning provider validates them with its own allowlisted schema. | |
| 11 | ||
| 12 | Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that | |
| 13 | uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or | |
| 14 | change application code. Add or change a profile in the same JSON file only when the model exposes | |
| 15 | a capability combination that does not already exist. | |
| 16 | ||
| 17 | `currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate | |
| 18 | catalog discovery. New Claude models do not need to be added there. Codex still discovers models | |
| 19 | from its app server and uses `currentModels.codex` only as a legacy-classification overlay. | |
| 20 | ||
| 21 | Claude 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 | ||
| 29 | Changing model data does not require tests. Do not add or update tests for a model slug, display | |
| 30 | name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is | |
| 31 | configuration and is validated by its schema when imported. | |
| 32 | ||
| 33 | Add 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 | ||
| 39 | Resolver tests must use synthetic providers and model names so normal JSON edits never create test | |
| 40 | churn. |
packages/contracts/src/model.ts+1-27| ... | ... | @@ -176,33 +176,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< |
| 176 | 176 | "5.3-spark": "gpt-5.3-codex-spark", |
| 177 | 177 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 178 | 178 | }, |
| 179 | [CLAUDE_DRIVER_KIND]: { | |
| 180 | fable: "claude-fable-5-1", | |
| 181 | "fable-5.1": "claude-fable-5-1", | |
| 182 | "claude-fable-5.1": "claude-fable-5-1", | |
| 183 | opus: "claude-opus-5", | |
| 184 | "opus-5": "claude-opus-5", | |
| 185 | "claude-opus-5.0": "claude-opus-5", | |
| 186 | "claude-opus-5-0": "claude-opus-5", | |
| 187 | "opus-4.8": "claude-opus-4-8", | |
| 188 | "claude-opus-4.8": "claude-opus-4-8", | |
| 189 | "opus-4.7": "claude-opus-4-7", | |
| 190 | "claude-opus-4.7": "claude-opus-4-7", | |
| 191 | "opus-4.6": "claude-opus-4-6", | |
| 192 | "claude-opus-4.6": "claude-opus-4-6", | |
| 193 | "claude-opus-4-6-20251117": "claude-opus-4-6", | |
| 194 | sonnet: "claude-sonnet-5", | |
| 195 | "sonnet-5": "claude-sonnet-5", | |
| 196 | "claude-sonnet-5.0": "claude-sonnet-5", | |
| 197 | "claude-sonnet-5-0": "claude-sonnet-5", | |
| 198 | "sonnet-4.6": "claude-sonnet-4-6", | |
| 199 | "claude-sonnet-4.6": "claude-sonnet-4-6", | |
| 200 | "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", | |
| 201 | haiku: "claude-haiku-4-5", | |
| 202 | "haiku-4.5": "claude-haiku-4-5", | |
| 203 | "claude-haiku-4.5": "claude-haiku-4-5", | |
| 204 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", | |
| 205 | }, | |
| 179 | [CLAUDE_DRIVER_KIND]: {}, | |
| 206 | 180 | [CURSOR_DRIVER_KIND]: { |
| 207 | 181 | composer: "composer-2", |
| 208 | 182 | "composer-1.5": "composer-1.5", |
packages/contracts/src/server.ts+2| ... | ... | @@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({ |
| 66 | 66 | name: TrimmedNonEmptyString, |
| 67 | 67 | shortName: Schema.optional(TrimmedNonEmptyString), |
| 68 | 68 | subProvider: Schema.optional(TrimmedNonEmptyString), |
| 69 | aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), | |
| 70 | badge: Schema.optional(Schema.Literal("new")), | |
| 69 | 71 | isCustom: Schema.Boolean, |
| 70 | 72 | isDefault: Schema.optional(Schema.Boolean), |
| 71 | 73 | isLegacy: Schema.optional(Schema.Boolean), |
packages/shared/src/model.test.ts+1-13| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; | |
| 2 | import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | applyClaudePromptEffortPrefix, |
| ... | ... | @@ -11,8 +11,6 @@ import { |
| 11 | 11 | getProviderOptionDescriptors, |
| 12 | 12 | getProviderOptionBooleanSelectionValue, |
| 13 | 13 | getProviderOptionStringSelectionValue, |
| 14 | normalizeCustomModelSlug, | |
| 15 | normalizeModelSlug, | |
| 16 | 14 | } from "./model.ts"; |
| 17 | 15 | |
| 18 | 16 | const codexCaps: ModelCapabilities = createModelCapabilities({ |
| ... | ... | @@ -148,16 +146,6 @@ describe("descriptor helpers", () => { |
| 148 | 146 | }); |
| 149 | 147 | }); |
| 150 | 148 | |
| 151 | describe("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 | ||
| 161 | 149 | describe("applyClaudePromptEffortPrefix", () => { |
| 162 | 150 | it("keeps slash commands intact when ultrathink is selected", () => { |
| 163 | 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 | 15 | export interface SelectableModelOption { |
| 16 | 16 | slug: string; |
| 17 | 17 | name: string; |
| 18 | aliases?: ReadonlyArray<string> | undefined; | |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | export function createModelCapabilities(input: { |
| ... | ... | @@ -281,6 +282,13 @@ export function resolveSelectableModel( |
| 281 | 282 | return byName.slug; |
| 282 | 283 | } |
| 283 | 284 | |
| 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 | 292 | const normalized = normalizeModelSlug(trimmed, provider); |
| 285 | 293 | if (!normalized) { |
| 286 | 294 | return null; |