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/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/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 40fe4dec8991..cff8f505e8f8 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,62 @@ 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, + ); + }); + + 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 659dc188d4a9..7371c80523d3 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; + for (const id of ids) if (id) known.add(id); + 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 553d3d2e5fef..b157867cce43 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -17,6 +17,7 @@ import { createModelCapabilities } 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, @@ -50,6 +51,7 @@ import { formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, resolveClaudeUpdateRequiredModels, + withClaudeReportedModels, } from "./ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -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 @@ -353,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. @@ -361,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. @@ -373,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, @@ -432,14 +461,23 @@ const probeClaudeCapabilities = ( apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + ...(init.models?.length ? { models: init.models } : {}), ...(usage ? { usage } : {}), } 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)), @@ -592,17 +630,20 @@ 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( + resolveClaudeModelsForVersion( + withClaudeReportedModels(modelCatalog, claudeSettings.customModels, capabilities?.models), + parsedVersion, + ), + 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/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 d0c1f348a736..a52fc3215a9d 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,77 @@ 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/claude-relay", + displayName: "Claude Relay", + description: "", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, + { 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"], + ["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)"], + ); + }).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: