diff --git a/src/integrations/index.test.ts b/src/integrations/index.test.ts index 66b203c92e..3c8439d204 100644 --- a/src/integrations/index.test.ts +++ b/src/integrations/index.test.ts @@ -40,6 +40,13 @@ describe('loaded registry validation', () => { expect(routeSupportsCustomHeaders('custom-anthropic')).toBe(true) }) + test('local gateways support auth headers without API-format selection', () => { + for (const routeId of ['ollama', 'lmstudio']) { + expect(routeSupportsApiFormatSelection(routeId)).toBe(false) + expect(routeSupportsAuthHeaders(routeId)).toBe(true) + } + }) + test('route catalogs do not duplicate defaultModel with catalog default flags', () => { const routes = [...getAllVendors(), ...getAllGateways()] expect( diff --git a/src/integrations/index.ts b/src/integrations/index.ts index fb863f4077..dc6eb0bb3b 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -141,6 +141,7 @@ export { isCloudflareBaseUrl, normalizeXiaomiMimoBaseUrl, resolveActiveRouteIdFromEnv, + resolveLocalCompatibleRouteIdFromBaseUrl, resolveRouteIdFromBaseUrl, routeSupportsApiFormatSelection, routeSupportsAuthHeaders, diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index ac62a9f45d..34c9daf6b5 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -9,6 +9,7 @@ import { isCloudflareBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, + resolveLocalCompatibleRouteIdFromBaseUrl, resolveRouteIdFromBaseUrl, } from './routeMetadata.js' @@ -88,6 +89,107 @@ test('getRouteProviderTypeLabel falls back safely for unknown routes', () => { ) }) +test('resolveRouteIdFromBaseUrl only treats loopback local ports as known local routes', () => { + expect(resolveRouteIdFromBaseUrl('http://localhost:11434/v1')).toBe('ollama') + expect(resolveRouteIdFromBaseUrl('http://127.0.0.1:11434/v1')).toBe( + 'ollama', + ) + expect(resolveRouteIdFromBaseUrl('http://[::1]:11434/v1')).toBe('ollama') + expect(resolveRouteIdFromBaseUrl('http://localhost:1234/v1')).toBe( + 'lmstudio', + ) + expect(resolveRouteIdFromBaseUrl('http://127.0.0.1:1234/v1')).toBe( + 'lmstudio', + ) + expect(resolveRouteIdFromBaseUrl('http://[::1]:1234/v1')).toBe('lmstudio') +}) + +test('resolveRouteIdFromBaseUrl preserves custom URLs that resemble local routes', () => { + expect(resolveRouteIdFromBaseUrl('https://proxy.example.com:11434/v1')).toBe( + null, + ) + expect(resolveRouteIdFromBaseUrl('https://proxy.example.com:1234/v1')).toBe( + null, + ) + expect(resolveRouteIdFromBaseUrl('https://myollama.example.com/v1')).toBe( + null, + ) + expect(resolveRouteIdFromBaseUrl('https://lmstudio.example.com/v1')).toBe( + null, + ) + expect(resolveRouteIdFromBaseUrl('https://example.com/ollama/v1')).toBe(null) + expect(resolveRouteIdFromBaseUrl('https://example.com/lm-studio/v1')).toBe( + null, + ) + expect(resolveRouteIdFromBaseUrl('https://localhost:11434/v1')).toBe(null) +}) + +test('remote Ollama-compatible URLs keep Ollama route identity for runtime decisions', () => { + // Tightened from the pre-PR broad matcher: only a host whose dot-label is + // exactly `ollama` (e.g. `ollama.corp.example.com`) keeps the Ollama route + // for runtime decisions. A :11434 port on a non-loopback host no longer + // classifies as Ollama — it would otherwise silently route vLLM / LM Studio + // tunnels through the local-shim catalog and drop responses-API-format + // selection. Likewise `my-ollama-server` (label `my-ollama-server`) and + // `myollama` (no `ollama` dot-label) no longer match. + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'https://ollama.corp.example.com/v1', + ), + ).toBe('ollama') + expect( + resolveLocalCompatibleRouteIdFromBaseUrl('https://ollama.example.com/v1'), + ).toBe('ollama') + expect( + resolveLocalCompatibleRouteIdFromBaseUrl('http://203.0.113.5:11434/v1'), + ).toBe(null) + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'http://my-ollama-server.example.com:11434/v1', + ), + ).toBe(null) + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'https://my-ollama.example.com/v1', + ), + ).toBe(null) + expect( + resolveLocalCompatibleRouteIdFromBaseUrl('https://myollama.example.com/v1'), + ).toBe(null) + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'https://ollama-corp.example.com/v1', + ), + ).toBe(null) + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'https://proxy.example.com:11434/v1', + ), + ).toBe(null) + + expect( + resolveActiveRouteIdFromEnv({ + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://ollama.corp.example.com/v1', + }), + ).toBe('ollama') +}) + +test('xai --provider replaces a stale remote Ollama URL identified by hostname token', () => { + // Loopback-port cross-provider replacement is exercised by the + // `${provider} replaces a stale known provider base URL` test in + // providerFlag.test.ts (the loopback :11434 path resolves to the `ollama` + // route via the strict resolver). This companion covers the remote + // hostname-token form kept by resolveLocalCompatibleRouteIdFromBaseUrl: a + // `ollama.corp.example.com` URL must also be replaced on provider switch, + // confirming MEDIUM-2's resolver alignment (LOW-5). + expect( + resolveLocalCompatibleRouteIdFromBaseUrl( + 'https://ollama.corp.example.com/v1', + ), + ).toBe('ollama') +}) + test('getRouteCredentialEnvVars keeps descriptor env vars and openai fallback for openai-compatible routes', () => { expect(getRouteCredentialEnvVars('custom')).toEqual([ 'OPENAI_API_KEYS', diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index da2f1bc481..3c8eaa03fa 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -99,6 +99,21 @@ function getAllRoutes(): RouteDescriptor[] { return [...getAllGateways(), ...getAllVendors(), ...getAllAnthropicProxies()] } +// Route inference is intentionally narrower than local-network detection: only +// built-in local defaults are known enough to replace as stale provider URLs. +function isDefaultLocalRouteHostname(hostname: string): boolean { + const normalizedHostname = + hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname + + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '127.0.0.1' || + normalizedHostname === '::1' + ) +} + function resolveKnownLocalRouteIdFromBaseUrl(baseUrl?: string): string | null { if (!baseUrl) { return null @@ -106,19 +121,16 @@ function resolveKnownLocalRouteIdFromBaseUrl(baseUrl?: string): string | null { try { const parsed = new URL(baseUrl) - const host = parsed.host.toLowerCase() const hostname = parsed.hostname.toLowerCase() - const path = parsed.pathname.toLowerCase() - const haystack = `${hostname} ${path}` - if (host.endsWith(':11434') || haystack.includes('ollama')) { + if (parsed.protocol !== 'http:' || !isDefaultLocalRouteHostname(hostname)) { + return null + } + + if (parsed.port === '11434') { return 'ollama' } - if ( - host.endsWith(':1234') || - haystack.includes('lmstudio') || - haystack.includes('lm-studio') - ) { + if (parsed.port === '1234') { return 'lmstudio' } } catch { @@ -128,6 +140,51 @@ function resolveKnownLocalRouteIdFromBaseUrl(baseUrl?: string): string | null { return null } +// Match a single dot-label exactly, NOT a dash-split sub-token. Splitting a +// label like `ollama-corp` on `[-_]+` would `includes('ollama')` it as a +// sub-token and misclassify any third-party host whose label merely embeds +// `ollama`. Exact dot-label match keeps `ollama` and `ollama.example.com` +// matching while excluding `ollama-corp.example.com` / `my-ollama.example.com`. +function hostnameContainsRouteToken(hostname: string, token: string): boolean { + return hostname.split('.').some(label => label === token) +} + +function resolveRemoteOllamaRouteIdFromBaseUrl(baseUrl?: string): string | null { + if (!baseUrl) { + return null + } + + try { + const parsed = new URL(baseUrl) + const hostname = parsed.hostname.toLowerCase() + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + + // No host-agnostic :11434 rule: a reverse proxy / vLLM / LM Studio tunnel + // bound to 11434 from a remote host would silently inherit Ollama's + // local-shim catalog + responses-API-format selection. Loopback :11434 is + // already handled by the strict resolver (resolveKnownLocalRouteIdFromBaseUrl). + if (hostnameContainsRouteToken(hostname, 'ollama')) { + return 'ollama' + } + } catch { + return null + } + + return null +} + +export function resolveLocalCompatibleRouteIdFromBaseUrl( + baseUrl?: string, +): string | null { + return ( + resolveRouteIdFromBaseUrl(baseUrl) ?? + resolveRemoteOllamaRouteIdFromBaseUrl(baseUrl) + ) +} + export function getRouteDescriptor( routeId: string, ): RouteDescriptor | null { @@ -879,7 +936,16 @@ function routeSupportsOpenAIShimOption( option: 'supportsApiFormatSelection' | 'supportsAuthHeaders', ): boolean { const descriptor = getRouteDescriptor(routeId) - if (!descriptor || descriptor.transportConfig.kind !== 'openai-compatible') { + if (!descriptor) { + return false + } + + const transportKind = descriptor.transportConfig.kind + const supportsOptionForTransport = + transportKind === 'openai-compatible' || + (option === 'supportsAuthHeaders' && transportKind === 'local') + + if (!supportsOptionForTransport) { return false } @@ -1024,7 +1090,7 @@ export function resolveActiveRouteIdFromEnv( if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)) { const baseUrl = processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE - const matchedRoute = resolveRouteIdFromBaseUrl(baseUrl) + const matchedRoute = resolveLocalCompatibleRouteIdFromBaseUrl(baseUrl) if (matchedRoute) { return matchedRoute @@ -1045,7 +1111,7 @@ export function resolveActiveRouteIdFromEnv( } // A custom/unknown profile may still target a known gateway via its // saved base URL; prefer that route over the generic openai/custom path. - const profileBaseUrlRoute = resolveRouteIdFromBaseUrl( + const profileBaseUrlRoute = resolveLocalCompatibleRouteIdFromBaseUrl( options.activeProfileBaseUrl, ) if (profileBaseUrlRoute) { @@ -1080,7 +1146,7 @@ export function resolveActiveRouteIdFromEnv( } // A custom/unknown profile may still target a known gateway via its // saved base URL; prefer that route over the generic anthropic fallback. - const profileBaseUrlRoute = resolveRouteIdFromBaseUrl( + const profileBaseUrlRoute = resolveLocalCompatibleRouteIdFromBaseUrl( options.activeProfileBaseUrl, ) if (profileBaseUrlRoute) { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index e9b17a1de1..65f05883fd 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -21,6 +21,7 @@ import { getRouteDescriptor, resolveRouteCredentialValue, resolveActiveRouteIdFromEnv, + resolveLocalCompatibleRouteIdFromBaseUrl, resolveRouteIdFromBaseUrl, type RouteDescriptor, } from './routeMetadata.js' @@ -269,7 +270,9 @@ export function resolveOpenAIShimRuntimeContext(options?: { activeProfileProvider: options?.activeProfileProvider, activeProfileBaseUrl: options?.baseUrl, }) - const baseUrlRouteId = resolveRouteIdFromBaseUrl(options?.baseUrl) + const baseUrlRouteId = resolveLocalCompatibleRouteIdFromBaseUrl( + options?.baseUrl, + ) const routeId = options?.preferBaseUrlRoute && options.baseUrl !== undefined ? baseUrlRouteId diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 9f5a776105..9f4ff56ffd 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -22,9 +22,6 @@ const realProviders = { } mock.module('../../utils/model/providers.js', () => realProviders) mock.module('src/utils/model/providers.js', () => realProviders) -const { getAnthropicClient } = await import( - `./client.js?real=${Date.now()}-${Math.random()}`, -) type FetchType = typeof globalThis.fetch @@ -38,6 +35,8 @@ type ShimClient = { const originalFetch = globalThis.fetch const originalMacro = (globalThis as Record).MACRO +let clientImportCounter = 0 +let getAnthropicClient: typeof import('./client.js').getAnthropicClient const originalEnv = { CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, @@ -131,6 +130,9 @@ function clearEnvForMiniMaxOnlyTest(): void { beforeEach(async () => { await acquireSharedMutationLock('client.test.ts') + ;({ getAnthropicClient } = await import( + `./client.js?client-test-${clientImportCounter++}` + )) ;(globalThis as Record).MACRO = { VERSION: 'test-version' } process.env.CLAUDE_CODE_USE_GEMINI = '1' process.env.GEMINI_API_KEY = 'gemini-test-key' diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index a04c8fb14c..250f17ac4e 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -6984,7 +6984,11 @@ test('uses native Ollama chat endpoint when local base URL omits /v1', async () }) test('keeps remote Ollama-named gateways on chat completions', async () => { - process.env.OPENAI_BASE_URL = 'https://ollama-gateway.example.com/v1' + // Tightened remote-Ollama classifier: only a host whose dot-label is exactly + // `ollama` (e.g. `ollama.example.com`) inherits the Ollama shim's chat-only + // transport. Hosts whose label merely embeds `ollama` after a dash (e.g. + // `ollama-gateway`) no longer match the Ollama route. + process.env.OPENAI_BASE_URL = 'https://ollama.example.com/v1' const requestUrls: string[] = [] globalThis.fetch = (async (input, init) => { @@ -7009,7 +7013,7 @@ test('keeps remote Ollama-named gateways on chat completions', async () => { ).resolves.toBeDefined() expect(requestUrls).toEqual([ - 'https://ollama-gateway.example.com/v1/chat/completions', + 'https://ollama.example.com/v1/chat/completions', ]) }) diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index f321c5c4f6..c06254e0b9 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -170,6 +170,50 @@ test('uses responses transport when OpenAI-compatible API format requests respon }) }) +test.each([ + // Tightened remote-Ollama classifier: only `ollama.corp.example.com` (a host + // whose dot-label is exactly `ollama`) keeps the Ollama route's chat-only + // transport. A non-loopback :11434 port (`203.0.113.5:11434`) or a + // dash-embedded label (`my-ollama-server`) no longer matches the Ollama + // route, so `OPENAI_API_FORMAT=responses` is honoured instead. + 'https://ollama.corp.example.com/v1', +])( + 'keeps remote Ollama endpoint %s on chat completions when API format is set', + baseUrl => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_MODEL = 'llama3.1:8b' + process.env.OPENAI_API_FORMAT = 'responses' + + expect(resolveProviderRequest()).toMatchObject({ + transport: 'chat_completions', + requestedModel: 'llama3.1:8b', + resolvedModel: 'llama3.1:8b', + baseUrl, + }) + }, +) + +test.each([ + 'http://203.0.113.5:11434/v1', + 'http://my-ollama-server.example.com:11434/v1', +])( + 'non-Ollama remote endpoint %s honours OPENAI_API_FORMAT after classifier tightening', + baseUrl => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = baseUrl + process.env.OPENAI_MODEL = 'llama3.1:8b' + process.env.OPENAI_API_FORMAT = 'responses' + + expect(resolveProviderRequest()).toMatchObject({ + transport: 'responses', + requestedModel: 'llama3.1:8b', + resolvedModel: 'llama3.1:8b', + baseUrl, + }) + }, +) + test('uses responses transport for Hicap gpt-5.5 models when requested', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://api.hicap.ai/v1' diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index c639c81ccd..7ef60cd703 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -958,8 +958,10 @@ export function resolveProviderRequest(options?: { model: resolvedModel, treatAsLocal: finalBaseUrl ? isLocalProviderUrl(finalBaseUrl) : false, }) + const runtimeRouteIsLocal = + runtimeShimContext?.descriptor?.transportConfig.kind === 'local' const explicitApiFormat = - isGithubMode + isGithubMode || runtimeRouteIsLocal ? undefined : parseOpenAICompatibleApiFormat(options?.apiFormat) ?? parseOpenAICompatibleApiFormat(processEnv.OPENAI_API_FORMAT) diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 767bed6a93..c416d0d3ed 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -39,6 +39,8 @@ const ENV_KEYS = [ 'VENICE_API_KEY', 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', + 'NEARAI_API_KEY', + 'FIREWORKS_API_KEY', 'OPENGATEWAY_API_KEY', 'OPENGATEWAY_BASE_URL', 'CLOUDFLARE_API_TOKEN', @@ -86,6 +88,8 @@ const RESET_KEYS = [ 'VENICE_API_KEY', 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', + 'NEARAI_API_KEY', + 'FIREWORKS_API_KEY', 'OPENGATEWAY_API_KEY', 'OPENGATEWAY_BASE_URL', 'CLOUDFLARE_API_TOKEN', @@ -467,6 +471,190 @@ describe('applyProviderFlag - ollama', () => { }) }) +describe('applyProviderFlag - explicit provider base URL defaults', () => { + const providers = [ + { + provider: 'ollama', + baseUrl: 'http://localhost:11434/v1', + defaultModel: 'llama3.1:8b', + }, + { + provider: 'nvidia-nim', + baseUrl: 'https://integrate.api.nvidia.com/v1', + defaultModel: 'nvidia/llama-3.1-nemotron-70b-instruct', + }, + { + provider: 'bankr', + baseUrl: 'https://llm.bankr.bot/v1', + defaultModel: 'claude-opus-4.6', + }, + { + provider: 'xai', + baseUrl: 'https://api.x.ai/v1', + defaultModel: 'grok-4.3', + }, + { + provider: 'xiaomi-mimo', + baseUrl: 'https://api.xiaomimimo.com/v1', + defaultModel: 'mimo-v2.5-pro', + }, + { + provider: 'xiaomi-mimo-token', + baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', + defaultModel: 'mimo-v2.5-pro', + }, + { + provider: 'venice', + baseUrl: 'https://api.venice.ai/api/v1', + defaultModel: 'venice-uncensored', + }, + { + provider: 'nearai', + baseUrl: 'https://cloud-api.near.ai/v1', + defaultModel: 'anthropic/claude-sonnet-4-6', + }, + { + provider: 'fireworks', + baseUrl: 'https://api.fireworks.ai/inference/v1', + defaultModel: 'accounts/fireworks/models/llama-v3p1-70b-instruct', + }, + ] as const + const customLocalLikeBaseUrls: Array<[string]> = [ + ['https://proxy.example.com:11434/v1'], + ['https://proxy.example.com:1234/v1'], + ['https://myollama.example.com/v1'], + ['https://lmstudio.example.com/v1'], + ['https://example.com/lm-studio/v1'], + ] + + for (const { provider, baseUrl, defaultModel } of providers) { + test(`${provider} replaces a stale known provider base URL`, () => { + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe(baseUrl) + }) + + test(`${provider} resets a stale model when replacing a stale known provider base URL`, () => { + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe(baseUrl) + expect(process.env.OPENAI_MODEL).toBe(defaultModel) + }) + + test(`${provider} preserves an explicit model when replacing a stale known provider base URL`, () => { + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + + const result = applyProviderFlag(provider, [ + '--provider', + provider, + '--model', + 'custom-route-model', + ]) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe(baseUrl) + expect(process.env.OPENAI_MODEL).toBe('custom-route-model') + }) + + test(`${provider} preserves a custom unknown base URL`, () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example.com/v1' + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example.com/v1') + }) + + test.each(customLocalLikeBaseUrls)( + `${provider} preserves custom local-like URL %s`, + customBaseUrl => { + process.env.OPENAI_BASE_URL = customBaseUrl + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe(customBaseUrl) + }, + ) + + test(`${provider} preserves a custom OPENAI_API_BASE alias`, () => { + process.env.OPENAI_API_BASE = + 'https://my-custom-gateway.example.com/v1' + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBeUndefined() + expect(process.env.OPENAI_API_BASE).toBe( + 'https://my-custom-gateway.example.com/v1', + ) + }) + } +}) + +describe('applyProviderFlag - loopback-port known routes are replaced on provider switch', () => { + // Loopback local-gateway ports (ollama :11434, lmstudio :1234) resolve to + // known provider routes via the strict resolver, so switching to a different + // descriptor-backed provider replaces them — unlike a non-loopback remote + // host on the same port, which is preserved as a custom URL. This confirms + // the loopback-port half of the cross-provider replacement contract (LOW-5). + const loopbackStaleBaseUrls = [ + 'http://localhost:11434/v1', + 'http://127.0.0.1:11434/v1', + 'http://[::1]:11434/v1', + 'http://localhost:1234/v1', + 'http://127.0.0.1:1234/v1', + ] as const + const targetProviders: ReadonlyArray<{ + provider: string + expectedBaseUrl: string + }> = [ + { + provider: 'xai', + expectedBaseUrl: 'https://api.x.ai/v1', + }, + { + provider: 'nvidia-nim', + expectedBaseUrl: 'https://integrate.api.nvidia.com/v1', + }, + ] + + for (const { provider, expectedBaseUrl } of targetProviders) { + for (const staleBaseUrl of loopbackStaleBaseUrls) { + test(`${provider} replaces stale loopback ${staleBaseUrl}`, () => { + process.env.OPENAI_BASE_URL = staleBaseUrl + + const result = applyProviderFlag(provider, []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe(expectedBaseUrl) + }) + } + } + + test('loopback-port stale replacement clears the OPENAI_API_BASE alias when it was the source', () => { + // Source is the alias, no OPENAI_BASE_URL set: replacement must migrate the + // alias so a stale OPENAI_API_BASE does not linger alongside the new URL + // (LOW-1). + delete process.env.OPENAI_BASE_URL + process.env.OPENAI_API_BASE = 'http://localhost:11434/v1' + + const result = applyProviderFlag('xai', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL!).toBe('https://api.x.ai/v1') + expect(process.env.OPENAI_API_BASE).toBeUndefined() + }) +}) + describe('applyProviderFlag - descriptor-backed openai-compatible routes', () => { test('deepseek applies generic openai-compatible routing with descriptor defaults', () => { const result = applyProviderFlag('deepseek', []) @@ -544,6 +732,19 @@ describe('applyProviderFlag - descriptor-backed openai-compatible routes', () => expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:8181/v1') }) + test('gitlawb-opengateway explicit base URL resets stale OpenAI model', () => { + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.5' + process.env.OPENGATEWAY_BASE_URL = 'http://localhost:8181/v1' + + const result = applyProviderFlag('gitlawb-opengateway', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:8181/v1') + expect(process.env.OPENAI_MODEL).toBe('mimo-v2.5-pro') + }) + test('gitlawb-opengateway explicit provider preserves custom OPENAI_BASE_URL when no OPENGATEWAY_BASE_URL is set', () => { process.env.OPENAI_BASE_URL = 'http://localhost:8181/v1' process.env.OPENGATEWAY_API_KEY = 'fake-ogw-key' diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 3a8d24ce0d..3bf4031555 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -23,7 +23,7 @@ import { getVendor, isCloudflareBaseUrl, resolveProfileRoute, - resolveRouteIdFromBaseUrl, + resolveLocalCompatibleRouteIdFromBaseUrl, } from '../integrations/index.js' import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js' import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js' @@ -178,8 +178,13 @@ function getConfiguredOpenAIBaseUrl(): string | undefined { return normalizeBaseUrlEnv(process.env.OPENAI_API_BASE) } +// Aligned with the runtime capability path (resolveActiveRouteIdFromEnv +// → resolveLocalCompatibleRouteIdFromBaseUrl): a remote Ollama URL identified +// by hostname token is recognized as a known provider route for replacement, +// so switching to a different provider also retargets it instead of leaving +// the new provider's traffic routed at the old Ollama host. function shouldReplaceStaleKnownBaseUrl(provider: string): boolean { - const currentRouteId = resolveRouteIdFromBaseUrl( + const currentRouteId = resolveLocalCompatibleRouteIdFromBaseUrl( getConfiguredOpenAIBaseUrl(), ) if (!currentRouteId) { @@ -204,10 +209,21 @@ function isPlaceholderBaseUrl(baseUrl: string): boolean { return /<[^>]+>/.test(baseUrl) } -function applyOpenAIBaseUrlDefault(provider: string, baseUrl?: string): void { +type OpenAIBaseUrlDefaultResult = { + replacedStaleKnownBaseUrl: boolean +} + +const UNCHANGED_OPENAI_BASE_URL_DEFAULT: OpenAIBaseUrlDefaultResult = { + replacedStaleKnownBaseUrl: false, +} + +function applyOpenAIBaseUrlDefault( + provider: string, + baseUrl?: string, +): OpenAIBaseUrlDefaultResult { const normalizedBaseUrl = baseUrl?.trim() if (!normalizedBaseUrl) { - return + return UNCHANGED_OPENAI_BASE_URL_DEFAULT } // Never seed an unresolved placeholder endpoint. The user must supply a real @@ -215,14 +231,50 @@ function applyOpenAIBaseUrlDefault(provider: string, baseUrl?: string): void { // `/provider` wizard treats such defaults as requiring explicit setup, and // the CLI shortcut should not silently install a broken endpoint. if (isPlaceholderBaseUrl(normalizedBaseUrl)) { - return + return UNCHANGED_OPENAI_BASE_URL_DEFAULT } - if ( - !getConfiguredOpenAIBaseUrl() || - shouldReplaceStaleKnownBaseUrl(provider) - ) { + const configuredBaseUrl = getConfiguredOpenAIBaseUrl() + const replacedStaleKnownBaseUrl = + !!configuredBaseUrl && shouldReplaceStaleKnownBaseUrl(provider) + + if (!configuredBaseUrl || replacedStaleKnownBaseUrl) { + // The stale source may have been the OPENAI_API_BASE alias (no + // OPENAI_BASE_URL set): migrate it so request-time resolution picks up the + // new host and no lingering alias shadows the replacement (LOW-1). + if ( + replacedStaleKnownBaseUrl && + !process.env.OPENAI_BASE_URL && + normalizeBaseUrlEnv(process.env.OPENAI_API_BASE) === configuredBaseUrl + ) { + delete process.env.OPENAI_API_BASE + } process.env.OPENAI_BASE_URL = normalizedBaseUrl + return { replacedStaleKnownBaseUrl } + } + + return UNCHANGED_OPENAI_BASE_URL_DEFAULT +} + +function applyOpenAIModelDefault( + defaultModel: string | undefined, + baseUrlDefault: OpenAIBaseUrlDefaultResult, + model: string | null, +): void { + if (model) { + process.env.OPENAI_MODEL = model + return + } + + const normalizedDefaultModel = defaultModel?.trim() + if (!normalizedDefaultModel) { + return + } + + if (baseUrlDefault.replacedStaleKnownBaseUrl) { + process.env.OPENAI_MODEL = normalizedDefaultModel + } else { + process.env.OPENAI_MODEL ??= normalizedDefaultModel } } @@ -426,29 +478,58 @@ export function applyProviderFlag( case 'ollama': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= defaultBaseUrl ?? 'http://localhost:11434/v1' + { + const baseUrlDefault = applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'http://localhost:11434/v1', + ) + // Align with the other descriptor-backed providers: apply the default + // model on a fresh selection (`??=`) rather than only when a stale base + // URL was replaced, so `openclaude --provider ollama` with no prior + // OPENAI_MODEL still lands on the default tag (LOW-2). + applyOpenAIModelDefault( + defaultModel ?? 'llama3.1:8b', + baseUrlDefault, + model, + ) + } if (!process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = 'ollama' } - if (model) process.env.OPENAI_MODEL = model break case 'nvidia-nim': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= defaultBaseUrl ?? 'https://integrate.api.nvidia.com/v1' + { + const baseUrlDefault = applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://integrate.api.nvidia.com/v1', + ) + applyOpenAIModelDefault( + defaultModel ?? 'nvidia/llama-3.1-nemotron-70b-instruct', + baseUrlDefault, + model, + ) + } process.env.NVIDIA_NIM = '1' if (process.env.NVIDIA_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.NVIDIA_API_KEY } - process.env.OPENAI_MODEL ??= 'nvidia/llama-3.1-nemotron-70b-instruct' - if (model) process.env.OPENAI_MODEL = model break case 'bankr': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= defaultBaseUrl ?? 'https://llm.bankr.bot/v1' - process.env.OPENAI_MODEL ??= 'claude-opus-4.6' - if (model) process.env.OPENAI_MODEL = model + { + const baseUrlDefault = applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://llm.bankr.bot/v1', + ) + applyOpenAIModelDefault( + defaultModel ?? 'claude-opus-4.6', + baseUrlDefault, + model, + ) + } if (process.env.BNKR_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.BNKR_API_KEY } @@ -475,16 +556,26 @@ export function applyProviderFlag( case 'gitlawb-opengateway': process.env.CLAUDE_CODE_USE_OPENAI = '1' - if (process.env.OPENGATEWAY_BASE_URL?.trim()) { - process.env.OPENAI_BASE_URL = process.env.OPENGATEWAY_BASE_URL.trim() - } else { - applyOpenAIBaseUrlDefault( - provider, - defaultBaseUrl ?? 'https://opengateway.gitlawb.com/v1', + { + const explicitBaseUrl = process.env.OPENGATEWAY_BASE_URL?.trim() + const baseUrlDefault = explicitBaseUrl + ? { + replacedStaleKnownBaseUrl: + shouldReplaceStaleKnownBaseUrl(provider), + } + : applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://opengateway.gitlawb.com/v1', + ) + if (explicitBaseUrl) { + process.env.OPENAI_BASE_URL = explicitBaseUrl + } + applyOpenAIModelDefault( + defaultModel ?? 'mimo-v2.5-pro', + baseUrlDefault, + model, ) } - process.env.OPENAI_MODEL ??= defaultModel ?? 'mimo-v2.5-pro' - if (model) process.env.OPENAI_MODEL = model if (opengatewayApiKey) { process.env.OPENAI_API_KEY = opengatewayApiKey } @@ -492,11 +583,11 @@ export function applyProviderFlag( case 'nearai': process.env.CLAUDE_CODE_USE_OPENAI = '1' - applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) - if (defaultModel) { - process.env.OPENAI_MODEL ??= defaultModel - } - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel, + applyOpenAIBaseUrlDefault(provider, defaultBaseUrl), + model, + ) if (process.env.NEARAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.NEARAI_API_KEY } else { @@ -506,9 +597,14 @@ export function applyProviderFlag( case 'xai': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= 'https://api.x.ai/v1' - process.env.OPENAI_MODEL ??= defaultModel ?? 'grok-4.3' - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel ?? 'grok-4.3', + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.x.ai/v1', + ), + model, + ) if (process.env.XAI_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.XAI_API_KEY } @@ -516,9 +612,14 @@ export function applyProviderFlag( case 'xiaomi-mimo': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= defaultBaseUrl ?? 'https://api.xiaomimimo.com/v1' - process.env.OPENAI_MODEL ??= defaultModel ?? 'mimo-v2.5-pro' - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel ?? 'mimo-v2.5-pro', + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.xiaomimimo.com/v1', + ), + model, + ) if (process.env.MIMO_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.MIMO_API_KEY } @@ -526,12 +627,14 @@ export function applyProviderFlag( case 'xiaomi-mimo-token': process.env.CLAUDE_CODE_USE_OPENAI = '1' - applyOpenAIBaseUrlDefault( - provider, - defaultBaseUrl ?? 'https://token-plan-sgp.xiaomimimo.com/v1', + applyOpenAIModelDefault( + defaultModel ?? 'mimo-v2.5-pro', + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://token-plan-sgp.xiaomimimo.com/v1', + ), + model, ) - process.env.OPENAI_MODEL ??= defaultModel ?? 'mimo-v2.5-pro' - if (model) process.env.OPENAI_MODEL = model if (process.env.MIMO_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.MIMO_API_KEY } @@ -539,9 +642,14 @@ export function applyProviderFlag( case 'venice': process.env.CLAUDE_CODE_USE_OPENAI = '1' - process.env.OPENAI_BASE_URL ??= defaultBaseUrl ?? 'https://api.venice.ai/api/v1' - process.env.OPENAI_MODEL ??= defaultModel ?? 'venice-uncensored' - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel ?? 'venice-uncensored', + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.venice.ai/api/v1', + ), + model, + ) if (process.env.VENICE_API_KEY && !process.env.OPENAI_API_KEY) { process.env.OPENAI_API_KEY = process.env.VENICE_API_KEY } @@ -549,12 +657,14 @@ export function applyProviderFlag( case 'atlas-cloud': process.env.CLAUDE_CODE_USE_OPENAI = '1' - applyOpenAIBaseUrlDefault( - provider, - defaultBaseUrl ?? 'https://api.atlascloud.ai/v1', + applyOpenAIModelDefault( + defaultModel ?? 'deepseek-ai/deepseek-v4-pro', + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.atlascloud.ai/v1', + ), + model, ) - process.env.OPENAI_MODEL ??= defaultModel ?? 'deepseek-ai/deepseek-v4-pro' - if (model) process.env.OPENAI_MODEL = model // The dedicated key always wins so a lingering OPENAI_API_KEY from // another provider is never sent to Atlas Cloud; without it the // generic key is cleared for the same reason and validation reports @@ -568,11 +678,11 @@ export function applyProviderFlag( case 'fireworks': process.env.CLAUDE_CODE_USE_OPENAI = '1' - applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) - if (defaultModel) { - process.env.OPENAI_MODEL ??= defaultModel - } - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel, + applyOpenAIBaseUrlDefault(provider, defaultBaseUrl), + model, + ) if (process.env.FIREWORKS_API_KEY) { process.env.OPENAI_API_KEY = process.env.FIREWORKS_API_KEY } else { @@ -585,11 +695,11 @@ export function applyProviderFlag( // applyOpenAIBaseUrlDefault skips unresolved `<...>` placeholder // endpoints (the Cloudflare default carries ``), so the // user must export a real account-scoped base URL. - applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) - if (defaultModel) { - process.env.OPENAI_MODEL ??= defaultModel - } - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel, + applyOpenAIBaseUrlDefault(provider, defaultBaseUrl), + model, + ) // The Cloudflare transport reads the generic OpenAI-compatible auth // header, so mirror CLOUDFLARE_API_TOKEN into OPENAI_API_KEY the same way // nearai/fireworks mirror their dedicated keys. Gate it on the configured @@ -620,11 +730,11 @@ export function applyProviderFlag( default: process.env.CLAUDE_CODE_USE_OPENAI = '1' - applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) - if (defaultModel) { - process.env.OPENAI_MODEL ??= defaultModel - } - if (model) process.env.OPENAI_MODEL = model + applyOpenAIModelDefault( + defaultModel, + applyOpenAIBaseUrlDefault(provider, defaultBaseUrl), + model, + ) break } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 5157568163..9990bb9426 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -694,6 +694,194 @@ describe('applyProviderProfileToProcessEnv', () => { expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1') }) + test('local profiles apply auth-header env without API-format selection', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + const envKeys = [ + 'OPENAI_BASE_URL', + 'OPENAI_AUTH_HEADER', + 'OPENAI_AUTH_SCHEME', + 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_API_FORMAT', + 'OPENAI_MODEL', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ] as const + const savedEnv = Object.fromEntries( + envKeys.map(key => [key, process.env[key]]), + ) as Record<(typeof envKeys)[number], string | undefined> + const localProfiles = [ + { + name: 'Ollama', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1:8b', + }, + { + name: 'LM Studio', + baseUrl: 'http://localhost:1234/v1', + model: 'local-model', + }, + ] + + try { + for (const localProfile of localProfiles) { + applyProviderProfileToProcessEnv( + buildProfile({ + ...localProfile, + provider: 'openai', + apiFormat: 'responses', + authHeader: 'X-API-Key', + authScheme: 'raw', + authHeaderValue: `${localProfile.name}-secret`, + }), + ) + + expect(process.env.OPENAI_BASE_URL).toBe(localProfile.baseUrl) + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-API-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe( + `${localProfile.name}-secret`, + ) + expect(process.env.OPENAI_API_FORMAT).toBeUndefined() + } + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + } + }) + + test.each([ + 'https://ollama.corp.example.com/v1', + ])( + 'remote Ollama profile %s applies auth headers without API-format selection', + async baseUrl => { + const envKeys = [ + 'OPENAI_BASE_URL', + 'OPENAI_AUTH_HEADER', + 'OPENAI_AUTH_SCHEME', + 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_API_FORMAT', + 'OPENAI_MODEL', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ] as const + const savedEnv = Object.fromEntries( + envKeys.map(key => [key, process.env[key]]), + ) as Record<(typeof envKeys)[number], string | undefined> + + try { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + name: 'Remote Ollama', + provider: 'openai', + baseUrl, + model: 'llama3.1:8b', + apiFormat: 'responses', + authHeader: 'X-API-Key', + authScheme: 'raw', + authHeaderValue: 'remote-ollama-secret', + }), + ) + + const { resolveProviderRequest } = await import( + `../services/api/providerConfig.ts?ts=${Date.now()}-${Math.random()}` + ) + + expect(process.env.OPENAI_BASE_URL).toBe(baseUrl) + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-API-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe( + 'remote-ollama-secret', + ) + expect(process.env.OPENAI_API_FORMAT).toBeUndefined() + expect(resolveProviderRequest()).toMatchObject({ + transport: 'chat_completions', + baseUrl, + }) + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + } + }, + ) + + test.each([ + // Tightened remote-Ollama classifier: only a host whose dot-label is + // exactly `ollama` keeps Ollama route identity. A non-loopback :11434 + // port or a dash-embedded `my-ollama-server` label no longer matches the + // Ollama route, so as a non-local OpenAI-compatible profile they DO carry + // OPENAI_API_FORMAT (the `openai` route supports responses selection). + 'http://203.0.113.5:11434/v1', + 'http://my-ollama-server.example.com:11434/v1', + ])( + 'non-Ollama remote profile %s applies API-format selection', + async baseUrl => { + const envKeys = [ + 'OPENAI_BASE_URL', + 'OPENAI_AUTH_HEADER', + 'OPENAI_AUTH_SCHEME', + 'OPENAI_AUTH_HEADER_VALUE', + 'OPENAI_API_FORMAT', + 'OPENAI_MODEL', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', + 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', + ] as const + const savedEnv = Object.fromEntries( + envKeys.map(key => [key, process.env[key]]), + ) as Record<(typeof envKeys)[number], string | undefined> + + try { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + name: 'Remote Generic OpenAI-compatible', + provider: 'openai', + baseUrl, + model: 'gpt-oss-120b', + apiFormat: 'responses', + authHeader: 'X-API-Key', + authScheme: 'raw', + authHeaderValue: 'generic-secret', + }), + ) + + expect(process.env.OPENAI_BASE_URL).toBe(baseUrl) + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-API-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('generic-secret') + // The resolved capability route is `openai` (supportsApiFormatSelection + // is true), so the responses format is applied. + expect(process.env.OPENAI_API_FORMAT).toBe('responses') + } finally { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + } + }, + ) + test('minimax profile ignores advanced OpenAI-compatible auth settings', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -2596,6 +2784,77 @@ describe('setActiveProviderProfile', () => { } }) + test('persists local profile auth headers without API-format selection', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const localProfiles = [ + { + id: 'ollama_auth', + name: 'Ollama', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1:8b', + }, + { + id: 'lmstudio_auth', + name: 'LM Studio', + baseUrl: 'http://localhost:1234/v1', + model: 'local-model', + }, + ] + + for (const localProfile of localProfiles) { + const profile = buildProfile({ + ...localProfile, + provider: 'openai', + apiKey: '', + apiFormat: 'responses', + authHeader: 'X-API-Key', + authScheme: 'raw', + authHeaderValue: `${localProfile.name}-secret`, + }) + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [profile], + })) + + const result = setActiveProviderProfile(localProfile.id, { configDir }) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + + expect(result?.authHeader).toBe('X-API-Key') + expect(result?.authScheme).toBe('raw') + expect(result?.authHeaderValue).toBe(`${localProfile.name}-secret`) + expect(result?.apiFormat).toBeUndefined() + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-API-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe( + `${localProfile.name}-secret`, + ) + expect(process.env.OPENAI_API_FORMAT).toBeUndefined() + expect(persisted.profile).toBe('openai') + expect(persisted.env).toEqual({ + OPENAI_BASE_URL: localProfile.baseUrl, + OPENAI_MODEL: localProfile.model, + OPENAI_AUTH_HEADER: 'X-API-Key', + OPENAI_AUTH_SCHEME: 'raw', + OPENAI_AUTH_HEADER_VALUE: `${localProfile.name}-secret`, + }) + } + } finally { + process.chdir(originalCwd) + delete process.env.CLAUDE_CONFIG_DIR + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('persists primary model for keyed openai-compatible multi-model profiles', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 9351b16892..66f39dae2b 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -42,6 +42,7 @@ import { routeSupportsAuthHeaders, routeSupportsCustomHeaders, resolveProfileRoute, + resolveLocalCompatibleRouteIdFromBaseUrl, resolveRouteIdFromBaseUrl, type ResolvedProfileRoute, type ProviderPreset, @@ -217,7 +218,7 @@ function resolveProfileCapabilityRouteId( return providerRouteId } - const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl) + const routeIdFromBaseUrl = resolveLocalCompatibleRouteIdFromBaseUrl(baseUrl) if (routeIdFromBaseUrl) { return routeIdFromBaseUrl } @@ -1333,10 +1334,26 @@ function buildOpenAICompatibleStartupEnv( } } + // Persist OPENAI_API_FORMAT only when the resolver-level capability route + // supports selection — local gateways (ollama / lmstudio / loopback) + // expose neither the chat/responses shim selection nor the auth-header shim + // for which it would be serialized. applyProviderProfileToProcessEnv and + // resolveProviderRequest already drop apiFormat for local routes; mirror + // that gate here so the persisted .openclaude-profile.json does not carry a + // field the runtime never re-reads (LOW-3). + const capabilityRouteId = resolveProfileCapabilityRouteId( + activeProfile.provider, + activeProfile.baseUrl, + ) + const supportsApiFormat = + routeSupportsApiFormatSelection(capabilityRouteId) + const env: ProfileEnv = { OPENAI_BASE_URL: activeProfile.baseUrl, OPENAI_MODEL: getPrimaryModel(activeProfile.model), - ...(activeProfile.apiFormat ? { OPENAI_API_FORMAT: activeProfile.apiFormat } : {}), + ...(supportsApiFormat && activeProfile.apiFormat + ? { OPENAI_API_FORMAT: activeProfile.apiFormat } + : {}), ...(activeProfile.authHeader ? { OPENAI_AUTH_HEADER: activeProfile.authHeader } : {}), ...(activeProfile.authScheme ? { OPENAI_AUTH_SCHEME: activeProfile.authScheme } : {}), ...(activeProfile.authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: activeProfile.authHeaderValue } : {}),