Repository navigation
fix(server): Claude lists the models from its modelPicker setting #23301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7a52752
e259111
bed1fab
48440a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import { assert, it } from "@effect/vitest"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
| import type { ServerProviderModel } from "@t3tools/contracts"; | ||
| import { createModelCapabilities } from "@t3tools/shared/model"; | ||
|
|
||
| import { applyClaudeModelPicker, readClaudeModelPicker } from "./ClaudeModelPicker.ts"; | ||
|
|
||
| const builtIn = (slug: string): ServerProviderModel => ({ | ||
| slug, | ||
| name: `Built-in ${slug}`, | ||
| shortName: `Short ${slug}`, | ||
| isCustom: false, | ||
| capabilities: createModelCapabilities({ optionDescriptors: [] }), | ||
| }); | ||
| const builtInModels = [builtIn("claude-known"), builtIn("claude-other")]; | ||
|
|
||
| const writeSettings = Effect.fn(function* (settings: string) { | ||
| const fs = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const configDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-picker-" }); | ||
| yield* fs.writeFileString(path.join(configDir, "settings.json"), settings); | ||
| return { homePath: configDir }; | ||
| }); | ||
|
|
||
| it.layer(NodeServices.layer)("Claude modelPicker", (it) => { | ||
| it.effect("lists picker rows first and keeps bundled models' controls under their label", () => | ||
| Effect.gen(function* () { | ||
| const claudeSettings = yield* writeSettings( | ||
| JSON.stringify({ | ||
| modelPicker: { | ||
| options: [ | ||
| { model: "deepseek/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, | ||
| { model: "claude-other", label: "Work Claude" }, | ||
| { model: "claude-known" }, | ||
| { model: "kimi/kimi-k2.6" }, | ||
| { model: "deepseek/deepseek-v4-pro", label: "duplicate" }, | ||
| { model: " " }, | ||
| { label: "no model" }, | ||
| ], | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const picker = yield* readClaudeModelPicker(claudeSettings, {}); | ||
| const models = applyClaudeModelPicker(builtInModels, picker); | ||
|
|
||
| assert.deepStrictEqual( | ||
| models.map((model) => [model.slug, model.name, model.isCustom]), | ||
| [ | ||
| ["deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", false], | ||
| ["claude-other", "Work Claude", false], | ||
| ["claude-known", "Built-in claude-known", false], | ||
| ["kimi/kimi-k2.6", "kimi/kimi-k2.6", false], | ||
| ], | ||
| ); | ||
| assert.strictEqual(models[1]?.capabilities, builtInModels[1]?.capabilities); | ||
| assert.isUndefined(models[1]?.shortName); | ||
| assert.strictEqual(models[2], builtInModels[0]); | ||
| assert.isNull(models[0]?.capabilities); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("shows only the picker rows when replaceBuiltInOptions is set", () => | ||
| Effect.gen(function* () { | ||
| const claudeSettings = yield* writeSettings( | ||
| JSON.stringify({ | ||
| modelPicker: { | ||
| replaceBuiltInOptions: true, | ||
| options: [{ model: "kimi/kimi-k2.6", label: "Kimi K2.6" }], | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const picker = yield* readClaudeModelPicker(claudeSettings, {}); | ||
|
|
||
| assert.deepStrictEqual( | ||
| applyClaudeModelPicker(builtInModels, picker).map((model) => model.slug), | ||
| ["kimi/kimi-k2.6"], | ||
| ); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("leaves the built-in list alone without a usable modelPicker", () => | ||
| Effect.gen(function* () { | ||
| const malformed = yield* writeSettings("{ modelPicker: { options: 'nope' }, }"); | ||
| const unusableRows = yield* writeSettings( | ||
| JSON.stringify({ | ||
| modelPicker: { replaceBuiltInOptions: true, options: [{ model: " " }, { label: "x" }] }, | ||
| }), | ||
| ); | ||
| const absent = yield* writeSettings("{}"); | ||
| const missing = { homePath: `${absent.homePath}/missing` }; | ||
|
|
||
| for (const claudeSettings of [malformed, unusableRows, absent, missing]) { | ||
| const picker = yield* readClaudeModelPicker(claudeSettings, {}); | ||
| assert.strictEqual(applyClaudeModelPicker(builtInModels, picker), builtInModels); | ||
| } | ||
| }), | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import type { ClaudeSettings, ServerProviderModel } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Path from "effect/Path"; | ||
| import * as Schema from "effect/Schema"; | ||
| import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
|
|
||
| import { resolveClaudeHomePath } from "./Drivers/ClaudeHome.ts"; | ||
| import { claudeManagedSettingsPath } from "./Drivers/ClaudeSkills.ts"; | ||
|
|
||
| const ModelPickerSettings = fromLenientJson( | ||
| Schema.Struct({ | ||
| modelPicker: Schema.optional( | ||
| Schema.Struct({ | ||
| options: Schema.Array(Schema.Unknown), | ||
| replaceBuiltInOptions: Schema.optional(Schema.Boolean), | ||
| }), | ||
| ), | ||
| }), | ||
| ); | ||
| const ModelPickerRow = Schema.Struct({ | ||
| model: Schema.String, | ||
| label: Schema.optional(Schema.String), | ||
| }); | ||
|
|
||
| const decodeModelPickerSettings = Schema.decodeUnknownEffect(ModelPickerSettings); | ||
| const decodeModelPickerRow = Schema.decodeUnknownOption(ModelPickerRow); | ||
|
|
||
| export interface ClaudeModelPicker { | ||
| readonly replaceBuiltInOptions: boolean; | ||
| /** Configured labels keyed by model id, in picker order. */ | ||
| readonly labels: ReadonlyMap<string, string | undefined>; | ||
| } | ||
|
|
||
| // Claude Code ignores a project checkout's `modelPicker`, and the highest source | ||
| // that defines one replaces the rest, so the first hit wins and nothing merges. | ||
| export const readClaudeModelPicker = Effect.fn("readClaudeModelPicker")(function* ( | ||
| claudeSettings: Pick<ClaudeSettings, "homePath">, | ||
| environment: NodeJS.ProcessEnv, | ||
| ) { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const platform = yield* HostProcess.Platform; | ||
| const configDir = yield* resolveClaudeHomePath(claudeSettings, environment); | ||
| const managedPath = claudeManagedSettingsPath(path, platform, environment); | ||
|
|
||
| for (const settingsPath of [ | ||
| ...(managedPath ? [managedPath] : []), | ||
| path.join(configDir, "settings.json"), | ||
| ]) { | ||
| const contents = yield* fileSystem | ||
| .readFileString(settingsPath) | ||
| .pipe(Effect.orElseSucceed(() => undefined)); | ||
| if (contents === undefined) continue; | ||
| const picker = (yield* decodeModelPickerSettings(contents).pipe( | ||
| Effect.orElseSucceed(() => undefined), | ||
| ))?.modelPicker; | ||
| if (!picker) continue; | ||
|
|
||
| const labels = new Map<string, string | undefined>(); | ||
| for (const option of picker.options) { | ||
| const row = Option.getOrUndefined(decodeModelPickerRow(option)); | ||
| const slug = row?.model.trim(); | ||
| if (row && slug && !labels.has(slug)) labels.set(slug, row.label?.trim() || undefined); | ||
| } | ||
| return labels.size === 0 | ||
| ? undefined | ||
| : { replaceBuiltInOptions: picker.replaceBuiltInOptions === true, labels }; | ||
| } | ||
| return undefined; | ||
| }); | ||
|
|
||
| export function applyClaudeModelPicker( | ||
| builtInModels: ReadonlyArray<ServerProviderModel>, | ||
| picker: ClaudeModelPicker | undefined, | ||
| ): ReadonlyArray<ServerProviderModel> { | ||
| if (!picker) return builtInModels; | ||
| const builtInBySlug = new Map(builtInModels.map((model) => [model.slug, model])); | ||
| // Rows naming a bundled model keep its effort and context controls. Any other | ||
| // id goes to Claude Code verbatim with no controls, like a custom model. | ||
| const listed = [...picker.labels].map(([slug, label]): ServerProviderModel => { | ||
| const builtIn = builtInBySlug.get(slug); | ||
| if (!builtIn) return { slug, name: label ?? slug, isCustom: false, capabilities: null }; | ||
| if (!label) return builtIn; | ||
| const { shortName: _shortName, ...rest } = builtIn; | ||
| return { ...rest, name: label }; | ||
| }); | ||
| return picker.replaceBuiltInOptions | ||
| ? listed | ||
| : [...listed, ...builtInModels.filter((model) => !picker.labels.has(model.slug))]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import * as CodexInstallation from "./CodexInstallation.ts"; | |
| import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; | ||
| import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
| import { describe, it, assert, expect } from "@effect/vitest"; | ||
| import * as DateTime from "effect/DateTime"; | ||
| import * as Deferred from "effect/Deferred"; | ||
|
|
@@ -40,6 +42,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
|
|
||
| import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | ||
| import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | ||
| import { BUNDLED_CLAUDE_MODEL_CATALOG } from "./ClaudeModelCatalog.ts"; | ||
| import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| import * as AntigravityInstallation from "./AntigravityInstallation.ts"; | ||
| import * as ModelManifest from "./ModelManifest.ts"; | ||
|
|
@@ -2407,5 +2410,46 @@ it.layer( | |
| ), | ||
| ), | ||
| ); | ||
|
|
||
| it.effect("offers the models from Claude Code's modelPicker ahead of the catalog", () => | ||
| Effect.gen(function* () { | ||
| const fs = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const configDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-status-" }); | ||
| yield* fs.writeFileString( | ||
| path.join(configDir, "settings.json"), | ||
| JSON.stringify({ | ||
| modelPicker: { | ||
| options: [ | ||
| { model: "deepseek/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, | ||
| { model: "kimi/kimi-k2.6", label: "Kimi K2.6" }, | ||
| ], | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const status = yield* checkClaudeProviderStatus( | ||
| { ...defaultClaudeSettings, homePath: configDir }, | ||
| claudeCapabilities(), | ||
| ); | ||
|
Comment on lines
+2431
to
+2434
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isolate the managed-settings lookup in this test. If the test host has a managed-settings file with
|
||
| assert.deepStrictEqual( | ||
| status.models.slice(0, 2).map((model) => [model.slug, model.name]), | ||
| [ | ||
| ["deepseek/deepseek-v4-pro", "DeepSeek V4 Pro"], | ||
| ["kimi/kimi-k2.6", "Kimi K2.6"], | ||
| ], | ||
| ); | ||
| assert.strictEqual(status.models[2]?.slug, "claude-opus-5-5"); | ||
| assert.strictEqual(status.models.length, BUNDLED_CLAUDE_MODEL_CATALOG.models.length + 2); | ||
| }).pipe( | ||
| Effect.provide( | ||
| layerMockSpawner((args) => { | ||
| const joined = args.join(" "); | ||
| if (joined === "--version") return { stdout: "2.1.296\n", stderr: "", code: 0 }; | ||
| throw new Error(`Unexpected args: ${joined}`); | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve configured picker labels after custom-model overrides.
If
customModelscontains the same slug with a different name,providerModelsFromSettingsreplaces themodelPickerlabel. This affects users who previously entered that model manually: the configured row appears first, but not under its configured label. Apply the picker label after the custom-model name override, or define an explicit precedence that preserves the intended display name.