From c1fdd59d5d3d8573e9833bdf9dd1ac8cda597ed3 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sun, 27 Sep 2026 01:17:26 -0300 Subject: [PATCH 1/4] feat(server): list Claude models reported by Claude Code Gateway-discovered models (ANTHROPIC_BASE_URL with CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1) now appear in the Claude picker. The capability probe keeps init.models and appends ids the bundled catalog does not know as built-in rows. Configured custom models with the same slug keep their settings-owned row. Closes #13875 --- apps/server/src/provider/ClaudeProvider.ts | 69 +++++++++++++++++-- .../src/provider/ProviderRegistry.test.ts | 50 ++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fef..fdb168472522 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -2,6 +2,7 @@ import { type ClaudeSettings, type ModelCapabilities, type ServerProvider, + type ServerProviderModel, type ServerProviderSlashCommand, type ServerProviderResetCredits, } from "@t3tools/contracts"; @@ -13,10 +14,11 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { query as claudeQuery, + type ModelInfo as ClaudeModelInfo, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKControlGetUsageResponse, @@ -273,6 +275,8 @@ type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + /** Models Claude Code reports, including gateway-discovered ones. */ + readonly models?: ReadonlyArray; /** * Subscription windows from the SDK's `get_usage` control request, or * `undefined` when the request itself failed. Absent windows on an @@ -344,6 +348,53 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +/** Strip Claude Code's context-window suffix, e.g. `opus[1m]` -> `opus`. */ +function stripClaudeModelSuffix(value: string): string { + return value.replace(/\[[^\]]*\]$/, "").trim(); +} + +/** + * Append models Claude Code reports that the bundled catalog does not know, + * such as ids from an Anthropic-compatible gateway with model discovery on. + * Catalog models, including ones the installed version cannot run, are never + * duplicated and keep T3's descriptors. A custom model with the same slug keeps + * its settings-owned row. Discovered ids carry no capabilities + * because the adapter resolves options from the catalog only, and are passed + * through to Claude Code verbatim at runtime. + */ +function mergeClaudeReportedModels( + models: ReadonlyArray, + modelCatalog: ClaudeModelCatalog, + customModels: ClaudeSettings["customModels"], + reportedModels: ReadonlyArray | undefined, +): ReadonlyArray { + if (!reportedModels?.length) return models; + const known = new Set(); + for (const { model } of modelCatalog.models) { + known.add(model.slug.toLowerCase()); + for (const alias of model.aliases ?? []) known.add(alias.toLowerCase()); + } + for (const entry of readCustomModelEntries(customModels)) known.add(entry.slug.toLowerCase()); + const discovered: Array = []; + for (const info of reportedModels) { + const slug = info.value.trim(); + // `default` is Claude Code's pointer at its own default model, not a model. + if (!slug || slug === "default") continue; + const ids = [slug, info.resolvedModel ?? ""].map((id) => + stripClaudeModelSuffix(id).toLowerCase(), + ); + if (ids.some((id) => id && known.has(id))) continue; + known.add(slug.toLowerCase()); + discovered.push({ + slug, + name: info.displayName.trim() || slug, + isCustom: false, + capabilities: DEFAULT_CLAUDE_MODEL_CAPABILITIES, + }); + } + return discovered.length ? [...models, ...discovered] : models; +} + function waitForAbortSignal(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve(); @@ -432,6 +483,7 @@ const probeClaudeCapabilities = ( apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + ...(init.models?.length ? { models: init.models } : {}), ...(usage ? { usage } : {}), } satisfies ClaudeCapabilitiesProbe; }), @@ -592,17 +644,22 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const models = providerModelsFromSettings( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion); const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; + const models = providerModelsFromSettings( + mergeClaudeReportedModels( + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), + modelCatalog, + claudeSettings.customModels, + capabilities?.models, + ), + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d0c1f348a736..ddf4a7dad330 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2,6 +2,7 @@ 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 type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import { describe, it, assert, expect } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -169,6 +170,7 @@ type TestClaudeCapabilities = { readonly apiKeySource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly models?: ReadonlyArray; }; function claudeCapabilities(overrides: Partial = {}) { @@ -3179,6 +3181,54 @@ it.layer( ), ); + it.effect("adds models Claude Code reports that the catalog does not know", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { + ...defaultClaudeSettings, + customModels: [ + { slug: "my-custom", name: "My Custom" }, + { slug: "gateway/kimi-3", name: "Kimi (mine)" }, + ], + }, + claudeCapabilities({ + models: [ + { value: "default", displayName: "Default", description: "" }, + { + value: "opus[1m]", + resolvedModel: "claude-opus-5[1m]", + displayName: "Opus", + description: "", + }, + { value: "sonnet", displayName: "Sonnet", description: "" }, + { value: "gateway/glm-5", displayName: "GLM 5", description: "" }, + { value: "gateway/kimi-3", displayName: "Kimi 3", description: "" }, + ], + }), + ); + const reported = status.models.filter( + (model) => !model.isCustom && !model.slug.startsWith("claude-"), + ); + assert.deepStrictEqual( + reported.map((model) => [model.slug, model.name]), + [["gateway/glm-5", "GLM 5"]], + ); + assert.deepStrictEqual(reported[0]?.capabilities, { optionDescriptors: [] }); + assert.deepStrictEqual( + status.models.filter((model) => model.isCustom).map((model) => model.name), + ["My Custom", "Kimi (mine)"], + ); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { // The capability probe resolves for a logged-out CLI, so `tokenSource: From 053c56c97af81bff6963ee526207b9c7ac33c3aa Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Thu, 1 Oct 2026 13:52:53 -0300 Subject: [PATCH 2/4] fix(server): discovered Claude models keep their effort options Claude Code reports supportsEffort and supportedEffortLevels for gateway-discovered models, but T3 gave them empty capabilities, so the picker hid the effort menu. Their descriptors now come from that report, and the driver feeds the last probe's models into the adapter catalog so the chosen effort reaches Claude Code at runtime. --- .../Adapters/ClaudeAdapterV2.test.ts | 35 ++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 ++++-- .../src/provider/ClaudeModelCatalog.test.ts | 45 ++++++++++ .../server/src/provider/ClaudeModelCatalog.ts | 85 +++++++++++++++++++ apps/server/src/provider/ClaudeProvider.ts | 59 ++----------- .../src/provider/Drivers/ClaudeDriver.ts | 24 +++++- .../src/provider/ProviderRegistry.test.ts | 25 +++++- 7 files changed, 234 insertions(+), 63 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9c4e336815f7..76164bd921a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -62,6 +62,10 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + withClaudeReportedModels, +} from "../../provider/ClaudeModelCatalog.ts"; import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -198,6 +202,37 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.include(options.settings, { alwaysThinkingEnabled: false }); }); + it("sends the effort chosen for a model Claude Code reported", () => { + const modelSelection = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "anthropic/claude-gateway", + options: [{ id: "effort", value: "max" }], + }; + const modelCatalog = withClaudeReportedModels( + BUNDLED_CLAUDE_MODEL_CATALOG, + [], + [ + { + value: "anthropic/claude-gateway", + displayName: "Gateway Claude", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "high", "max"], + }, + ], + ); + const base = { nativeThreadId: "gateway-thread", resume: false, cwd: "/workspace" }; + + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + ...base, + modelSelection, + modelCatalog, + }); + assert.equal(options.model, "anthropic/claude-gateway"); + assert.equal(options.effort, "max"); + assert.isUndefined(ClaudeAdapterV2.makeClaudeQueryOptions({ ...base, modelSelection }).effort); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c7b1fb0d5819..1637a9a86501 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,6 +97,7 @@ import { } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, resolveClaudeCatalogContextWindowTokens, } from "../../provider/ClaudeModelCatalog.ts"; import { @@ -807,6 +808,8 @@ export const layerQueryRunner: Layer.Layer< export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; + /** Resolves model options; defaults to the bundled catalog. */ + readonly modelCatalog?: ClaudeModelCatalog; readonly nativeThreadId: string; readonly resume: boolean; readonly resumeSessionAt?: string; @@ -831,7 +834,7 @@ export function makeClaudeQueryOptions(input: { readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"]; readonly allowDangerouslySkipPermissions?: boolean; }): ClaudeAgentSdkQueryOptions { - const compiledSelection = compileClaudeModelSelection(input.modelSelection); + const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog); const { "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, @@ -3019,6 +3022,12 @@ export interface ClaudeAdapterV2Options { readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + /** + * The instance's model catalog, read per query so models Claude Code + * reports (such as gateway-discovered ones) resolve their options. + * Defaults to the bundled catalog. + */ + readonly modelCatalog?: Effect.Effect; /** Sink for wake-turn continuation requests; defaults to dropping them. */ readonly continuationRequests?: { readonly offer: ( @@ -3035,6 +3044,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; + const modelCatalog = adapterOptions.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG); // Re-scan on every send: skills are added and switched off mid-session, and // the scan is a few directory reads. A skill switched off via skillOverrides, @@ -7271,7 +7281,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); + const catalog = yield* modelCatalog; + const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); // A continuation prompts nothing: it drains output the live process @@ -7341,6 +7352,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, + modelCatalog: catalog, nativeThreadId, resume: shouldResume, ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), @@ -7566,7 +7578,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection).promptEffort, + compileClaudeModelSelection(turnInput.modelSelection, yield* modelCatalog) + .promptEffort, ), attachments: turnInput.message.attachments, attachmentsDir, @@ -7775,7 +7788,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const userMessage = yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(currentTurn.input.modelSelection).promptEffort, + compileClaudeModelSelection(currentTurn.input.modelSelection, yield* modelCatalog) + .promptEffort, ), attachments: turnInput.message.attachments, priority: "now", @@ -8172,7 +8186,7 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 40fe4dec8991..85d099a6fdd6 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -14,6 +14,7 @@ import { resolveClaudeModelSlug, resolveClaudeUpdateRequiredModels, scopeClaudeModelCatalog, + withClaudeReportedModels, } from "./ClaudeModelCatalog.ts"; /** @@ -253,4 +254,48 @@ describe("Claude model catalog", () => { ["claude-synthetic-next", "claude-custom-tuned"], ); }); + + it("resolves effort for models Claude Code reports from its own capabilities", () => { + const catalog = withClaudeReportedModels( + catalogFromManifest(manifest()), + [], + [ + { value: "synthetic", displayName: "Synthetic", description: "", supportsEffort: true }, + { + value: "anthropic/claude-gateway", + displayName: "Gateway Claude", + description: "From gateway", + supportsEffort: true, + supportedEffortLevels: ["low", "high", "max"], + supportsFastMode: true, + }, + { value: "claude-gateway-plain", displayName: "Plain", description: "From gateway" }, + ], + ); + + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next", "anthropic/claude-gateway", "claude-gateway-plain"], + ); + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "anthropic/claude-gateway", "max"), + "max", + ); + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "anthropic/claude-gateway", undefined), + "high", + ); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "anthropic/claude-gateway", + options: [{ id: "effort", value: "max" }], + }), + "anthropic/claude-gateway", + ); + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "claude-gateway-plain", "max"), + undefined, + ); + }); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 659dc188d4a9..b0b6a70928a6 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -6,6 +6,7 @@ import { type ServerProviderModel, type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; +import type { ModelInfo as ClaudeModelInfo } from "@anthropic-ai/claude-agent-sdk"; import * as Option from "effect/Option"; import { getModelSelectionStringOptionValue, @@ -128,6 +129,90 @@ export function scopeClaudeModelCatalog( return { models: [...builtInModels, ...customCatalogModels] }; } +const CLAUDE_EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +/** Strip Claude Code's context-window suffix, e.g. `opus[1m]` -> `opus`. */ +function stripClaudeModelSuffix(value: string): string { + return value.replace(/\[[^\]]*\]$/, "").trim(); +} + +/** Option descriptors for a model from what Claude Code reports it supports. */ +function claudeReportedModelCapabilities(info: ClaudeModelInfo): ModelCapabilities { + const efforts = info.supportsEffort ? (info.supportedEffortLevels ?? []) : []; + const defaultEffort = efforts.includes("high") ? "high" : undefined; + return { + optionDescriptors: [ + ...(efforts.length + ? [ + { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: efforts.map((id) => ({ + id, + label: CLAUDE_EFFORT_LABELS[id] ?? id, + ...(id === defaultEffort ? { isDefault: true } : {}), + })), + }, + ] + : []), + ...(info.supportsFastMode + ? [{ id: "fastMode", label: "Fast Mode", type: "boolean" as const }] + : []), + ], + }; +} + +/** + * Append models Claude Code reports that the catalog does not know, such as + * ids from an Anthropic-compatible gateway with model discovery on. Catalog + * models, including ones the installed version cannot run, are never + * duplicated and keep T3's descriptors. A custom model with the same slug keeps + * its settings-owned row. Discovered models take their options from what + * Claude Code reports and are passed to it verbatim at runtime. + */ +export function withClaudeReportedModels( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, + reportedModels: ReadonlyArray | undefined, +): ClaudeModelCatalog { + if (!reportedModels?.length) return catalog; + const known = new Set(); + for (const { model } of catalog.models) { + known.add(model.slug.toLowerCase()); + for (const alias of model.aliases ?? []) known.add(alias.toLowerCase()); + } + for (const entry of readCustomModelEntries(customModels)) known.add(entry.slug.toLowerCase()); + const discovered: Array = []; + for (const info of reportedModels) { + const slug = info.value.trim(); + // `default` is Claude Code's pointer at its own default model, not a model. + if (!slug || slug === "default") continue; + const ids = [slug, info.resolvedModel ?? ""].map((id) => + stripClaudeModelSuffix(id).toLowerCase(), + ); + if (ids.some((id) => id && known.has(id))) continue; + known.add(slug.toLowerCase()); + discovered.push({ + model: { + slug, + name: info.displayName.trim() || slug, + isCustom: false, + capabilities: claudeReportedModelCapabilities(info), + }, + runtime: {}, + compatibility: {}, + }); + } + return discovered.length ? { models: [...catalog.models, ...discovered] } : catalog; +} + function resolveClaudeCatalogModel( catalog: ClaudeModelCatalog, slugOrAlias: string | null | undefined, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index fdb168472522..f40b27eac4ae 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -2,7 +2,6 @@ import { type ClaudeSettings, type ModelCapabilities, type ServerProvider, - type ServerProviderModel, type ServerProviderSlashCommand, type ServerProviderResetCredits, } from "@t3tools/contracts"; @@ -14,7 +13,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { query as claudeQuery, @@ -52,6 +51,7 @@ import { formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, resolveClaudeUpdateRequiredModels, + withClaudeReportedModels, } from "./ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -348,53 +348,6 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } -/** Strip Claude Code's context-window suffix, e.g. `opus[1m]` -> `opus`. */ -function stripClaudeModelSuffix(value: string): string { - return value.replace(/\[[^\]]*\]$/, "").trim(); -} - -/** - * Append models Claude Code reports that the bundled catalog does not know, - * such as ids from an Anthropic-compatible gateway with model discovery on. - * Catalog models, including ones the installed version cannot run, are never - * duplicated and keep T3's descriptors. A custom model with the same slug keeps - * its settings-owned row. Discovered ids carry no capabilities - * because the adapter resolves options from the catalog only, and are passed - * through to Claude Code verbatim at runtime. - */ -function mergeClaudeReportedModels( - models: ReadonlyArray, - modelCatalog: ClaudeModelCatalog, - customModels: ClaudeSettings["customModels"], - reportedModels: ReadonlyArray | undefined, -): ReadonlyArray { - if (!reportedModels?.length) return models; - const known = new Set(); - for (const { model } of modelCatalog.models) { - known.add(model.slug.toLowerCase()); - for (const alias of model.aliases ?? []) known.add(alias.toLowerCase()); - } - for (const entry of readCustomModelEntries(customModels)) known.add(entry.slug.toLowerCase()); - const discovered: Array = []; - for (const info of reportedModels) { - const slug = info.value.trim(); - // `default` is Claude Code's pointer at its own default model, not a model. - if (!slug || slug === "default") continue; - const ids = [slug, info.resolvedModel ?? ""].map((id) => - stripClaudeModelSuffix(id).toLowerCase(), - ); - if (ids.some((id) => id && known.has(id))) continue; - known.add(slug.toLowerCase()); - discovered.push({ - slug, - name: info.displayName.trim() || slug, - isCustom: false, - capabilities: DEFAULT_CLAUDE_MODEL_CAPABILITIES, - }); - } - return discovered.length ? [...models, ...discovered] : models; -} - function waitForAbortSignal(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve(); @@ -651,11 +604,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const models = providerModelsFromSettings( - mergeClaudeReportedModels( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), - modelCatalog, - claudeSettings.customModels, - capabilities?.models, + resolveClaudeModelsForVersion( + withClaudeReportedModels(modelCatalog, claudeSettings.customModels, capabilities?.models), + parsedVersion, ), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f7935bb8d481..2afddd7e7369 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -12,6 +12,7 @@ * * @module provider/Drivers/ClaudeDriver */ +import type { ModelInfo as ClaudeModelInfo } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; @@ -19,6 +20,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; @@ -45,7 +47,7 @@ import { } from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; -import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; +import { resolveClaudeModelCatalog, withClaudeReportedModels } from "../ClaudeModelCatalog.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -166,6 +168,15 @@ export const ClaudeDriver: ProviderDriver = { continuationGroupKey, }); + // Models from the last successful capabilities probe, so sessions can + // resolve options for gateway-discovered models the manifest lacks. + const reportedModels = yield* Ref.make>([]); + const sessionModelCatalog = Effect.all([modelCatalog, Ref.get(reportedModels)]).pipe( + Effect.map(([catalog, models]) => + withClaudeReportedModels(catalog, effectiveConfig.customModels, models), + ), + ); + const scopedLimitNames = yield* makeClaudeScopedLimitNames; const orchestrationAdapter = yield* createClaudeAdapterV2( { @@ -176,7 +187,11 @@ export const ClaudeDriver: ProviderDriver = { enabled, config, }, - { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, + { + scopedLimitNames, + modelCatalog: sessionModelCatalog, + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + }, ).pipe( Effect.mapError( (cause) => @@ -191,7 +206,7 @@ export const ClaudeDriver: ProviderDriver = { const textGeneration = yield* makeClaudeTextGeneration( effectiveConfig, processEnv, - modelCatalog, + sessionModelCatalog, ); // Per-instance capabilities cache: keyed on binary + resolved HOME so @@ -201,6 +216,9 @@ export const ClaudeDriver: ProviderDriver = { timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.tap((probe) => + probe ? Ref.set(reportedModels, probe.models ?? []) : Effect.void, + ), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index ddf4a7dad330..a52fc3215a9d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -3202,6 +3202,13 @@ it.layer( }, { value: "sonnet", displayName: "Sonnet", description: "" }, { value: "gateway/glm-5", displayName: "GLM 5", description: "" }, + { + value: "gateway/claude-relay", + displayName: "Claude Relay", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, { value: "gateway/kimi-3", displayName: "Kimi 3", description: "" }, ], }), @@ -3211,9 +3218,25 @@ it.layer( ); assert.deepStrictEqual( reported.map((model) => [model.slug, model.name]), - [["gateway/glm-5", "GLM 5"]], + [ + ["gateway/glm-5", "GLM 5"], + ["gateway/claude-relay", "Claude Relay"], + ], ); assert.deepStrictEqual(reported[0]?.capabilities, { optionDescriptors: [] }); + assert.deepStrictEqual(reported[1]?.capabilities, { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + }); assert.deepStrictEqual( status.models.filter((model) => model.isCustom).map((model) => model.name), ["My Custom", "Kimi (mine)"], From 3f1652b1caa80d24293bd8acacbb75e84bf920cf Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Thu, 1 Oct 2026 14:18:19 -0300 Subject: [PATCH 3/4] fix(server): gateway Claude models show up from a cold config dir Claude Code reports gateway models from its cache file and refreshes that cache in the background after startup. The capability probe aborted the subprocess right after initialization, so with a gateway slower than a few hundred milliseconds discovery never finished, no cache was written, and the models never reached the picker. When gateway discovery is enabled, the probe now returns at once but keeps the subprocess alive for Claude Code's discovery timeout before aborting it, so the next probe reports the gateway's models. --- .../provider/ClaudeCapabilitiesProbe.test.ts | 30 ++++++++++++++ apps/server/src/provider/ClaudeProvider.ts | 41 +++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index d617eb999fa6..833dd5a2fa1a 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -353,6 +353,36 @@ it.effect("preserves initialized capabilities when optional usage times out", () }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("keeps the probe subprocess alive while gateway model discovery can finish", () => + Effect.gen(function* () { + let abortSignal: AbortSignal | undefined; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + abortSignal = options?.abortController?.signal; + return { + initializationResult: async () => ({ + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => + new Promise((_, reject) => reject(new Error("usage unavailable"))), + } as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const capabilities = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: "claude" }), + { + ...process.env, + ANTHROPIC_BASE_URL: "https://gateway.example", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + }, + ); + assert.isDefined(capabilities); + assert.equal(abortSignal?.aborted, false); + yield* TestClock.adjust("3 seconds"); + assert.equal(abortSignal?.aborted, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("asks for usage without the local transcript scan", () => Effect.gen(function* () { let usageOptions: unknown; diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index f40b27eac4ae..b157867cce43 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -357,6 +357,25 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } +/** Claude Code's default for `CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS`. */ +const CLAUDE_GATEWAY_DISCOVERY_TIMEOUT_MS = 3_000; + +/** + * How long a probe subprocess must outlive initialization so gateway model + * discovery can finish. Claude Code reports gateway models from its on-disk + * cache and refreshes that cache in the background after startup, so aborting + * right after initialization means a cold config dir never learns them. + */ +function claudeGatewayDiscoveryGraceMs(environment: NodeJS.ProcessEnv): number { + const enabled = environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY?.trim(); + if (!enabled || enabled === "0" || enabled.toLowerCase() === "false") return 0; + if (!environment.ANTHROPIC_BASE_URL?.trim()) return 0; + const configured = Number(environment.CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 + ? configured + : CLAUDE_GATEWAY_DISCOVERY_TIMEOUT_MS; +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. @@ -365,7 +384,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * message is ever written to the subprocess stdin. This means the Claude * Code subprocess completes its local initialization IPC (returning * account info and slash commands) but never starts an API request to - * Anthropic. We read the init data and then abort the subprocess. + * Anthropic. We read the init data and then abort the subprocess, after a + * grace period when gateway model discovery is enabled. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -377,8 +397,13 @@ const probeClaudeCapabilities = ( includeUsage = true, ) => { const abort = new AbortController(); + let abortGraceMs = 0; + const abortProbe = Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + abortGraceMs = claudeGatewayDiscoveryGraceMs(claudeEnvironment); const executablePath = yield* resolveClaudeSdkExecutablePath( claudeSettings.binaryPath, claudeEnvironment, @@ -441,10 +466,18 @@ const probeClaudeCapabilities = ( } satisfies ClaudeCapabilitiesProbe; }), ), + // Return the capabilities right away, but let a gateway discovery that is + // still in flight write Claude Code's cache so the next probe reports it. Effect.ensuring( - Effect.sync(() => { - if (!abort.signal.aborted) abort.abort(); - }), + Effect.suspend(() => + abortGraceMs > 0 + ? Effect.sleep(abortGraceMs).pipe( + Effect.andThen(abortProbe), + Effect.forkDetach, + Effect.asVoid, + ) + : abortProbe, + ), ), Effect.result, Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), From bb0830886dca8338f85e24a273809594c0599c84 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Wed, 7 Oct 2026 15:11:13 -0300 Subject: [PATCH 4/4] fix(server): suffixed Claude models reported twice show up once Discovered models were deduped against the catalog without their context-window suffix but remembered with it, so gateway/model[1m] followed by gateway/model, or the same id repeated, produced duplicate rows. Remember the same normalized ids the lookup checks. --- .../server/src/provider/ClaudeModelCatalog.test.ts | 14 ++++++++++++++ apps/server/src/provider/ClaudeModelCatalog.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 85d099a6fdd6..cff8f505e8f8 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -298,4 +298,18 @@ describe("Claude model catalog", () => { undefined, ); }); + + it("lists a reported model once regardless of context-window suffix or order", () => { + const reported = (value: string) => ({ value, displayName: "Gateway", description: "" }); + const catalog = withClaudeReportedModels( + catalogFromManifest(manifest()), + [], + [reported("gateway/model[1m]"), reported("gateway/model"), reported("gateway/model[1m]")], + ); + + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next", "gateway/model[1m]"], + ); + }); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index b0b6a70928a6..7371c80523d3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -198,7 +198,7 @@ export function withClaudeReportedModels( stripClaudeModelSuffix(id).toLowerCase(), ); if (ids.some((id) => id && known.has(id))) continue; - known.add(slug.toLowerCase()); + for (const id of ids) if (id) known.add(id); discovered.push({ model: { slug,