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 <the@jat.mn>
This commit is contained in:
LM40R4Y
2026-10-18 10:01:06 +08:00
committed by GitHub
co-authored by jatmn
parent 0e9170fd37
commit 6e1694892d
6 changed files with 365 additions and 35 deletions
+9
View File
@@ -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
+145 -5
View File
@@ -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<ReturnType<typeof renderModelCommandWithCapturedPicker>> | 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()
}
})
}
+31 -24
View File
@@ -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
}
+107
View File
@@ -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<string, string>
}> = [
{ 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<string, string> }> = []
setMockFetch(mock((input, init) => {
const headers: Record<string, string> = {}
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)
})
+72 -6
View File
@@ -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<string, string> | 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<T> {
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<string, string> },
options?: {
processEnv?: NodeJS.ProcessEnv
apiKey?: string
baseUrl?: string
headers?: Record<string, string>
},
): Record<string, string> | 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<string, string>
@@ -401,6 +461,7 @@ async function runDiscovery(
export async function discoverModelsForRoute(
routeId: string,
options?: {
processEnv?: NodeJS.ProcessEnv
baseUrl?: string
apiKey?: string
headers?: Record<string, string>
@@ -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<string, string>
@@ -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 ??
+1
View File
@@ -484,6 +484,7 @@ function findCachedCatalogEntryForApiName(
cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey
}
const cacheKey = getDiscoveryCacheKey(routeId, {
processEnv: runtimeEnv,
baseUrl,
apiKey,
cacheKey: cacheIdentity,