mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -484,6 +484,7 @@ function findCachedCatalogEntryForApiName(
|
||||
cacheIdentity = getXaiDiscoveryCacheIdentity(credentials) ?? apiKey
|
||||
}
|
||||
const cacheKey = getDiscoveryCacheKey(routeId, {
|
||||
processEnv: runtimeEnv,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
cacheKey: cacheIdentity,
|
||||
|
||||
Reference in New Issue
Block a user