From 7a52752a15d1db1991d1c15a631aea2616724c17 Mon Sep 17 00:00:00 2001 From: vinkyli Date: Thu, 22 Oct 2026 10:51:32 +0800 Subject: [PATCH 1/2] fix(server): Claude lists the models from its modelPicker setting Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ClaudeModelPicker.test.ts | 94 +++++++++++++++++++ apps/server/src/provider/ClaudeModelPicker.ts | 88 +++++++++++++++++ apps/server/src/provider/ClaudeProvider.ts | 4 +- .../src/provider/Drivers/ClaudeSkills.ts | 2 +- .../src/provider/ProviderRegistry.test.ts | 44 +++++++++ docs/user/providers-claude.md | 7 +- 6 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/provider/ClaudeModelPicker.test.ts create mode 100644 apps/server/src/provider/ClaudeModelPicker.ts diff --git a/apps/server/src/provider/ClaudeModelPicker.test.ts b/apps/server/src/provider/ClaudeModelPicker.test.ts new file mode 100644 index 0000000000000..26fd420889268 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelPicker.test.ts @@ -0,0 +1,94 @@ +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}`, + 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' own controls", () => + Effect.gen(function* () { + const claudeSettings = yield* writeSettings( + JSON.stringify({ + modelPicker: { + options: [ + { model: "deepseek/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, + { model: "claude-other", label: "ignored for bundled models" }, + { 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", "Built-in claude-other", false], + ["kimi/kimi-k2.6", "kimi/kimi-k2.6", false], + ["claude-known", "Built-in claude-known", false], + ], + ); + assert.strictEqual(models[1], builtInModels[1]); + 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 absent = yield* writeSettings("{}"); + const missing = { homePath: `${absent.homePath}/missing` }; + + for (const claudeSettings of [malformed, absent, missing]) { + const picker = yield* readClaudeModelPicker(claudeSettings, {}); + assert.strictEqual(applyClaudeModelPicker(builtInModels, picker), builtInModels); + } + }), + ); +}); diff --git a/apps/server/src/provider/ClaudeModelPicker.ts b/apps/server/src/provider/ClaudeModelPicker.ts new file mode 100644 index 0000000000000..16edd4c945168 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelPicker.ts @@ -0,0 +1,88 @@ +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; + /** Display names keyed by model id, in picker order. */ + readonly names: ReadonlyMap; +} + +// 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, + 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 names = new Map(); + for (const option of picker.options) { + const row = Option.getOrUndefined(decodeModelPickerRow(option)); + const slug = row?.model.trim(); + if (row && slug && !names.has(slug)) names.set(slug, row.label?.trim() || slug); + } + return { replaceBuiltInOptions: picker.replaceBuiltInOptions === true, names }; + } + return undefined; +}); + +export function applyClaudeModelPicker( + builtInModels: ReadonlyArray, + picker: ClaudeModelPicker | undefined, +): ReadonlyArray { + 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.names].map( + ([slug, name]): ServerProviderModel => + builtInBySlug.get(slug) ?? { slug, name, isCustom: false, capabilities: null }, + ); + return picker.replaceBuiltInOptions + ? listed + : [...listed, ...builtInModels.filter((model) => !picker.names.has(model.slug))]; +} diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18f3b8508a04a..f22c90338b750 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -52,6 +52,7 @@ import { resolveClaudeModelsForVersion, resolveClaudeUpdateRequiredModels, } from "./ClaudeModelCatalog.ts"; +import { applyClaudeModelPicker, readClaudeModelPicker } from "./ClaudeModelPicker.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -582,8 +583,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const modelPicker = yield* readClaudeModelPicker(claudeSettings, resolvedEnvironment); const models = providerModelsFromSettings( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), + applyClaudeModelPicker(resolveClaudeModelsForVersion(modelCatalog, parsedVersion), modelPicker), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index ff26287611812..b7577eb98548e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -109,7 +109,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { }; } -function claudeManagedSettingsPath( +export function claudeManagedSettingsPath( path: Path.Path, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a278470c286d0..c0680e3c99a78 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -3,6 +3,8 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.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"; @@ -39,6 +41,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"; @@ -2287,5 +2290,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(), + ); + 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}`); + }), + ), + ), + ); }); }); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index cd9c22c542817..07b1e602f2ccb 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -110,9 +110,10 @@ If that Claude config directory has a cached Anthropic login, run `/logout` in a Claude Code session using that directory before starting the router setup. Cached login credentials can conflict with the router token. -Select the model you want in T3 Code. For an OpenRouter model outside the built-in -list, open that Claude instance in **Settings > Providers** and add its full model -ID with **Add custom model**. Then select it in the chat model picker. +Select the model you want in T3 Code. Models listed in that config directory's +`modelPicker` already appear in the picker. For an OpenRouter model outside both +the built-in list and `modelPicker`, open that Claude instance in **Settings > Providers** +and add its full model ID with **Add custom model**. Then select it in the chat model picker. `ANTHROPIC_DEFAULT_*_MODEL` variables map Claude Code aliases such as `sonnet`; they do not replace the explicit model ID selected in T3 Code. Custom models may have fewer effort, thinking, or context controls than built-in models. From e25911124b3fb3c758c8d2adf0aa7c1d410ee31c Mon Sep 17 00:00:00 2001 From: vinkyli Date: Thu, 22 Oct 2026 11:09:01 +0800 Subject: [PATCH 2/2] fix(server): picker labels rename bundled Claude models Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ClaudeModelPicker.test.ts | 21 +++++++++++----- apps/server/src/provider/ClaudeModelPicker.ts | 25 +++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelPicker.test.ts b/apps/server/src/provider/ClaudeModelPicker.test.ts index 26fd420889268..65c9da2f75f95 100644 --- a/apps/server/src/provider/ClaudeModelPicker.test.ts +++ b/apps/server/src/provider/ClaudeModelPicker.test.ts @@ -11,6 +11,7 @@ import { applyClaudeModelPicker, readClaudeModelPicker } from "./ClaudeModelPick const builtIn = (slug: string): ServerProviderModel => ({ slug, name: `Built-in ${slug}`, + shortName: `Short ${slug}`, isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [] }), }); @@ -25,14 +26,15 @@ const writeSettings = Effect.fn(function* (settings: string) { }); it.layer(NodeServices.layer)("Claude modelPicker", (it) => { - it.effect("lists picker rows first and keeps bundled models' own controls", () => + 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: "ignored for bundled models" }, + { model: "claude-other", label: "Work Claude" }, + { model: "claude-known" }, { model: "kimi/kimi-k2.6" }, { model: "deepseek/deepseek-v4-pro", label: "duplicate" }, { model: " " }, @@ -49,12 +51,14 @@ it.layer(NodeServices.layer)("Claude modelPicker", (it) => { models.map((model) => [model.slug, model.name, model.isCustom]), [ ["deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", false], - ["claude-other", "Built-in claude-other", false], - ["kimi/kimi-k2.6", "kimi/kimi-k2.6", 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], builtInModels[1]); + assert.strictEqual(models[1]?.capabilities, builtInModels[1]?.capabilities); + assert.isUndefined(models[1]?.shortName); + assert.strictEqual(models[2], builtInModels[0]); assert.isNull(models[0]?.capabilities); }), ); @@ -82,10 +86,15 @@ it.layer(NodeServices.layer)("Claude modelPicker", (it) => { 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, absent, missing]) { + for (const claudeSettings of [malformed, unusableRows, absent, missing]) { const picker = yield* readClaudeModelPicker(claudeSettings, {}); assert.strictEqual(applyClaudeModelPicker(builtInModels, picker), builtInModels); } diff --git a/apps/server/src/provider/ClaudeModelPicker.ts b/apps/server/src/provider/ClaudeModelPicker.ts index 16edd4c945168..0e51cf8be7d93 100644 --- a/apps/server/src/provider/ClaudeModelPicker.ts +++ b/apps/server/src/provider/ClaudeModelPicker.ts @@ -30,8 +30,8 @@ const decodeModelPickerRow = Schema.decodeUnknownOption(ModelPickerRow); export interface ClaudeModelPicker { readonly replaceBuiltInOptions: boolean; - /** Display names keyed by model id, in picker order. */ - readonly names: ReadonlyMap; + /** Configured labels keyed by model id, in picker order. */ + readonly labels: ReadonlyMap; } // Claude Code ignores a project checkout's `modelPicker`, and the highest source @@ -59,13 +59,15 @@ export const readClaudeModelPicker = Effect.fn("readClaudeModelPicker")(function ))?.modelPicker; if (!picker) continue; - const names = new Map(); + const labels = new Map(); for (const option of picker.options) { const row = Option.getOrUndefined(decodeModelPickerRow(option)); const slug = row?.model.trim(); - if (row && slug && !names.has(slug)) names.set(slug, row.label?.trim() || slug); + if (row && slug && !labels.has(slug)) labels.set(slug, row.label?.trim() || undefined); } - return { replaceBuiltInOptions: picker.replaceBuiltInOptions === true, names }; + return labels.size === 0 + ? undefined + : { replaceBuiltInOptions: picker.replaceBuiltInOptions === true, labels }; } return undefined; }); @@ -78,11 +80,14 @@ export function applyClaudeModelPicker( 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.names].map( - ([slug, name]): ServerProviderModel => - builtInBySlug.get(slug) ?? { slug, name, isCustom: false, capabilities: null }, - ); + 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.names.has(model.slug))]; + : [...listed, ...builtInModels.filter((model) => !picker.labels.has(model.slug))]; }