diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index 4a34d98a796bd..5b75b8b2434c0 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -15,6 +15,9 @@ const selection = ( describe("compileClaudeModelSelection", () => { it("compiles context, effort, and settings together", () => { + expect(compileClaudeModelSelection(selection("claude-haiku-4-5-20251001", [])).apiModelId).toBe( + "claude-haiku-4-5-20251001", + ); expect( compileClaudeModelSelection( selection("claude-opus-4-6", [ diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index bf3c9318877bb..151dea9ceb9a7 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -47,44 +47,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 2092429f6d556..2edd5d2baa1a2 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,8 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import { 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 { + BUNDLED_CLAUDE_MODEL_CATALOG, formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, @@ -69,6 +73,50 @@ 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 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: [ + { 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-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 }, + { + 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 }, + ]); + }); + it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => { const source = manifest(); const profile = source.providers!.claudeAgent!.profiles.synthetic!; @@ -149,12 +197,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"), diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index d4fba4253866b..67569c6ed8d0f 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -320,7 +320,9 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = entry?.model.slug ?? modelSelection.model; + const slug = /^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 c0a9c5698d92c..be6fd2b84d862 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"; @@ -248,6 +249,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 0000000000000..a82427f55f61d --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -0,0 +1,182 @@ +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 { resolveClaudeModelSlug, 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) => resolveClaudeModelSlug(catalog, model.id)), + ); + return { + ...provider, + models: [ + ...models + .filter( + (model) => + known.get(model.id)?.isCustom || + !unavailable.has(resolveClaudeModelSlug(catalog, model.id)), + ) + .map((model) => { + const metadata = + known.get(model.id) ?? known.get(resolveClaudeModelSlug(catalog, model.id)); + 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.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 0d62109cce828..6555de3e01e43 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -433,6 +433,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), @@ -443,18 +449,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, @@ -466,10 +475,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 ?? {})) { + if (typeof value === "string") env[name] = 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; }); // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index e318a3c79a66e..a4c1a7d58b60e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -793,7 +793,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"), @@ -806,6 +806,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: [], @@ -824,6 +825,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 fb6aeaac26b63..439cf204095f6 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -193,6 +193,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"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 51acced2745e5..ff2582d5d9992 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -208,7 +208,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 1eef74a864261..7254c147efa2d 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -134,6 +134,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