From 6e1694892dc2f9d83b09f3700619241a77fd634d Mon Sep 17 00:00:00 2001 From: LM40R4Y <92870720+rayss868@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:01:06 +0700 Subject: [PATCH] fix(model): enable discovery for remote custom providers (#1895) * fix(model): enable model discovery for remote custom OpenAI-compatible providers Only skip descriptor discovery for local custom providers (Ollama, LM Studio) that rely on the legacy scoped-cache path. Remote custom providers (custom gateways, self-hosted APIs) now proceed with descriptor discovery so their dynamically-discovered models appear in the /model picker and /model refresh works. Previously, the routeId === 'custom' guard unconditionally returned null, preventing ALL custom providers from using descriptor discovery. Remote providers fell back to hardcoded default options while local providers correctly used the legacy path via getAdditionalModelOptionsCacheScope(). * fix: restore routeId === 'custom' fallback for legacy OpenAI discovery * test(model): add remote custom route regression test * fix(model): forward custom auth headers to descriptor discovery for remote custom routes - Add OPENAI_AUTH_HEADER forwarding in getOpenAIDiscoveryRequestOptions - Update remote-custom test to verify descriptor discovery path - Fix test for pooled credential to expect {} headers instead of undefined * fix(model): address review feedback - auth headers, cache keys, fallback - P1: Properly replicate inference auth header logic (OPENAI_AUTH_HEADER + OPENAI_AUTH_SCHEME + apiKey fallback matching the shim) - P1: Update findCachedCatalogEntryForApiName and getNvidiaNimDiscoveryCacheKeyForEnv to include OPENAI_AUTH_HEADER in cache key calculation so readers match the picker's partition - P2: Fall through to legacy OpenAI path when dynamic discovery catalog has no usable entries (no static models and no cached discovery) * fix(model): keep custom discovery repair scoped and auth-consistent * fix(discovery): preserve custom credentials across refresh * fix(model): retain filtered custom fallback and isolate cache mocks --------- Co-authored-by: jatmn --- docs/integrations/how-to/add-gateway.md | 9 ++ src/commands/model/model.test.tsx | 150 +++++++++++++++++++++- src/commands/model/model.tsx | 55 ++++---- src/integrations/discoveryService.test.ts | 107 +++++++++++++++ src/integrations/discoveryService.ts | 78 ++++++++++- src/integrations/runtimeMetadata.ts | 1 + 6 files changed, 365 insertions(+), 35 deletions(-) diff --git a/docs/integrations/how-to/add-gateway.md b/docs/integrations/how-to/add-gateway.md index 8757925e9..95ed970ba 100644 --- a/docs/integrations/how-to/add-gateway.md +++ b/docs/integrations/how-to/add-gateway.md @@ -374,6 +374,15 @@ that should support `/model refresh` and in-picker refresh. The shared discovery cache keeps curated entries visible while refreshes fail or become stale. + +Remote endpoints using the built-in `custom` route also use descriptor discovery +in `/model`, including initial discovery and `/model refresh`. If no selectable +models remain after merging configured profile models and applying the allowlist, +a Default option stays available. Discovery honors `OPENAI_AUTH_HEADER`, +`OPENAI_AUTH_HEADER_VALUE`, and `OPENAI_AUTH_SCHEME`, using the same auth partition +for cached runtime limits. Local custom chat-completions endpoints using the +`openai:` scoped cache retain the legacy picker. + ## `providerModelMap` in mixed gateway catalogs If the gateway exposes a shared model under a route-specific API name, point diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 6f0ae3b39..6f05c5ad3 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -24,6 +24,11 @@ import { import { mergeRouteCatalogEntries } from '../../utils/model/routeCatalogOptions.js' import * as actualFastModeForModelTest from '../../utils/fastMode.js' import * as actualExtraUsageForModelTest from '../../utils/extraUsage.js' +import * as actualDiscoveryServiceForModelTest from '../../integrations/discoveryService.js' +import * as actualDiscoveryCacheForModelTest from '../../integrations/discoveryCache.js' + +const REAL_DISCOVERY_SERVICE_FOR_MODEL_TEST = { ...actualDiscoveryServiceForModelTest } +const REAL_DISCOVERY_CACHE_FOR_MODEL_TEST = { ...actualDiscoveryCacheForModelTest } // Snapshot the real fast-mode module up front so the cross-profile switch test // can mock it with a full surface and restore the real one afterwards. @@ -46,6 +51,9 @@ const originalEnv = { OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, + OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER, + OPENAI_AUTH_HEADER_VALUE: process.env.OPENAI_AUTH_HEADER_VALUE, + OPENAI_AUTH_SCHEME: process.env.OPENAI_AUTH_SCHEME, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, CMD_API_KEY: process.env.CMD_API_KEY, @@ -231,6 +239,9 @@ afterEach(() => { restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) + restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER) + restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE) + restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME) restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('CMD_API_KEY', originalEnv.CMD_API_KEY) @@ -285,13 +296,14 @@ type DiscoveryModelForTest = { } function mockDescriptorDiscovery(options: { - cachedModels: DiscoveryModelForTest[] + cachedModels: DiscoveryModelForTest[] | null + error?: string discoveredModels?: DiscoveryModelForTest[] routeId?: string }): void { mock.module('../../integrations/discoveryCache.js', () => ({ clearDiscoveryCache: mock(async () => {}), - getCachedModels: mock(async () => ({ + getCachedModels: mock(async () => options.cachedModels === null ? null : ({ models: options.cachedModels, updatedAt: Date.now(), error: null, @@ -312,7 +324,7 @@ function mockDescriptorDiscovery(options: { discoverModelsForRoute: mock(async () => { const routeId = options.routeId ?? 'openrouter' const rawStatic = getRouteDescriptor(routeId)?.catalog?.models ?? [] - const discovered = (options.discoveredModels ?? options.cachedModels) as ModelCatalogEntry[] + const discovered = (options.discoveredModels ?? options.cachedModels ?? []) as ModelCatalogEntry[] const merged = filterAvailableCatalogEntries( mergeRouteCatalogEntries(rawStatic, discovered), ) @@ -320,8 +332,8 @@ function mockDescriptorDiscovery(options: { routeId, models: merged, stale: false, - error: null, - source: 'network', + error: options.error ? { message: options.error, recordedAt: Date.now() } : null, + source: options.error ? 'error' : 'network', } }), probeRouteReadiness: mock(async () => null), @@ -3828,3 +3840,131 @@ test('cross-profile /model does NOT switch a literal prefixed model id lacking t instance.unmount() } }) + +for (const state of ['cold', 'warm', 'failed', 'restricted', 'filtered-cold', 'filtered-warm'] as const) { + test(`/model remote custom discovery: ${state}`, async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://custom.example/v1' + const filtered = state === 'filtered-cold' || state === 'filtered-warm' + process.env.OPENAI_MODEL = filtered ? 'allowed-private-model' : 'selected-model' + if (filtered) useSettings({ availableModels: ['allowed-private-model'] }) + for (const key of [ + 'OPENAI_API_BASE', 'OPENROUTER_API_KEY', + 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', + 'CLAUDE_CODE_USE_MISTRAL', 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + ]) delete process.env[key] + if (state === 'restricted') { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' + } else { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + } + const model = { id: 'discovered-model', apiName: filtered ? 'blocked-model' : 'discovered-model', label: 'Discovered model' } + mockDescriptorDiscovery({ + routeId: 'custom', + cachedModels: state === 'warm' || state === 'filtered-warm' ? [model] : null, + discoveredModels: state === 'failed' ? [] : [model], + error: state === 'failed' ? 'Endpoint unavailable' : undefined, + }) + mockProviderProfiles() + const { discoverModelsForRoute } = await import('../../integrations/discoveryService.js') + const rendered = await renderModelCommandWithCapturedPicker(`remote-custom-${state}`) + try { + if (state === 'cold' || state === 'failed' || state === 'filtered-cold') { + await waitForCondition(() => rendered.getCapturedProps().discoveryState?.tone !== 'info') + expect(discoverModelsForRoute).toHaveBeenCalledWith('custom', expect.objectContaining({ forceRefresh: true })) + } + if (state === 'failed' || state === 'restricted' || filtered) { + expect(rendered.getCapturedProps().optionsOverride).toContainEqual( + expect.objectContaining({ value: null, label: 'Default (recommended)' }), + ) + } else { + expect(rendered.getCapturedProps().optionsOverride).toContainEqual( + expect.objectContaining({ value: 'discovered-model', label: 'Discovered model' }), + ) + } + if (state === 'failed') { + expect(rendered.getCapturedProps().discoveryState?.message).toContain('Endpoint unavailable') + } + if (state === 'restricted') expect(discoverModelsForRoute).not.toHaveBeenCalled() + if (filtered) { + rendered.getCapturedProps().onRefresh?.() + await waitForCondition(() => rendered.getCapturedProps().discoveryState?.message?.startsWith('No changes found') === true) + expect(rendered.getCapturedProps().optionsOverride).toEqual([ + expect.objectContaining({ value: null, label: 'Default (recommended)' }), + ]) + } + let message = '' + const { call } = await importFreshModelModule(`remote-custom-refresh-${state}`) + await call(value => { message = value ?? '' }, {} as never, 'refresh') + expect(message).toContain( + state === 'restricted' ? 'disabled' : state === 'failed' ? 'Endpoint unavailable' : + state === 'warm' || filtered ? 'No changes found' : 'Updated Custom (OpenAI-compatible) models', + ) + } finally { + rendered.instance.unmount() + rendered.stdout.end() + } + }) +} + +for (const entryPoint of ['picker', 'slash'] as const) { + test(`/model ${entryPoint} refresh preserves a missing key across a profile switch`, async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://profile-a.example/v1' + process.env.OPENAI_MODEL = 'model-a' + for (const key of [ + 'OPENAI_API_KEY', 'OPENAI_API_KEYS', 'OPENAI_API_BASE', 'OPENROUTER_API_KEY', + 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_HEADER_VALUE', 'OPENAI_AUTH_SCHEME', + 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_MISTRAL', + 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'ANTHROPIC_CUSTOM_HEADERS', + ]) delete process.env[key] + mockProviderProfiles() + mock.module('../../integrations/discoveryService.js', () => REAL_DISCOVERY_SERVICE_FOR_MODEL_TEST) + const cached = { models: [{ id: 'model-a', apiName: 'model-a' }], updatedAt: Date.now(), error: null } + const clearCache = mock(async () => { + // Profile B activates while A's manual refresh is awaiting cache I/O. + await Promise.resolve() + process.env.OPENAI_BASE_URL = 'https://profile-b.example/v1' + process.env.OPENAI_API_KEY = 'profile-b-secret' + }) + mock.module('../../integrations/discoveryCache.js', () => ({ + clearDiscoveryCache: clearCache, + getCachedModels: mock(async () => cached), + getCachedModelsSync: () => cached, + setCachedModels: mock(async () => {}), + recordDiscoveryError: mock(async () => {}), + isCacheStale: mock(async () => false), + parseDurationString: () => 86_400_000, + })) + const originalFetch = globalThis.fetch + const requests: Array<{ url: string; authorization: string | null }> = [] + globalThis.fetch = mock(async (input, init) => { + requests.push({ url: String(input), authorization: new Headers(init?.headers).get('authorization') }) + return new Response(JSON.stringify({ data: [{ id: 'model-a' }] }), { status: 200 }) + }) as unknown as typeof globalThis.fetch + let rendered: Awaited> | undefined + try { + if (entryPoint === 'picker') { + rendered = await renderModelCommandWithCapturedPicker('custom-manual-profile-switch') + expect(rendered.getCapturedProps().onRefresh).toBeDefined() + rendered.getCapturedProps().onRefresh?.() + await waitForCondition(() => rendered?.getCapturedProps().discoveryState?.message?.startsWith('No changes found') === true) + } else { + const { call } = await importFreshModelModule('custom-slash-profile-switch') + let message = '' + await call(value => { message = value ?? '' }, {} as never, 'refresh') + expect(message).toContain('No changes found') + } + expect(clearCache).toHaveBeenCalledTimes(1) + expect(requests).toEqual([{ url: 'https://profile-a.example/v1/models', authorization: null }]) + } finally { + // mock.restore() does not undo module registrations; later tests need disk I/O. + mock.module('../../integrations/discoveryCache.js', () => REAL_DISCOVERY_CACHE_FOR_MODEL_TEST) + globalThis.fetch = originalFetch + rendered?.instance.unmount() + rendered?.stdout.end() + } + }) +} diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index cd306c6a9..c1dc23ad6 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -268,32 +268,34 @@ export function mergeActiveProfileModelOptions( }, ): ModelOption[] { const activeProfile = getActiveProviderProfile() - if (!activeProfile) { - return filterModelOptionsByAllowlist(routeOptions) + if (activeProfile && isActiveProfileAppliedToRoute(activeProfile, routeId)) { + const profileOptions = getConfiguredProfileModelOptions(activeProfile) + if (profileOptions.length > 0) { + const surface = + options?.profileModelSurface ?? + resolveProviderProfileModelSurface({ + activeProfile, + routeId, + settingsMode: getProviderProfileModelPickerMode(), + }) + routeOptions = + surface === 'provider' + ? mergeProviderCatalogFirst(routeOptions, profileOptions) + : mergeProfileListFirst(routeOptions, profileOptions) + } } - if (!isActiveProfileAppliedToRoute(activeProfile, routeId)) { - return filterModelOptionsByAllowlist(routeOptions) + const filteredOptions = filterModelOptionsByAllowlist(routeOptions) + // Keep a usable fallback only when the merged, permitted custom list is empty. + // Retain the descriptor context so initial discovery and refresh still run. + if ( + routeId === 'custom' && + !getAdditionalModelOptionsCacheScope()?.startsWith('openai:') && + filteredOptions.length === 0 + ) { + return [getDefaultOptionForUser()] } - - const profileOptions = getConfiguredProfileModelOptions(activeProfile) - if (profileOptions.length === 0) { - return filterModelOptionsByAllowlist(routeOptions) - } - - const surface = - options?.profileModelSurface ?? - resolveProviderProfileModelSurface({ - activeProfile, - routeId, - settingsMode: getProviderProfileModelPickerMode(), - }) - const merged = - surface === 'provider' - ? mergeProviderCatalogFirst(routeOptions, profileOptions) - : mergeProfileListFirst(routeOptions, profileOptions) - - return filterModelOptionsByAllowlist(merged) + return filteredOptions } function getActiveRouteId(): string | null { @@ -453,7 +455,12 @@ async function loadDescriptorDiscoveryContext( return null } - if (routeId === 'custom') { + // Local custom chat-completions routes with an openai: scope keep their + // legacy picker semantics. + if ( + routeId === 'custom' && + getAdditionalModelOptionsCacheScope()?.startsWith('openai:') + ) { return null } diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 63ced2f63..8970b2a37 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -21,6 +21,9 @@ const originalEnv = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER, + OPENAI_AUTH_HEADER_VALUE: process.env.OPENAI_AUTH_HEADER_VALUE, + OPENAI_AUTH_SCHEME: process.env.OPENAI_AUTH_SCHEME, APISMART_API_KEY: process.env.APISMART_API_KEY, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, @@ -63,6 +66,9 @@ function clearProviderEnv(): void { delete process.env.OPENAI_API_KEY delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_MODEL + delete process.env.OPENAI_AUTH_HEADER + delete process.env.OPENAI_AUTH_HEADER_VALUE + delete process.env.OPENAI_AUTH_SCHEME delete process.env.APISMART_API_KEY delete process.env.ANTHROPIC_CUSTOM_HEADERS delete process.env.CLAUDE_CODE_USE_OPENAI @@ -101,6 +107,9 @@ afterEach(() => { restoreEnvValue('OPENAI_API_KEY') restoreEnvValue('OPENAI_API_KEYS') restoreEnvValue('OPENAI_MODEL') + restoreEnvValue('OPENAI_AUTH_HEADER') + restoreEnvValue('OPENAI_AUTH_HEADER_VALUE') + restoreEnvValue('OPENAI_AUTH_SCHEME') restoreEnvValue('APISMART_API_KEY') restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS') restoreEnvValue('CLAUDE_CODE_USE_OPENAI') @@ -1392,3 +1401,101 @@ describe('resolveDiscoveryRouteIdFromBaseUrl', () => { ).toBe('ollama') }) }) + +// Exercise the real custom descriptor, HTTP request construction, disk cache, +// and runtime-limit reader. Only the remote HTTP response is simulated. +const customAuthCases: Array<{ + name: string + value: string | undefined + scheme: string | undefined + expected: Record +}> = [ + { name: 'X-API-Key', value: undefined, scheme: undefined, expected: { 'x-api-key': 'pooled-key' } }, + { name: 'X-API-Key', value: 'explicit-key', scheme: 'raw', expected: { 'x-api-key': 'explicit-key' } }, + { name: 'authorization', value: undefined, scheme: undefined, expected: { authorization: 'Bearer pooled-key' } }, + { name: 'Authorization', value: 'explicit-key', scheme: 'raw', expected: { authorization: 'explicit-key' } }, + { name: 'Authorization', value: 'explicit-key', scheme: undefined, expected: { authorization: 'Bearer explicit-key' } }, + { name: 'X-API-Key', value: '', scheme: 'bearer', expected: { 'x-api-key': 'Bearer pooled-key' } }, + { name: 'invalid header', value: 'explicit-key', scheme: undefined, expected: { authorization: 'Bearer pooled-key' } }, + { name: 'invalid header', value: undefined, scheme: undefined, expected: { authorization: 'Bearer pooled-key' } }, +] +for (const auth of customAuthCases) { + test(`custom discovery auth ${auth.name}/${auth.scheme}/${auth.value}`, async () => { + const { refreshStartupDiscoveryForActiveRoute, discoverModelsForRoute, getDiscoveryCacheKey } = await loadDiscoveryServiceModule() + const { resolveModelRuntimeLimits } = await import('./runtimeMetadata.js') + const env: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://custom.example/v1', + OPENAI_API_KEYS: 'pooled-key,second-key', + OPENAI_AUTH_HEADER: auth.name, + OPENAI_AUTH_HEADER_VALUE: auth.value, + OPENAI_AUTH_SCHEME: auth.scheme, + } + const validName = !auth.name.includes(' ') + const callerHeaders = { + 'X-Tenant': 'tenant-a', + ...(validName ? { [auth.name.toUpperCase()]: 'stale-key' } : {}), + } + env.ANTHROPIC_CUSTOM_HEADERS = Object.entries(callerHeaders) + .map(([name, value]) => `${name}: ${value}`).join('\n') + const requests: Array<{ url: string; headers: Record }> = [] + setMockFetch(mock((input, init) => { + const headers: Record = {} + new Headers(init?.headers).forEach((value, name) => { headers[name] = value }) + requests.push({ url: String(input), headers }) + return Promise.resolve(new Response(JSON.stringify({ + data: [{ id: 'remote-model', context_window: 123456 }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + }) as unknown as typeof globalThis.fetch) + const first = await refreshStartupDiscoveryForActiveRoute({ processEnv: env }) + expect(first?.source).toBe('network') + expect(requests).toEqual([{ url: 'https://custom.example/v1/models', headers: { ...auth.expected, 'x-tenant': 'tenant-a' } }]) + // The picker supplies the resolved credential; startup supplies its env. + const cached = await discoverModelsForRoute('custom', { + baseUrl: env.OPENAI_BASE_URL, apiKey: 'pooled-key', processEnv: env, headers: callerHeaders, + }) + expect(cached?.source).toBe('cache') + expect(requests).toHaveLength(1) + expect(resolveModelRuntimeLimits({ model: 'remote-model', processEnv: env }).contextWindow).toBe(123456) + const identityOptions = { baseUrl: env.OPENAI_BASE_URL, apiKey: 'pooled-key', processEnv: env } + expect(getDiscoveryCacheKey('custom', { ...identityOptions, headers: callerHeaders })).toBe( + getDiscoveryCacheKey('custom', { ...identityOptions, headers: { 'X-Tenant': 'tenant-a' } }), + ) + // An explicit custom-auth value selects a different account/catalog. + if (auth.name === 'Authorization') { + expect(resolveModelRuntimeLimits({ + model: 'remote-model', processEnv: { ...env, OPENAI_AUTH_HEADER_VALUE: 'another-account' }, + }).contextWindow).toBeUndefined() + } + }) +} + +test('custom startup discovery retains its auth when another profile activates', async () => { + const { refreshStartupDiscoveryForRoute } = await loadDiscoveryServiceModule() + const { resolveModelRuntimeLimits } = await import('./runtimeMetadata.js') + process.env.OPENAI_AUTH_HEADER = 'X-Profile-Key' + process.env.OPENAI_AUTH_HEADER_VALUE = 'profile-a-secret' + const requests: Array<{ url: string; secret: string | null }> = [] + setMockFetch(mock((input, init) => { + requests.push({ url: String(input), secret: new Headers(init?.headers).get('X-Profile-Key') }) + return Promise.resolve(new Response(JSON.stringify({ + data: [{ id: 'model-a', context_window: 123456 }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + }) as unknown as typeof globalThis.fetch) + // Profile activation applies env and starts discovery without awaiting it. + const pending = refreshStartupDiscoveryForRoute('custom', { + baseUrl: 'https://profile-a.example/v1', + }) + process.env.OPENAI_AUTH_HEADER_VALUE = 'profile-b-secret' + expect((await pending)?.source).toBe('network') + expect(requests).toEqual([{ url: 'https://profile-a.example/v1/models', secret: 'profile-a-secret' }]) + expect(resolveModelRuntimeLimits({ + model: 'model-a', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://profile-a.example/v1', + OPENAI_AUTH_HEADER: 'X-Profile-Key', + OPENAI_AUTH_HEADER_VALUE: 'profile-a-secret', + }, + }).contextWindow).toBe(123456) +}) diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 69f300d22..580b0db79 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -143,6 +143,7 @@ function fingerprintDiscoveryCachePartition(value: unknown): string { export function getDiscoveryCacheKey( routeId: string, options?: { + processEnv?: NodeJS.ProcessEnv baseUrl?: string apiKey?: string cacheKey?: string @@ -171,10 +172,46 @@ function getRouteBaseUrl( return options?.baseUrl ?? getRouteDescriptor(routeId)?.defaultBaseUrl } +// Keep custom endpoint authentication identical for requests and cache readers. +function getCustomDiscoveryAuth( + routeId: string, + options?: { apiKey?: string; baseUrl?: string; processEnv?: NodeJS.ProcessEnv }, +): Record | undefined { + if (routeId !== 'custom') return undefined + const env = options?.processEnv ?? process.env + const name = env.OPENAI_AUTH_HEADER?.trim() + if (!name || !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return undefined + const explicitValue = env.OPENAI_AUTH_HEADER_VALUE?.trim() + if (explicitValue && /[\r\n]/.test(explicitValue)) { + throw new Error('OPENAI_AUTH_HEADER_VALUE must not contain CR/LF characters') + } + const value = + explicitValue || + firstUsableCredential(options?.apiKey) || + firstUsableCredential( + resolveRouteCredentialValue({ + routeId, + baseUrl: options?.baseUrl, + processEnv: env, + }), + ) + const scheme = env.OPENAI_AUTH_SCHEME + const bearer = + scheme === 'bearer' || + (scheme !== 'raw' && name.toLowerCase() === 'authorization') + return value ? { [name]: bearer ? `Bearer ${value}` : value } : {} +} + function getRouteDiscoveryApiKey( routeId: string, - options?: { baseUrl?: string; apiKey?: string }, + options?: { + processEnv?: NodeJS.ProcessEnv + baseUrl?: string + apiKey?: string + }, ): string | undefined { + if (getCustomDiscoveryAuth(routeId, options)) return undefined + const baseUrl = getRouteBaseUrl(routeId, options) // ApiSmart's dedicated token must never be used for an overridden discovery // URL. Apply the same exact inference-endpoint boundary used by requests and @@ -199,13 +236,15 @@ function getRouteDiscoveryApiKey( resolveRouteCredentialValue({ routeId, baseUrl, - processEnv: process.env, + processEnv: + routeId === 'custom' ? options?.processEnv ?? process.env : process.env, }), ) } export async function resolveDiscoveryRequestOptions< T extends { + processEnv?: NodeJS.ProcessEnv apiKey?: string cacheKey?: string baseUrl?: string @@ -217,6 +256,11 @@ export async function resolveDiscoveryRequestOptions< resolverOptions?: { refreshXaiOAuth?: boolean }, ): Promise { const next = { ...(options ?? {}) } as T + if (routeId === 'custom') { + // Profile activation can change the global env while cache I/O is pending. + next.processEnv = { ...(next.processEnv ?? process.env) } + return next + } if (getRouteDiscoveryApiKey(routeId, next) || routeId !== 'xai') { return next } @@ -248,7 +292,12 @@ export async function resolveDiscoveryRequestOptions< export function getRouteDiscoveryHeaders( routeId: string, - options?: { baseUrl?: string; headers?: Record }, + options?: { + processEnv?: NodeJS.ProcessEnv + apiKey?: string + baseUrl?: string + headers?: Record + }, ): Record | undefined { const transportConfig = getRouteDescriptor(routeId)?.transportConfig // Descriptor headers are attribution, not transport plumbing: an `aimlapi` @@ -281,7 +330,17 @@ export function getRouteDiscoveryHeaders( ...callerHeaders, } - return Object.keys(headers).length > 0 ? headers : undefined + const customAuth = getCustomDiscoveryAuth(routeId, options) + const requestHeaders = { ...headers } + for (const [name, value] of Object.entries(customAuth ?? {})) { + for (const existingName of Object.keys(requestHeaders)) { + if (existingName.toLowerCase() === name.toLowerCase()) { + delete requestHeaders[existingName] + } + } + requestHeaders[name] = value + } + return Object.keys(requestHeaders).length > 0 ? requestHeaders : undefined } function toDiscoveredModelEntry(modelId: string): ModelCatalogEntry { @@ -343,6 +402,7 @@ function dedupeDiscoveredEntries( async function runDiscovery( routeId: string, options?: { + processEnv?: NodeJS.ProcessEnv baseUrl?: string apiKey?: string headers?: Record @@ -401,6 +461,7 @@ async function runDiscovery( export async function discoverModelsForRoute( routeId: string, options?: { + processEnv?: NodeJS.ProcessEnv baseUrl?: string apiKey?: string headers?: Record @@ -476,7 +537,10 @@ export async function discoverModelsForRoute( } try { - const discoveryOptions = await resolveDiscoveryRequestOptions(routeId, options) + const discoveryOptions = await resolveDiscoveryRequestOptions( + routeId, + routeId === 'custom' ? cachedOptions : options, + ) const discoveryCacheKey = getDiscoveryCacheKey(routeId, discoveryOptions) const discovered = await runDiscovery(routeId, discoveryOptions) if (discovered === null) { @@ -530,6 +594,7 @@ export async function discoverModelsForRoute( export async function refreshStartupDiscoveryForRoute( routeId: string, options?: { + processEnv?: NodeJS.ProcessEnv baseUrl?: string apiKey?: string headers?: Record @@ -561,7 +626,7 @@ export async function refreshStartupDiscoveryForRoute( } return discoverModelsForRoute(routeId, { - ...options, + ...(routeId === 'custom' ? cachedOptions : options), forceRefresh: true, }) } @@ -591,6 +656,7 @@ export async function refreshStartupDiscoveryForActiveRoute( } return refreshStartupDiscoveryForRoute(routeId, { + processEnv, baseUrl, headers: options?.headers ?? diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 5d30f62a0..ad02872fb 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -484,6 +484,7 @@ function findCachedCatalogEntryForApiName( cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey } const cacheKey = getDiscoveryCacheKey(routeId, { + processEnv: runtimeEnv, baseUrl, apiKey, cacheKey: cacheIdentity,