From 220ced24e8c75abdc3c6bb1be5db93d53b4dfdf9 Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Sat, 31 Oct 2026 16:05:57 -0400 Subject: [PATCH 01/11] fix(claude): discover models from configured gateways --- .../src/provider/Drivers/ClaudeDriver.ts | 12 ++ .../provider/Drivers/ClaudeGatewayModels.ts | 124 ++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 40 ++++-- docs/user/providers-claude.md | 4 + 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeGatewayModels.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c0a9c5698d92c..650de3647a710 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"; @@ -255,6 +256,17 @@ export const ClaudeDriver: ProviderDriver, + environment: NodeJS.ProcessEnv, + cwd?: string, + ) { + const claudeEnvironment = yield* makeClaudeEnvironment(config, environment); + const { env: settingsEnv, apiKeyHelper } = yield* readClaudeConnectionSettings( + config, + cwd, + claudeEnvironment, + ); + const env = { ...claudeEnvironment, ...settingsEnv }; + 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: Record = { "anthropic-version": "2023-06-01" }; + if (env.ANTHROPIC_AUTH_TOKEN?.trim()) { + headers.authorization = `Bearer ${env.ANTHROPIC_AUTH_TOKEN.trim()}`; + } 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) headers["x-api-key"] = key; + } + + 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(headers)), + ); + 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"), + // Gateways without a models endpoint retain the ordinary Claude catalog. + Effect.orElseSucceed(() => undefined), +); + +export function applyClaudeGatewayModels( + provider: ServerProviderDraft, + models: ReadonlyArray, +): ServerProviderDraft { + 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)); + return { + ...provider, + models: models + .filter((model) => !unavailable.has(model.id)) + .map( + (model) => + known.get(model.id) ?? { + slug: model.id, + name: model.display_name || model.id, + isCustom: true, + capabilities: { optionDescriptors: [] }, + }, + ), + ...(provider.updateRequiredModels + ? { + updateRequiredModels: provider.updateRequiredModels.filter((model) => + offered.has(model.slug), + ), + } + : {}), + }; +} diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index c73385374fbc0..9be8fb5afd8ae 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -428,25 +428,26 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -const SettingsEnv = Schema.Struct({ - ANTHROPIC_BASE_URL: Schema.optional(Schema.String), - CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), -}); -const decodeSettingsEnv = Schema.decodeUnknownEffect( - fromLenientJson(Schema.Struct({ env: Schema.optional(SettingsEnv) })), +const ConnectionSettings = fromLenientJson( + Schema.Struct({ + env: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + apiKeyHelper: Schema.optional(Schema.Unknown), + }), ); +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 settingsEnv: typeof SettingsEnv.Type = {}; + const env: Record = {}; + let apiKeyHelper: string | undefined; for (const settingsPath of claudeSettingsPaths( path, configDirPath, @@ -458,10 +459,23 @@ 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), + ); + 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 }; +}); + +export const readClaudeSettingsEnv = Effect.fn("readClaudeSettingsEnv")(function* ( + config: Pick, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +) { + return (yield* readClaudeConnectionSettings(config, cwd, environment)).env; }); // Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 0ed918a9d0580..bca6a93edb3ca 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -130,6 +130,10 @@ 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. + 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 From dc7694d9c5f1f420e54b2a7bb019b2b90d4a96ad Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Sat, 31 Oct 2026 16:17:12 -0400 Subject: [PATCH 02/11] fix(claude): match gateway request configuration --- .../provider/Drivers/ClaudeGatewayModels.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index 56dad9334aa36..5cd14357c1cb6 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -23,6 +23,15 @@ const GatewayModels = Schema.Struct({ }); type GatewayModel = (typeof GatewayModels.Type)["data"][number]; +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, @@ -36,6 +45,13 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel 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 || @@ -49,9 +65,9 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel endpoint.hash = ""; endpoint.searchParams.set("limit", "1000"); - const headers: Record = { "anthropic-version": "2023-06-01" }; + const headers = new Headers({ "anthropic-version": "2023-06-01" }); if (env.ANTHROPIC_AUTH_TOKEN?.trim()) { - headers.authorization = `Bearer ${env.ANTHROPIC_AUTH_TOKEN.trim()}`; + headers.set("authorization", `Bearer ${env.ANTHROPIC_AUTH_TOKEN.trim()}`); } else { let key = env.ANTHROPIC_API_KEY?.trim(); if (!key && apiKeyHelper?.trim()) { @@ -67,7 +83,11 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel if (result.code !== 0) return undefined; key = result.stdout.trim(); } - if (key) headers["x-api-key"] = key; + if (key) headers.set("x-api-key", key); + } + for (const line of env.ANTHROPIC_CUSTOM_HEADERS?.split("\n") ?? []) { + const colon = line.indexOf(":"); + if (colon >= 0) headers.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); } const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); @@ -75,7 +95,9 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel const cursors = new Set(); while (true) { const response = yield* client.execute( - HttpClientRequest.get(endpoint.toString()).pipe(HttpClientRequest.setHeaders(headers)), + HttpClientRequest.get(endpoint.toString()).pipe( + HttpClientRequest.setHeaders(Object.fromEntries(headers)), + ), ); const page = yield* HttpClientResponse.schemaBodyJson(GatewayModels)(response); for (const model of page.data) { From 002f837c8297cf77944fb05e6b59cba99a950bc2 Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Sat, 31 Oct 2026 16:46:57 -0400 Subject: [PATCH 03/11] fix(claude): retain custom models and handle invalid catalog headers --- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../provider/Drivers/ClaudeGatewayModels.ts | 37 +++++++++++-------- docs/user/providers-claude.md | 3 +- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 650de3647a710..ad5010fc76627 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -256,8 +256,8 @@ export const ClaudeDriver: ProviderDriver + Effect.gen(function* () { if (!provider.enabled || !provider.installed) return provider; const models = yield* discoverClaudeGatewayModels( effectiveConfig, diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index 5cd14357c1cb6..12af681bb3f2a 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -66,8 +66,9 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel endpoint.searchParams.set("limit", "1000"); const headers = new Headers({ "anthropic-version": "2023-06-01" }); - if (env.ANTHROPIC_AUTH_TOKEN?.trim()) { - headers.set("authorization", `Bearer ${env.ANTHROPIC_AUTH_TOKEN.trim()}`); + const token = env.ANTHROPIC_AUTH_TOKEN?.trim(); + if (token) { + yield* Effect.try(() => headers.set("authorization", `Bearer ${token}`)); } else { let key = env.ANTHROPIC_API_KEY?.trim(); if (!key && apiKeyHelper?.trim()) { @@ -83,11 +84,14 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel if (result.code !== 0) return undefined; key = result.stdout.trim(); } - if (key) headers.set("x-api-key", key); + if (key) yield* Effect.try(() => headers.set("x-api-key", key)); } for (const line of env.ANTHROPIC_CUSTOM_HEADERS?.split("\n") ?? []) { const colon = line.indexOf(":"); - if (colon >= 0) headers.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); + if (colon >= 0) + yield* Effect.try(() => + headers.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim()), + ); } const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); @@ -124,17 +128,20 @@ export function applyClaudeGatewayModels( const offered = new Set(models.map((model) => model.id)); return { ...provider, - models: models - .filter((model) => !unavailable.has(model.id)) - .map( - (model) => - known.get(model.id) ?? { - slug: model.id, - name: model.display_name || model.id, - isCustom: true, - capabilities: { optionDescriptors: [] }, - }, - ), + models: [ + ...models + .filter((model) => !unavailable.has(model.id)) + .map( + (model) => + known.get(model.id) ?? { + slug: model.id, + name: model.display_name || model.id, + isCustom: true, + capabilities: { optionDescriptors: [] }, + }, + ), + ...provider.models.filter((model) => model.isCustom && !offered.has(model.slug)), + ], ...(provider.updateRequiredModels ? { updateRequiredModels: provider.updateRequiredModels.filter((model) => diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index bca6a93edb3ca..2d12292c16e44 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -132,7 +132,8 @@ requirements, use the 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. +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 From 93287a77ee9f852a01ff6f29bd91d61aa10e4eae Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:45:10 +0000 Subject: [PATCH 04/11] fix(claude): publish authoritative gateway models to clients --- .../src/provider/ClaudeModelCatalog.test.ts | 36 +++++++++++++++++++ .../provider/Drivers/ClaudeGatewayModels.ts | 2 +- .../src/provider/ProviderRegistry.test.ts | 3 +- apps/server/src/provider/ProviderRegistry.ts | 4 +++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 2092429f6d556..aa762a69a17ee 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 { 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 { @@ -69,6 +72,39 @@ 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", + models: [ + { 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" }, + ]); + 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: "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!; diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index 12af681bb3f2a..fade095a13478 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -136,7 +136,7 @@ export function applyClaudeGatewayModels( known.get(model.id) ?? { slug: model.id, name: model.display_name || model.id, - isCustom: true, + isCustom: false, capabilities: { optionDescriptors: [] }, }, ), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 93fec081ebb9c..34297b7f5c420 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -790,7 +790,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"), @@ -803,6 +803,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: [], diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 76f2723cd9d4f..8a50e9da86e6b 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 && provider.status === "ready"); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { From f6601bb9679ce028b059816a8aeeded485fbd449 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:50:32 +0000 Subject: [PATCH 05/11] fix(claude): preserve dated gateway model identifiers --- apps/server/src/claudeModelOptions.test.ts | 3 +++ apps/server/src/provider/ClaudeModelCatalog.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) 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/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index f67628efb83a3..7f04155cc06f3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -259,7 +259,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, From 8551db6111767007789e19e7f03dc08bf2d9344c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 01:56:55 +0000 Subject: [PATCH 06/11] fix(claude): preserve gateway models across helper and health paths --- apps/server/src/provider/ClaudeModelCatalog.test.ts | 9 ++++++++- apps/server/src/provider/ClaudeModelCatalog.ts | 5 ++--- apps/server/src/provider/ProviderRegistry.test.ts | 7 +++++++ apps/server/src/provider/ProviderRegistry.ts | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index aa762a69a17ee..1103a9e095690 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -185,12 +185,19 @@ 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-20251001", + ); 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 7f04155cc06f3..9d24ca77b7636 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -129,6 +129,7 @@ function resolveClaudeCatalogModel( } export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { + if (/^claude-.+-\d{8}$/.test(slugOrAlias)) return slugOrAlias; return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } @@ -259,9 +260,7 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = /^claude-.+-\d{8}$/.test(modelSelection.model) - ? modelSelection.model - : (entry?.model.slug ?? modelSelection.model); + const slug = resolveClaudeModelSlug(catalog, modelSelection.model); const descriptors = getProviderOptionDescriptors({ caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, selections: modelSelection.options, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 34297b7f5c420..a2139d509ea6f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -822,6 +822,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 8a50e9da86e6b..f8baf8ce15f22 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -194,7 +194,7 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => } if (provider.driver === ProviderDriverKind.make("claudeAgent")) { - return !(provider.installed && provider.status === "ready"); + return !provider.installed; } const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); From 942f26018469f9d732284e212b6d9f1e4158aaeb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 02:05:04 +0000 Subject: [PATCH 07/11] fix(claude): preserve canonical subagent model metadata --- apps/server/src/provider/ClaudeModelCatalog.test.ts | 9 ++++++++- apps/server/src/provider/ClaudeModelCatalog.ts | 5 +++-- apps/server/src/textGeneration/ClaudeTextGeneration.ts | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 1103a9e095690..8036791d80fa9 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -196,7 +196,14 @@ describe("Claude model catalog", () => { const catalog = catalogFromManifest(input); assert.strictEqual( resolveClaudeModelSlug(catalog, "claude-synthetic-next-20251001"), - "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( diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 9d24ca77b7636..7f04155cc06f3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -129,7 +129,6 @@ function resolveClaudeCatalogModel( } export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { - if (/^claude-.+-\d{8}$/.test(slugOrAlias)) return slugOrAlias; return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } @@ -260,7 +259,9 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = resolveClaudeModelSlug(catalog, 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/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, From 05fdd674a864cca0ce897a8f1bd390865cfdcad7 Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Tue, 3 Nov 2026 10:43:06 -0500 Subject: [PATCH 08/11] fix(claude): preserve catalog metadata for gateway aliases --- .../src/provider/ClaudeModelCatalog.test.ts | 10 ++++--- .../src/provider/Drivers/ClaudeDriver.ts | 25 +++++++++------- .../provider/Drivers/ClaudeGatewayModels.ts | 30 ++++++++++++------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 8036791d80fa9..bc9e7a7e18a51 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -6,6 +6,7 @@ import { applyClaudeGatewayModels } from "./Drivers/ClaudeGatewayModels.ts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { + BUNDLED_CLAUDE_MODEL_CATALOG, formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, @@ -89,10 +90,11 @@ describe("Claude model catalog", () => { auth: { status: "authenticated" }, }, }); - const result = applyClaudeGatewayModels(provider, [ - { id: "known" }, - { id: "claude-haiku-4-5-20251001", display_name: "Gateway Haiku" }, - ]); + const result = applyClaudeGatewayModels( + provider, + [{ id: "known" }, { id: "claude-haiku-4-5-20251001", display_name: "Gateway Haiku" }], + BUNDLED_CLAUDE_MODEL_CATALOG, + ); assert.deepStrictEqual(result.models, [ { slug: "known", name: "Known", isCustom: false, capabilities: null }, { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ad5010fc76627..10c6e86b8636e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -249,6 +249,20 @@ 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); + }), + ), ), ), Effect.tap((provider) => @@ -256,17 +270,6 @@ 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); - }), - ), Effect.map(stampIdentity), ), ), diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index fade095a13478..dcf27920ce5d0 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -10,6 +10,7 @@ import { } 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( @@ -122,30 +123,37 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel export function applyClaudeGatewayModels( provider: ServerProviderDraft, models: ReadonlyArray, + catalog: ClaudeModelCatalog, ): ServerProviderDraft { 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) => !unavailable.has(model.id)) - .map( - (model) => - known.get(model.id) ?? { - slug: model.id, - name: model.display_name || model.id, - isCustom: false, - capabilities: { optionDescriptors: [] }, - }, - ), + .filter((model) => !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 && !offered.has(model.slug)), ], ...(provider.updateRequiredModels ? { updateRequiredModels: provider.updateRequiredModels.filter((model) => - offered.has(model.slug), + offeredCanonical.has(model.slug), ), } : {}), From b8e6af23ecf4fad76bcbdb8b07ec02f2fe48d32a Mon Sep 17 00:00:00 2001 From: greyxp1 Date: Tue, 3 Nov 2026 10:56:57 -0500 Subject: [PATCH 09/11] fix(claude): reject gateway catalog redirects --- .../src/provider/Drivers/ClaudeGatewayModels.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index dcf27920ce5d0..1fd83514b4848 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -1,7 +1,7 @@ import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { @@ -99,11 +99,13 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel 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)), - ), - ); + 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; @@ -116,6 +118,9 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel } }, 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), ); From e00d2b97e2a706d75036e48c1013ad811ad6daf9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 02:14:12 +0000 Subject: [PATCH 10/11] fix(claude): retain saved gateway models across catalog refresh --- .../provider/ClaudeCapabilitiesProbe.test.ts | 81 ++++++++++--------- .../src/provider/ClaudeModelCatalog.test.ts | 12 ++- .../src/provider/Drivers/ClaudeDriver.ts | 7 +- .../provider/Drivers/ClaudeGatewayModels.ts | 15 +++- .../src/provider/Drivers/ClaudeSkills.ts | 7 +- 5 files changed, 75 insertions(+), 47 deletions(-) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index d71421debc727..9cd3bce31d832 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -46,44 +46,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: process.env.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: process.env.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 bc9e7a7e18a51..2edd5d2baa1a2 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -78,7 +78,10 @@ describe("Claude model catalog", () => { 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 }, @@ -92,8 +95,13 @@ describe("Claude model catalog", () => { }); const result = applyClaudeGatewayModels( provider, - [{ id: "known" }, { id: "claude-haiku-4-5-20251001", display_name: "Gateway Haiku" }], + [ + { 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 }, @@ -103,6 +111,8 @@ describe("Claude model catalog", () => { 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 }, ]); }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 10c6e86b8636e..be6fd2b84d862 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -260,7 +260,12 @@ export const ClaudeDriver: ProviderDriver, 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)); @@ -140,7 +143,11 @@ export function applyClaudeGatewayModels( ...provider, models: [ ...models - .filter((model) => !unavailable.has(resolveClaudeModelSlug(catalog, model.id))) + .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)); @@ -153,7 +160,9 @@ export function applyClaudeGatewayModels( capabilities: { optionDescriptors: [] }, }; }), - ...provider.models.filter((model) => model.isCustom && !offered.has(model.slug)), + ...provider.models.filter( + (model) => (model.isCustom || saved.has(model.slug)) && !offered.has(model.slug), + ), ], ...(provider.updateRequiredModels ? { diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 82b29951f4672..6555de3e01e43 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -480,9 +480,10 @@ export const readClaudeConnectionSettings = Effect.fn("readClaudeConnectionSetti ); Object.assign( settingsEnv, - yield* decodeSettingsEnv(contents) - .pipe(Effect.orElseSucceed(() => undefined)) - .pipe(Effect.map((settings) => settings?.env)), + 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; From 74dfb53d821988167a546f10ef6b9c8022871050 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:25:11 +0000 Subject: [PATCH 11/11] fix(claude): map gateway header failures to a typed error --- .../provider/Drivers/ClaudeGatewayModels.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts index 5df7e3b52e4c8..a82427f55f61d 100644 --- a/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts +++ b/apps/server/src/provider/Drivers/ClaudeGatewayModels.ts @@ -25,6 +25,11 @@ const GatewayModels = Schema.Struct({ }); 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", @@ -68,9 +73,14 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel 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* Effect.try(() => headers.set("authorization", `Bearer ${token}`)); + yield* setHeader("authorization", `Bearer ${token}`); } else { let key = env.ANTHROPIC_API_KEY?.trim(); if (!key && apiKeyHelper?.trim()) { @@ -86,14 +96,11 @@ export const discoverClaudeGatewayModels = Effect.fn("discoverClaudeGatewayModel if (result.code !== 0) return undefined; key = result.stdout.trim(); } - if (key) yield* Effect.try(() => headers.set("x-api-key", key)); + 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* Effect.try(() => - headers.set(line.slice(0, colon).trim(), line.slice(colon + 1).trim()), - ); + if (colon >= 0) yield* setHeader(line.slice(0, colon).trim(), line.slice(colon + 1).trim()); } const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);