From c534aa5771fb9e75c87270ecded2fd7da199259e Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Sun, 5 Apr 2026 15:46:06 -0700 Subject: [PATCH] Feature: Add local OpenAI-compatible model discovery to /model (#201) * Add local OpenAI-compatible model discovery to /model * Guard local OpenAI model discovery from Codex routing * Preserve remote OpenAI Codex alias behavior --- src/commands/model/model.test.tsx | 43 ++++++++ src/commands/model/model.tsx | 6 +- src/commands/provider/provider.test.tsx | 45 +++++++++ src/commands/provider/provider.tsx | 24 +++-- src/components/StartupScreen.ts | 9 +- src/services/api/bootstrap.ts | 74 +++++++++++++- src/services/api/codexShim.test.ts | 21 ++++ src/services/api/providerConfig.local.test.ts | 54 +++++++++- src/services/api/providerConfig.ts | 39 ++++++-- src/utils/config.ts | 1 + src/utils/model/modelOptions.ts | 39 ++++++-- src/utils/model/providers.test.ts | 35 +++++++ src/utils/model/providers.ts | 11 +-- src/utils/providerDiscovery.test.ts | 78 +++++++++++++++ src/utils/providerDiscovery.ts | 99 +++++++++++++++++++ 15 files changed, 539 insertions(+), 39 deletions(-) create mode 100644 src/commands/model/model.test.tsx create mode 100644 src/utils/providerDiscovery.test.ts diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx new file mode 100644 index 00000000..52cb8113 --- /dev/null +++ b/src/commands/model/model.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +const originalEnv = { + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_MODEL: process.env.OPENAI_MODEL, +} + +afterEach(() => { + mock.restore() + process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI + process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL +}) + +test('opens the model picker without awaiting local model discovery refresh', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1' + process.env.OPENAI_MODEL = 'qwen2.5-coder-7b-instruct' + + let resolveDiscovery: (() => void) | undefined + const discoverOpenAICompatibleModelOptions = mock( + () => + new Promise(resolve => { + resolveDiscovery = resolve + }), + ) + + mock.module('../../utils/model/openaiModelDiscovery.js', () => ({ + discoverOpenAICompatibleModelOptions, + })) + + const { call } = await import('./model.js') + const result = await Promise.race([ + call(() => {}, {} as never, ''), + new Promise(resolve => setTimeout(() => resolve('timeout'), 50)), + ]) + + resolveDiscovery?.() + + expect(result).not.toBe('timeout') + expect(discoverOpenAICompatibleModelOptions).toHaveBeenCalledTimes(1) +}) \ No newline at end of file diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index e03cf6bd..95c22ed2 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import type { CommandResultDisplay } from '../../commands.js'; import { ModelPicker } from '../../components/ModelPicker.js'; import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; +import { fetchBootstrapData } from '../../services/api/bootstrap.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; import { useAppState, useSetAppState } from '../../state/AppState.js'; import type { LocalJSXCommandCall } from '../../types/command.js'; @@ -19,6 +20,7 @@ import { getActiveOpenAIModelOptionsCache, setActiveOpenAIModelOptionsCache } fr import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js'; import { isModelAllowed } from '../../utils/model/modelAllowlist.js'; import { validateModel } from '../../utils/model/validateModel.js'; +import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'; function ModelPickerWrapper(t0) { const $ = _c(17); const { @@ -319,7 +321,9 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => { }); return ; } - await refreshOpenAIModelOptionsCache(); + if (getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) { + void refreshOpenAIModelOptionsCache(); + } return ; }; function renderModelLabel(model: string | null): string { diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index c04c2d18..ac17558e 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -197,6 +197,21 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', () expect(message).not.toContain('sk-secret-12345678') }) +test('buildProfileSaveMessage labels local openai-compatible profiles consistently', () => { + const message = buildProfileSaveMessage( + 'openai', + { + OPENAI_MODEL: 'gpt-5.4', + OPENAI_BASE_URL: 'http://127.0.0.1:8080/v1', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + ) + + expect(message).toContain('Saved Local OpenAI-compatible profile.') + expect(message).toContain('Model: gpt-5.4') + expect(message).toContain('Endpoint: http://127.0.0.1:8080/v1') +}) + test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => { const message = buildProfileSaveMessage( 'gemini', @@ -230,6 +245,36 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', ( expect(summary.endpointLabel).toBe('sk-...5678') }) +test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => { + const summary = buildCurrentProviderSummary({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_MODEL: 'qwen2.5-coder-7b-instruct', + OPENAI_BASE_URL: 'http://127.0.0.1:8080/v1', + }, + persisted: null, + }) + + expect(summary.providerLabel).toBe('Local OpenAI-compatible') + expect(summary.modelLabel).toBe('qwen2.5-coder-7b-instruct') + expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1') +}) + +test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex', () => { + const summary = buildCurrentProviderSummary({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_MODEL: 'gpt-5.4', + OPENAI_BASE_URL: 'http://127.0.0.1:8080/v1', + }, + persisted: null, + }) + + expect(summary.providerLabel).toBe('Local OpenAI-compatible') + expect(summary.modelLabel).toBe('gpt-5.4') + expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1') +}) + test('getProviderWizardDefaults ignores poisoned current provider values', () => { const defaults = getProviderWizardDefaults({ OPENAI_API_KEY: 'sk-secret-12345678', diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 43361997..7ed57b61 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -15,6 +15,7 @@ import { Box, Text } from '../../ink.js' import { DEFAULT_CODEX_BASE_URL, DEFAULT_OPENAI_BASE_URL, + isLocalProviderUrl, resolveCodexApiCredentials, resolveProviderRequest, } from '../../services/api/providerConfig.js' @@ -52,7 +53,11 @@ import { recommendOllamaModel, type RecommendationGoal, } from '../../utils/providerRecommendation.js' -import { hasLocalOllama, listOllamaModels } from '../../utils/providerDiscovery.js' +import { + getLocalOpenAICompatibleProviderLabel, + hasLocalOllama, + listOllamaModels, +} from '../../utils/providerDiscovery.js' type ProviderChoice = 'auto' | ProviderProfile | 'clear' @@ -182,10 +187,8 @@ export function buildCurrentProviderSummary(options?: { let providerLabel = 'OpenAI-compatible' if (request.transport === 'codex_responses') { providerLabel = 'Codex' - } else if (request.baseUrl.includes('localhost:11434')) { - providerLabel = 'Ollama' - } else if (request.baseUrl.includes('localhost:1234')) { - providerLabel = 'LM Studio' + } else if (isLocalProviderUrl(request.baseUrl)) { + providerLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl) } return { @@ -272,16 +275,20 @@ function buildSavedProfileSummary( ), } case 'openai': - default: + default: { + const baseUrl = env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL + return { - providerLabel: 'OpenAI-compatible', + providerLabel: isLocalProviderUrl(baseUrl) + ? getLocalOpenAICompatibleProviderLabel(baseUrl) + : 'OpenAI-compatible', modelLabel: getSafeDisplayValue( env.OPENAI_MODEL ?? 'gpt-4o', process.env, env, ), endpointLabel: getSafeDisplayValue( - env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL, + baseUrl, process.env, env, ), @@ -290,6 +297,7 @@ function buildSavedProfileSummary( ? 'configured' : undefined, } + } } } diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts index e38a4111..77b08f07 100644 --- a/src/components/StartupScreen.ts +++ b/src/components/StartupScreen.ts @@ -5,6 +5,9 @@ * Addresses: https://github.com/Gitlawb/openclaude/issues/55 */ +import { isLocalProviderUrl } from '../services/api/providerConfig.js' +import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js' + declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string } const ESC = '\x1b[' @@ -99,7 +102,7 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc if (useOpenAI) { const rawModel = process.env.OPENAI_MODEL || 'gpt-4o' const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' - const isLocal = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(baseUrl) + const isLocal = isLocalProviderUrl(baseUrl) let name = 'OpenAI' if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek' else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter' @@ -107,10 +110,8 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc else if (/groq/i.test(baseUrl)) name = 'Groq' else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel)) name = 'Mistral' else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI' - else if (/localhost:11434/i.test(baseUrl)) name = 'Ollama' - else if (/localhost:1234/i.test(baseUrl)) name = 'LM Studio' else if (/llama/i.test(rawModel)) name = 'Meta Llama' - else if (isLocal) name = 'Local' + else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl) // Resolve model alias to actual model name + reasoning effort let displayModel = rawModel diff --git a/src/services/api/bootstrap.ts b/src/services/api/bootstrap.ts index 82ef0d6c..c11f6ca4 100644 --- a/src/services/api/bootstrap.ts +++ b/src/services/api/bootstrap.ts @@ -14,7 +14,16 @@ import { lazySchema } from '../../utils/lazySchema.js' import { logError } from '../../utils/log.js' import { getAPIProvider } from '../../utils/model/providers.js' import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' +import type { ModelOption } from '../../utils/model/modelOptions.js' +import { + getLocalOpenAICompatibleProviderLabel, + listOpenAICompatibleModels, +} from '../../utils/providerDiscovery.js' import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' +import { + getAdditionalModelOptionsCacheScope, + resolveProviderRequest, +} from './providerConfig.js' const bootstrapResponseSchema = lazySchema(() => z.object({ @@ -39,6 +48,12 @@ const bootstrapResponseSchema = lazySchema(() => type BootstrapResponse = z.infer> +type BootstrapCachePayload = { + clientData: Record | null + additionalModelOptions: ModelOption[] + additionalModelOptionsScope: string +} + async function fetchBootstrapAPI(): Promise { if (isEssentialTrafficOnly()) { logForDebugging('[Bootstrap] Skipped: Nonessential traffic disabled') @@ -108,22 +123,70 @@ async function fetchBootstrapAPI(): Promise { } } +async function fetchLocalOpenAIModelOptions(): Promise { + const scope = getAdditionalModelOptionsCacheScope() + if (!scope?.startsWith('openai:')) { + return null + } + + const { baseUrl } = resolveProviderRequest() + const models = await listOpenAICompatibleModels({ + baseUrl, + apiKey: process.env.OPENAI_API_KEY, + }) + + if (models === null) { + logForDebugging('[Bootstrap] Local OpenAI model discovery failed') + return null + } + + const providerLabel = getLocalOpenAICompatibleProviderLabel(baseUrl) + + return { + clientData: getGlobalConfig().clientDataCache ?? null, + additionalModelOptionsScope: scope, + additionalModelOptions: models.map(model => ({ + value: model, + label: model, + description: `Detected from ${providerLabel}`, + })), + } +} + /** * Fetch bootstrap data from the API and persist to disk cache. */ export async function fetchBootstrapData(): Promise { try { - const response = await fetchBootstrapAPI() - if (!response) return + const scope = getAdditionalModelOptionsCacheScope() + let payload: BootstrapCachePayload | null = null - const clientData = response.client_data ?? null - const additionalModelOptions = response.additional_model_options ?? [] + if (scope === 'firstParty') { + const response = await fetchBootstrapAPI() + if (!response) return + + payload = { + clientData: response.client_data ?? null, + additionalModelOptions: response.additional_model_options ?? [], + additionalModelOptionsScope: scope, + } + } else if (scope?.startsWith('openai:')) { + payload = await fetchLocalOpenAIModelOptions() + if (!payload) return + } else { + logForDebugging('[Bootstrap] Skipped: no additional model source') + return + } + + const { clientData, additionalModelOptions, additionalModelOptionsScope } = + payload // Only persist if data actually changed — avoids a config write on every startup. const config = getGlobalConfig() if ( isEqual(config.clientDataCache, clientData) && - isEqual(config.additionalModelOptionsCache, additionalModelOptions) + isEqual(config.additionalModelOptionsCache, additionalModelOptions) && + config.additionalModelOptionsCacheScope === additionalModelOptionsScope ) { logForDebugging('[Bootstrap] Cache unchanged, skipping write') return @@ -134,6 +197,7 @@ export async function fetchBootstrapData(): Promise { ...current, clientDataCache: clientData, additionalModelOptionsCache: additionalModelOptions, + additionalModelOptionsCacheScope: additionalModelOptionsScope, })) } catch (error) { logError(error) diff --git a/src/services/api/codexShim.test.ts b/src/services/api/codexShim.test.ts index 79ab085e..60791947 100644 --- a/src/services/api/codexShim.test.ts +++ b/src/services/api/codexShim.test.ts @@ -14,12 +14,19 @@ import { } from './providerConfig.js' const tempDirs: string[] = [] +const originalEnv = { + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, +} afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop() if (dir) rmSync(dir, { recursive: true, force: true }) } + + process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE }) function createTempAuthJson(payload: Record): string { @@ -62,12 +69,26 @@ describe('Codex provider config', () => { }) test('resolves codexplan alias to Codex transport with reasoning', () => { + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_BASE + const resolved = resolveProviderRequest({ model: 'codexplan' }) expect(resolved.transport).toBe('codex_responses') expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.reasoning).toEqual({ effort: 'high' }) }) + test('does not force Codex transport when a local non-Codex base URL is explicit', () => { + const resolved = resolveProviderRequest({ + model: 'codexplan', + baseUrl: 'http://127.0.0.1:8080/v1', + }) + + expect(resolved.transport).toBe('chat_completions') + expect(resolved.baseUrl).toBe('http://127.0.0.1:8080/v1') + expect(resolved.resolvedModel).toBe('gpt-5.4') + }) + test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => { // On Windows, env vars can leak as the literal string "undefined" instead of // the JS value undefined when not properly unset (issue #336). diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index 9adf0d57..9622998c 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -1,6 +1,22 @@ -import { expect, test } from 'bun:test' +import { afterEach, expect, test } from 'bun:test' -import { isLocalProviderUrl } from './providerConfig.js' +import { + getAdditionalModelOptionsCacheScope, + isLocalProviderUrl, + resolveProviderRequest, +} from './providerConfig.js' + +const originalEnv = { + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_MODEL: process.env.OPENAI_MODEL, +} + +afterEach(() => { + process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI + process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL +}) test('treats localhost endpoints as local', () => { expect(isLocalProviderUrl('http://localhost:11434/v1')).toBe(true) @@ -33,3 +49,37 @@ test('treats public hosts as remote', () => { expect(isLocalProviderUrl('https://example.com/v1')).toBe(false) expect(isLocalProviderUrl('http://[2001:4860:4860::8888]:11434/v1')).toBe(false) }) + +test('creates a cache scope for local openai-compatible providers', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:1234/v1' + process.env.OPENAI_MODEL = 'llama-3.2-3b-instruct' + + expect(getAdditionalModelOptionsCacheScope()).toBe( + 'openai:http://localhost:1234/v1', + ) +}) + +test('keeps codex alias models on chat completions for local openai-compatible providers', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1' + process.env.OPENAI_MODEL = 'gpt-5.4' + + expect(resolveProviderRequest()).toMatchObject({ + transport: 'chat_completions', + requestedModel: 'gpt-5.4', + resolvedModel: 'gpt-5.4', + baseUrl: 'http://127.0.0.1:8080/v1', + }) + expect(getAdditionalModelOptionsCacheScope()).toBe( + 'openai:http://127.0.0.1:8080/v1', + ) +}) + +test('skips local model cache scope for remote openai-compatible providers', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-4o' + + expect(getAdditionalModelOptionsCacheScope()).toBeNull() +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index c8dd717c..768f95d5 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -219,6 +219,14 @@ export function isCodexAlias(model: string): boolean { return base in CODEX_ALIAS_MODELS } +export function shouldUseCodexTransport( + model: string, + baseUrl: string | undefined, +): boolean { + const explicitBaseUrl = asEnvUrl(baseUrl) + return isCodexBaseUrl(explicitBaseUrl) || (!explicitBaseUrl && isCodexAlias(model)) +} + export function isLocalProviderUrl(baseUrl: string | undefined): boolean { if (!baseUrl) return false try { @@ -302,13 +310,8 @@ export function resolveProviderRequest(options?: { asEnvUrl(options?.baseUrl) ?? asEnvUrl(process.env.OPENAI_BASE_URL) ?? asEnvUrl(process.env.OPENAI_API_BASE) - // Use Codex transport only when: - // - the base URL is explicitly the Codex endpoint, OR - // - the model is a Codex alias AND no custom base URL has been set - // A custom OPENAI_BASE_URL (e.g. Azure, OpenRouter) always wins over - // model-name-based Codex detection to prevent auth failures (#200, #203). const transport: ProviderTransport = - isCodexBaseUrl(rawBaseUrl) || (!rawBaseUrl && isCodexAlias(requestedModel)) + shouldUseCodexTransport(requestedModel, rawBaseUrl) ? 'codex_responses' : 'chat_completions' @@ -337,6 +340,30 @@ export function resolveProviderRequest(options?: { } } +export function getAdditionalModelOptionsCacheScope(): string | null { + if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && + !isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) { + return 'firstParty' + } + return null + } + + const request = resolveProviderRequest() + if (request.transport !== 'chat_completions') { + return null + } + + if (!isLocalProviderUrl(request.baseUrl)) { + return null + } + + return `openai:${request.baseUrl.toLowerCase()}` +} + export function resolveCodexAuthPath( env: NodeJS.ProcessEnv = process.env, ): string { diff --git a/src/utils/config.ts b/src/utils/config.ts index 3bfa5a56..0acfdad6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -576,6 +576,7 @@ export type GlobalConfig = { // Additional model options for the model picker (fetched during bootstrap). additionalModelOptionsCache?: ModelOption[] + additionalModelOptionsCacheScope?: string // Additional model options discovered from OpenAI-compatible endpoints. openaiAdditionalModelOptionsCache?: ModelOption[] diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 776d5ec3..343bf82e 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -1,5 +1,6 @@ // biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered import { getInitialMainLoopModel } from '../../bootstrap/state.js' +import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js' import { isClaudeAISubscriber, isMaxSubscriber, @@ -44,6 +45,25 @@ export type ModelOption = { descriptionForModel?: string } +function getScopedAdditionalModelOptions(): ModelOption[] { + const config = getGlobalConfig() + const activeScope = getAdditionalModelOptionsCacheScope() + + if (!activeScope) { + return [] + } + + if (config.additionalModelOptionsCacheScope !== undefined) { + return config.additionalModelOptionsCacheScope === activeScope + ? (config.additionalModelOptionsCache ?? []) + : [] + } + + return activeScope === 'firstParty' + ? (config.additionalModelOptionsCache ?? []) + : [] +} + export function getDefaultOptionForUser(fastMode = false): ModelOption { if (process.env.USER_TYPE === 'ant') { const currentModel = renderDefaultModelSetting( @@ -408,6 +428,16 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { return standardOptions } + if (getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) { + const activeOpenAIOptions = getActiveOpenAIModelOptionsCache() + return [ + getDefaultOptionForUser(fastMode), + ...(activeOpenAIOptions.length > 0 + ? activeOpenAIOptions + : getScopedAdditionalModelOptions()), + ] + } + // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku if (getAPIProvider() === 'firstParty') { const payg1POptions = [getDefaultOptionForUser(fastMode)] @@ -566,13 +596,8 @@ export function getModelOptions(fastMode = false): ModelOption[] { }) } - const additionalOptions = - getAPIProvider() === 'openai' - ? getActiveOpenAIModelOptionsCache() - : getGlobalConfig().additionalModelOptionsCache ?? [] - - // Append additional model options fetched during bootstrap/endpoints. - for (const opt of additionalOptions) { + // Append additional model options fetched during bootstrap + for (const opt of getScopedAdditionalModelOptions()) { if (!options.some(existing => existing.value === opt.value)) { options.push(opt) } diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index 272ed17b..ec8542f3 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -7,6 +7,9 @@ const originalEnv = { CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + OPENAI_MODEL: process.env.OPENAI_MODEL, } afterEach(() => { @@ -16,6 +19,9 @@ afterEach(() => { process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY + process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE + process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL }) async function importFreshProvidersModule() { @@ -29,6 +35,9 @@ function clearProviderEnv(): void { delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_BASE + delete process.env.OPENAI_MODEL } test('first-party provider keeps Anthropic account setup flow enabled', () => { @@ -69,3 +78,29 @@ test('GEMINI takes precedence over GitHub when both are set', async () => { expect(getAPIProvider()).toBe('gemini') }) + +test('explicit local openai-compatible base URLs stay on the openai provider', () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1' + process.env.OPENAI_MODEL = 'gpt-5.4' + + expect(getAPIProvider()).toBe('openai') +}) + +test('codex aliases still resolve to the codex provider without a non-codex base URL', () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_MODEL = 'codexplan' + + expect(getAPIProvider()).toBe('codex') +}) + +test('official OpenAI base URLs now keep provider detection on openai for aliases', () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + process.env.OPENAI_MODEL = 'gpt-5.4' + + expect(getAPIProvider()).toBe('openai') +}) diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 2c25163b..d65ee982 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,5 +1,5 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' -import { isCodexAlias } from '../../services/api/providerConfig.js' +import { shouldUseCodexTransport } from '../../services/api/providerConfig.js' import { isEnvTruthy } from '../envUtils.js' export type APIProvider = @@ -34,11 +34,10 @@ export function usesAnthropicAccountFlow(): boolean { return getAPIProvider() === 'firstParty' } function isCodexModel(): boolean { - const model = (process.env.OPENAI_MODEL || '').trim() - if (!model) return false - // Delegate to the canonical alias table in providerConfig to keep - // the two Codex detection systems (provider type + transport) in sync. - return isCodexAlias(model) + return shouldUseCodexTransport( + process.env.OPENAI_MODEL || '', + process.env.OPENAI_BASE_URL ?? process.env.OPENAI_API_BASE, + ) } export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { diff --git a/src/utils/providerDiscovery.test.ts b/src/utils/providerDiscovery.test.ts new file mode 100644 index 00000000..acf62748 --- /dev/null +++ b/src/utils/providerDiscovery.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import { + getLocalOpenAICompatibleProviderLabel, + listOpenAICompatibleModels, +} from './providerDiscovery.js' + +const originalFetch = globalThis.fetch +const originalEnv = { + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, +} + +afterEach(() => { + globalThis.fetch = originalFetch + process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL +}) + +test('lists models from a local openai-compatible /models endpoint', async () => { + globalThis.fetch = mock((input, init) => { + const url = typeof input === 'string' ? input : input.url + expect(url).toBe('http://localhost:1234/v1/models') + expect(init?.headers).toEqual({ Authorization: 'Bearer local-key' }) + + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { id: 'qwen2.5-coder-7b-instruct' }, + { id: 'llama-3.2-3b-instruct' }, + { id: 'qwen2.5-coder-7b-instruct' }, + ], + }), + { status: 200 }, + ), + ) + }) as typeof globalThis.fetch + + await expect( + listOpenAICompatibleModels({ + baseUrl: 'http://localhost:1234/v1', + apiKey: 'local-key', + }), + ).resolves.toEqual([ + 'qwen2.5-coder-7b-instruct', + 'llama-3.2-3b-instruct', + ]) +}) + +test('returns null when a local openai-compatible /models request fails', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response('not available', { status: 503 })), + ) as typeof globalThis.fetch + + await expect( + listOpenAICompatibleModels({ baseUrl: 'http://localhost:1234/v1' }), + ).resolves.toBeNull() +}) + +test('detects LM Studio from the default localhost port', () => { + expect(getLocalOpenAICompatibleProviderLabel('http://localhost:1234/v1')).toBe( + 'LM Studio', + ) +}) + +test('detects common local openai-compatible providers by hostname', () => { + expect( + getLocalOpenAICompatibleProviderLabel('http://localai.local:8080/v1'), + ).toBe('LocalAI') + expect( + getLocalOpenAICompatibleProviderLabel('http://vllm.local:8000/v1'), + ).toBe('vLLM') +}) + +test('falls back to a generic local openai-compatible label', () => { + expect( + getLocalOpenAICompatibleProviderLabel('http://127.0.0.1:8080/v1'), + ).toBe('Local OpenAI-compatible') +}) \ No newline at end of file diff --git a/src/utils/providerDiscovery.ts b/src/utils/providerDiscovery.ts index 5e209d65..54c8d235 100644 --- a/src/utils/providerDiscovery.ts +++ b/src/utils/providerDiscovery.ts @@ -1,4 +1,5 @@ import type { OllamaModelDescriptor } from './providerRecommendation.ts' +import { DEFAULT_OPENAI_BASE_URL } from '../services/api/providerConfig.js' export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434' export const DEFAULT_ATOMIC_CHAT_BASE_URL = 'http://127.0.0.1:1337' @@ -53,6 +54,64 @@ export function getAtomicChatChatBaseUrl(baseUrl?: string): string { return `${getAtomicChatApiBaseUrl(baseUrl)}/v1` } +export function getOpenAICompatibleModelsBaseUrl(baseUrl?: string): string { + return ( + baseUrl || process.env.OPENAI_BASE_URL || DEFAULT_OPENAI_BASE_URL + ).replace(/\/+$/, '') +} + +export function getLocalOpenAICompatibleProviderLabel(baseUrl?: string): string { + try { + const parsed = new URL(getOpenAICompatibleModelsBaseUrl(baseUrl)) + const host = parsed.host.toLowerCase() + const hostname = parsed.hostname.toLowerCase() + const path = parsed.pathname.toLowerCase() + const haystack = `${hostname} ${path}` + + if ( + host.endsWith(':1234') || + haystack.includes('lmstudio') || + haystack.includes('lm-studio') + ) { + return 'LM Studio' + } + if (host.endsWith(':11434') || haystack.includes('ollama')) { + return 'Ollama' + } + if (haystack.includes('localai')) { + return 'LocalAI' + } + if (haystack.includes('jan')) { + return 'Jan' + } + if (haystack.includes('kobold')) { + return 'KoboldCpp' + } + if (haystack.includes('llama.cpp') || haystack.includes('llamacpp')) { + return 'llama.cpp' + } + if (haystack.includes('vllm')) { + return 'vLLM' + } + if ( + haystack.includes('open-webui') || + haystack.includes('openwebui') + ) { + return 'Open WebUI' + } + if ( + haystack.includes('text-generation-webui') || + haystack.includes('oobabooga') + ) { + return 'text-generation-webui' + } + } catch { + // Fall back to the generic label when the base URL is malformed. + } + + return 'Local OpenAI-compatible' +} + export async function hasLocalOllama(baseUrl?: string): Promise { const { signal, clear } = withTimeoutSignal(1200) try { @@ -111,6 +170,46 @@ export async function listOllamaModels( } } +export async function listOpenAICompatibleModels(options?: { + baseUrl?: string + apiKey?: string +}): Promise { + const { signal, clear } = withTimeoutSignal(5000) + try { + const response = await fetch( + `${getOpenAICompatibleModelsBaseUrl(options?.baseUrl)}/models`, + { + method: 'GET', + headers: options?.apiKey + ? { + Authorization: `Bearer ${options.apiKey}`, + } + : undefined, + signal, + }, + ) + if (!response.ok) { + return null + } + + const data = (await response.json()) as { + data?: Array<{ id?: string }> + } + + return Array.from( + new Set( + (data.data ?? []) + .filter(model => Boolean(model.id)) + .map(model => model.id!), + ), + ) + } catch { + return null + } finally { + clear() + } +} + export async function hasLocalAtomicChat(baseUrl?: string): Promise { const { signal, clear } = withTimeoutSignal(1200) try {