diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index a7513a593183..606292f5a3cb 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -19,19 +19,24 @@ const selection = ( describe("compileClaudeModelSelection", () => { it("compiles context, effort, and settings together", () => { - expect( - compileClaudeModelSelection( - selection("claude-opus-4-6", [ - { id: "contextWindow", value: "1m" }, - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]), - ), - ).toMatchObject({ - apiModelId: "claude-opus-4-6[1m]", - effort: "max", - settings: { fastMode: true }, - }); + expect(compileClaudeModelSelection(selection("claude-haiku-4-5-20251001", [])).apiModelId).toBe( + "claude-haiku-4-5-20251001", + ); + for (const model of ["claude-opus-4-6", "claude-opus-4-6-20261101"]) { + expect( + compileClaudeModelSelection( + selection(model, [ + { id: "contextWindow", value: "1m" }, + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), + ), + ).toMatchObject({ + apiModelId: model + "[1m]", + effort: "max", + settings: { fastMode: true }, + }); + } }); it("runs Ultracode at the chosen effort and reads the old Ultracode effort as xhigh", () => { diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index b89cffa42387..87f58067c906 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -74,44 +74,47 @@ it.effect("reports a deleted worktree's scan as finished, not pending", () => }).pipe(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]" }], - models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }], +it.effect.each(["1", true])( + "keeps the probe subprocess alive for gateway discovery flag %s", + (enabled) => + 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]" }], + models: [{ value: "gateway/claude-relay", displayName: "Relay", description: "" }], + }), + 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 fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped(); + // Claude Code also reads these from settings.json, which is where gateway users set them. + yield* fileSystem.writeFileString( + `${configDir}/settings.json`, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: "https://gateway.example", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: enabled, + CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS: 1000, + }, }), - 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 fileSystem = yield* FileSystem.FileSystem; - const configDir = yield* fileSystem.makeTempDirectoryScoped(); - // Claude Code also reads these from settings.json, which is where gateway users set them. - yield* fileSystem.writeFileString( - `${configDir}/settings.json`, - JSON.stringify({ - env: { - ANTHROPIC_BASE_URL: "https://gateway.example", - CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", - }, - }), - ); - const capabilities = yield* probeClaudeCapabilities( - yield* decodeClaudeSettings({ binaryPath: "claude" }), - { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, - ); - assert.deepEqual( - capabilities.models?.map((model) => model.value), - ["gateway/claude-relay"], - ); - assert.equal(abortSignal?.aborted, false); - yield* TestClock.adjust("3 seconds"); - assert.equal(abortSignal?.aborted, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + const capabilities = yield* probeClaudeCapabilities( + yield* decodeClaudeSettings({ binaryPath: "claude" }), + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, + ); + assert.deepEqual( + capabilities.models?.map((model) => model.value), + ["gateway/claude-relay"], + ); + assert.equal(abortSignal?.aborted, false); + yield* TestClock.adjust("1 second"); + assert.equal(abortSignal?.aborted, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index bbcc02f90132..0f5ab4bba620 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { buildServerProvider } from "@t3tools/provider-core/server/snapshotProbe"; + +import { applyClaudeGatewayModels } from "./Drivers/ClaudeGatewayModels.ts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { @@ -74,6 +77,64 @@ const catalogFromManifest = (data: ModelManifestData) => resolveClaudeModelCatalog(resolveProviderCatalog(data, CLAUDE) ?? undefined); describe("Claude model catalog", () => { + it("advertises discovered gateway models without requiring saved custom model settings", () => { + const opus = BUNDLED_CLAUDE_MODEL_CATALOG.models.find( + (entry) => entry.model.slug === "claude-opus-4-5", + )!.model; + const provider = buildServerProvider({ + presentation: { displayName: "Claude" }, + enabled: true, + checkedAt: "2026-11-01T00:00:00Z", + updateRequiredModels: [{ slug: "claude-haiku-5-5", name: "Haiku", minVersion: "2.1.293" }], + models: [ + opus, + { + slug: "claude-opus-4-5-20251101", + name: "SDK dated Opus", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + { slug: "haiku", name: "Custom Haiku", isCustom: true, capabilities: null }, + { slug: "claude-sonnet-4-6", name: "Saved Sonnet", isCustom: false, capabilities: null }, + { slug: "known", name: "Known", isCustom: false, capabilities: null }, + { slug: "omitted", name: "Omitted", isCustom: false, capabilities: null }, + { slug: "saved", name: "Saved", isCustom: true, capabilities: null }, + ], + probe: { + installed: true, + version: "2.1.316", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + const result = applyClaudeGatewayModels( + provider, + [ + { id: "known" }, + { id: "claude-opus-4-5-20251101" }, + { id: "claude-haiku-5-5-20261101" }, + { id: "claude-haiku-4-5-20251001", display_name: "Gateway Haiku" }, + { id: "haiku" }, + ], + BUNDLED_CLAUDE_MODEL_CATALOG, + ["saved", "haiku", "claude-sonnet-4-6"], + ); + assert.deepStrictEqual(result.models, [ + { slug: "known", name: "Known", isCustom: false, capabilities: null }, + { ...opus, slug: "claude-opus-4-5-20251101" }, + { + slug: "claude-haiku-4-5-20251001", + name: "Gateway Haiku", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + { slug: "haiku", name: "Custom Haiku", isCustom: true, capabilities: null }, + { slug: "claude-sonnet-4-6", name: "Saved Sonnet", isCustom: false, capabilities: null }, + { slug: "saved", name: "Saved", isCustom: true, capabilities: null }, + ]); + assert.deepStrictEqual(result.updateRequiredModels, provider.updateRequiredModels); + }); + it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => { const source = manifest(); const profile = source.providers!.claudeAgent!.profiles.synthetic!; @@ -161,12 +222,26 @@ describe("Claude model catalog", () => { aliases: ["claude-synthetic-next"], status: "current", }, - ...base.providers!.claudeAgent!.models, + ...base.providers!.claudeAgent!.models.map((model) => ({ + ...model, + aliases: [...(model.aliases ?? []), "claude-synthetic-next-20251001"], + })), ], }, }, }; const catalog = catalogFromManifest(input); + assert.strictEqual( + resolveClaudeModelSlug(catalog, "claude-synthetic-next-20251001"), + "claude-synthetic-next", + ); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-synthetic-next-20251001", + }), + "claude-synthetic-next-20251001[large]", + ); assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); assert.strictEqual( resolveClaudeModelSlug(catalog, "claude-synthetic-next"), @@ -289,6 +364,7 @@ describe("Claude model catalog", () => { "us.anthropic.claude-opus-4-6-v1", ); assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5"), "haiku"); + assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5-20251001"), "haiku"); assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-5-5"), "claude-haiku-5-5"); // First-party ids already are what Claude Code runs, even when an alias points elsewhere. diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index e3d4ea4397c0..dc71741253c9 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -233,7 +233,10 @@ export function resolveClaudeCatalogModel( catalog.models.find((entry) => entry.model.slug === value) ?? catalog.models.find((entry) => entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()), - ) + ) ?? + (/^claude-.+-\d{8}$/.test(value) + ? resolveClaudeCatalogModel(catalog, value.replace(/-\d{8}$/, "")) + : undefined) ); } @@ -546,7 +549,11 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = entry?.apiModel ?? entry?.model.slug ?? modelSelection.model; + const slug = + entry?.apiModel ?? + (/^claude-.+-\d{8}$/.test(modelSelection.model) + ? modelSelection.model + : (entry?.model.slug ?? modelSelection.model)); const descriptors = getProviderOptionDescriptors({ caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, selections: modelSelection.options, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d75b1cad8640..946b143f5d74 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -23,6 +23,7 @@ import { } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { ProviderDriverError } from "../Errors.ts"; import { claudeUsageReader } from "./claudeUsage.ts"; +import { applyClaudeGatewayModels, discoverClaudeGatewayModels } from "./ClaudeGatewayModels.ts"; import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; import * as ClaudeResetCredits from "../claudeResetCredits.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; @@ -280,6 +281,25 @@ export const ClaudeDriver: ProviderDriver + Effect.gen(function* () { + if (!provider.enabled || !provider.installed) return provider; + const models = yield* discoverClaudeGatewayModels( + effectiveConfig, + processEnv, + stateDir, + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + return models === undefined + ? provider + : applyClaudeGatewayModels( + provider, + models, + catalog, + effectiveConfig.customModels, + ); + }), + ), ), ), Effect.tap((provider) => diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts new file mode 100644 index 000000000000..7e1127432f8f --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -0,0 +1,183 @@ +import type { ClaudeSettings, CustomModelSetting } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { ChildProcess } from "effect/process"; +import { readCustomModelEntries } from "@t3tools/shared/model"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + spawnAndCollect, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import { readClaudeConnectionSettings } from "./ClaudeSkills.ts"; +import { makeClaudeEnvironment } from "./ClaudeHome.ts"; +import { resolveClaudeReportedModelSlug, type ClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; + +const GatewayModels = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + display_name: Schema.optional(Schema.String), + }), + ), + has_more: Schema.optional(Schema.Boolean), + last_id: Schema.optional(Schema.NullOr(Schema.String)), +}); +type GatewayModel = (typeof GatewayModels.Type)["data"][number]; + +class ClaudeGatewayHeaderError extends Schema.TaggedError()( + "ClaudeGatewayHeaderError", + { header: Schema.String, cause: Schema.Defect() }, +) {} + +const CLOUD_PROVIDER_FLAGS = [ + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", + "CLAUDE_CODE_USE_MANTLE", +] as const; + +export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModels")( + function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, + ) { + const claudeEnvironment = yield* makeClaudeEnvironment(config, environment); + const { env: settingsEnv, apiKeyHelper } = yield* readClaudeConnectionSettings( + config, + cwd, + claudeEnvironment, + ); + const env = { ...claudeEnvironment, ...settingsEnv }; + // Cloud backends ignore ANTHROPIC_BASE_URL, even when it remains in the environment. + if ( + CLOUD_PROVIDER_FLAGS.some((name) => + ["1", "true", "yes", "on"].includes(env[name]?.trim().toLowerCase() ?? ""), + ) + ) + return undefined; + const endpoint = URL.parse(env.ANTHROPIC_BASE_URL?.trim() ?? ""); + if ( + !endpoint || + !["http:", "https:"].includes(endpoint.protocol) || + endpoint.hostname === "api.anthropic.com" + ) + return undefined; + + endpoint.pathname = `${endpoint.pathname.replace(/\/$/, "").replace(/\/v1$/, "")}/v1/models`; + endpoint.search = ""; + endpoint.hash = ""; + endpoint.searchParams.set("limit", "1000"); + + const headers = new Headers({ "anthropic-version": "2023-06-01" }); + const setHeader = (name: string, value: string) => + Effect.try({ + try: () => headers.set(name, value), + catch: (cause) => new ClaudeGatewayHeaderError({ header: name, cause }), + }); + const token = env.ANTHROPIC_AUTH_TOKEN?.trim(); + if (token) { + yield* setHeader("authorization", `Bearer ${token}`); + } else { + let key = env.ANTHROPIC_API_KEY?.trim(); + if (!key && apiKeyHelper?.trim()) { + const platform = yield* HostProcess.Platform; + const command = + platform === "win32" + ? ChildProcess.make(env.ComSpec || "cmd.exe", ["/d", "/s", "/c", apiKeyHelper], { + env, + ...(cwd ? { cwd } : {}), + }) + : ChildProcess.make("sh", ["-c", apiKeyHelper], { env, ...(cwd ? { cwd } : {}) }); + const result = yield* spawnAndCollect("Claude API key helper", command); + if (result.code !== 0) return undefined; + key = result.stdout.trim(); + } + if (key) yield* setHeader("x-api-key", key); + } + for (const line of env.ANTHROPIC_CUSTOM_HEADERS?.split("\n") ?? []) { + const colon = line.indexOf(":"); + if (colon >= 0) yield* setHeader(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); + } + + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const models = new Map(); + const cursors = new Set(); + while (true) { + const response = yield* client + .execute( + HttpClientRequest.get(endpoint.toString()).pipe( + HttpClientRequest.setHeaders(Object.fromEntries(headers)), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + const page = yield* HttpClientResponse.schemaBodyJson(GatewayModels)(response); + for (const model of page.data) { + if (!model.id.trim()) return undefined; + models.set(model.id, model); + } + if (!page.has_more) return [...models.values()]; + if (!page.last_id || cursors.has(page.last_id)) return undefined; + cursors.add(page.last_id); + endpoint.searchParams.set("after_id", page.last_id); + } + }, + Effect.timeout("5 seconds"), + Effect.tapError(() => + Effect.logDebug("Claude gateway model discovery failed, using the bundled catalog"), + ), + // Gateways without a models endpoint retain the ordinary Claude catalog. + Effect.orElseSucceed(() => undefined), +); + +export function applyClaudeGatewayModels( + provider: ServerProviderDraft, + models: ReadonlyArray, + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray = [], +): ServerProviderDraft { + const saved = new Set(readCustomModelEntries(customModels).map((model) => model.slug)); + const known = new Map(provider.models.map((model) => [model.slug, model])); + const unavailable = new Set(provider.updateRequiredModels?.map((model) => model.slug)); + const offered = new Set(models.map((model) => model.id)); + const offeredCanonical = new Set( + models.map((model) => resolveClaudeReportedModelSlug(catalog, model.id)), + ); + return { + ...provider, + models: [ + ...models + .filter( + (model) => + known.get(model.id)?.isCustom || + !unavailable.has(resolveClaudeReportedModelSlug(catalog, model.id)), + ) + .map((model) => { + const exact = known.get(model.id); + const canonical = known.get(resolveClaudeReportedModelSlug(catalog, model.id)); + const metadata = exact?.isCustom ? exact : (canonical ?? exact); + return metadata + ? { ...metadata, slug: model.id } + : { + slug: model.id, + name: model.display_name || model.id, + isCustom: false, + capabilities: { optionDescriptors: [] }, + }; + }), + ...provider.models.filter( + (model) => (model.isCustom || saved.has(model.slug)) && !offered.has(model.slug), + ), + ], + ...(provider.updateRequiredModels + ? { + updateRequiredModels: provider.updateRequiredModels.filter((model) => + offeredCanonical.has(model.slug), + ), + } + : {}), + }; +} diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 349294baa8c8..d4427f244f27 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -5,7 +5,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { discoverClaudeSkills as discoverSkills } from "./ClaudeSkills.ts"; +import { + discoverClaudeSkills as discoverSkills, + readClaudeConnectionSettings, +} from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; // Windows makes the managed directory fixture-controlled without reading host policy or skills. @@ -29,6 +32,32 @@ const writeSkill = Effect.fn(function* ( }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("coerces connection settings environment values like Claude Code", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" }); + const env = { + ANTHROPIC_BASE_URL: "https://gateway.example", + CLAUDE_CODE_USE_BEDROCK: false, + CLAUDE_CODE_USE_VERTEX: true, + CLAUDE_CODE_USE_FOUNDRY: 1, + }; + yield* fs.writeFileString(path.join(configDir, "settings.json"), JSON.stringify({ env })); + const settings = yield* readClaudeConnectionSettings( + { homePath: configDir }, + undefined, + {}, + ).pipe(Effect.provideService(HostProcess.Platform, "win32")); + assert.deepStrictEqual(settings.env, { + ANTHROPIC_BASE_URL: "https://gateway.example", + CLAUDE_CODE_USE_BEDROCK: "false", + CLAUDE_CODE_USE_VERTEX: "true", + CLAUDE_CODE_USE_FOUNDRY: "1", + }); + }), + ); + it.effect("discovers enabled plugin skills with manifest names and ignores skill overrides", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 4fdd3565d11b..d081962579a9 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -646,6 +646,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); +const ConnectionSettings = fromLenientJson( + Schema.Struct({ + env: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + apiKeyHelper: Schema.optional(Schema.Unknown), + }), +); const SettingsEnv = Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String), CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), @@ -657,18 +663,21 @@ const SettingsEnv = Schema.Struct({ const decodeSettingsEnv = Schema.decodeUnknownEffect( fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })), ); +const decodeConnectionSettings = Schema.decodeUnknownEffect(ConnectionSettings); -/** The `env` values T3 Code reads from Claude's user and managed settings; later files win. */ -export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( +/** Claude Code applies settings `env` over its inherited process environment. */ +export const readClaudeConnectionSettings = Effect.fn("readClaudeConnectionSettings")(function* ( config: Pick, cwd: string | undefined, environment: NodeJS.ProcessEnv, -): Effect.fn.Return { +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd); + const env: Record = {}; const settingsEnv: typeof SettingsEnv.Type = {}; + let apiKeyHelper: string | undefined; for (const settingsPath of claudeSettingsPaths( path, configDirPath, @@ -680,10 +689,30 @@ export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function .readFileString(settingsPath) .pipe(Effect.orElseSucceed(() => undefined)); if (contents === undefined) continue; - const parsed = yield* decodeSettingsEnv(contents).pipe(Effect.orElseSucceed(() => undefined)); - Object.assign(settingsEnv, parsed?.env); + const parsed = yield* decodeConnectionSettings(contents).pipe( + Effect.orElseSucceed(() => undefined), + ); + Object.assign( + settingsEnv, + yield* decodeSettingsEnv(contents).pipe( + Effect.orElseSucceed(() => undefined), + Effect.map((settings) => settings?.env), + ), + ); + for (const [name, value] of Object.entries(parsed?.env ?? {})) { + env[name] = String(value); + } + if (typeof parsed?.apiKeyHelper === "string") apiKeyHelper = parsed.apiKeyHelper; } - return settingsEnv; + return { env, apiKeyHelper, settingsEnv }; +}); + +export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( + config: Pick, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +) { + return (yield* readClaudeConnectionSettings(config, cwd, environment)).settingsEnv; }); /** The host of an `ANTHROPIC_BASE_URL` that is not Anthropic's own API. */ diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index c42d78b237d4..87594eb1b1cc 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1145,7 +1145,7 @@ it.layer( ); }); - it("does not bring back models the installed CLI is too old to run", () => { + it("does not bring back models omitted by a completed Claude probe", () => { const pendingProvider = { instanceId: ProviderInstanceId.make("claudeAgent"), driver: ProviderDriverKind.make("claudeAgent"), @@ -1158,6 +1158,7 @@ it.layer( models: [ { slug: "claude-old", name: "Old", isCustom: false, capabilities: null }, { slug: "claude-next", name: "Next", isCustom: false, capabilities: null }, + { slug: "claude-removed", name: "Removed", isCustom: false, capabilities: null }, ], slashCommands: [], skills: [], @@ -1176,6 +1177,13 @@ it.layer( ProviderRegistry.mergeProviderSnapshot(pendingProvider, probedProvider).models, [...probedProvider.models], ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(pendingProvider, { + ...probedProvider, + status: "warning", + }).models, + [...probedProvider.models], + ); }); it("retains ACP Registry models while discovery has not completed", () => { diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 8a7ff0ac7fd3..c1d9aba718d4 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -218,6 +218,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => return !(provider.installed && provider.status === "ready"); } + if (provider.driver === ProviderDriverKind.make("claudeAgent")) { + return !provider.installed; + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); const isOpenCode = provider.driver === ProviderDriverKind.make("opencode"); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 756a068926a6..bd58768f7647 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -225,7 +225,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), + resolveClaudeCatalogApiModelId(catalog, modelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), "--settings", settingsJson, diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 5d4295a94263..841dce197d61 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -151,6 +151,11 @@ requirements, use the ## Other routers +A custom `ANTHROPIC_BASE_URL` also supplies the model list through `/v1/models`. +T3 refreshes it using Claude Code's configured credentials and keeps its built-in +catalog if the gateway cannot provide a list. Custom models added in T3 stay listed +when the gateway omits them. + A local router uses an ordinary Claude provider instance. Give it a separate config directory and put the router's endpoint and credential variables in that instance's **Environment variables**. The router must run where the environment