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
This commit is contained in:
Technomancer702
2026-04-05 15:46:06 -07:00
committed by GitHub
parent 60d3d8961a
commit c534aa5771
15 changed files with 539 additions and 39 deletions

View File

@@ -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<void>(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)
})

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import type { CommandResultDisplay } from '../../commands.js'; import type { CommandResultDisplay } from '../../commands.js';
import { ModelPicker } from '../../components/ModelPicker.js'; import { ModelPicker } from '../../components/ModelPicker.js';
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.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 { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
import { useAppState, useSetAppState } from '../../state/AppState.js'; import { useAppState, useSetAppState } from '../../state/AppState.js';
import type { LocalJSXCommandCall } from '../../types/command.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 { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js';
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'; import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
import { validateModel } from '../../utils/model/validateModel.js'; import { validateModel } from '../../utils/model/validateModel.js';
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js';
function ModelPickerWrapper(t0) { function ModelPickerWrapper(t0) {
const $ = _c(17); const $ = _c(17);
const { const {
@@ -319,7 +321,9 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
}); });
return <SetModelAndClose args={args} onDone={onDone} />; return <SetModelAndClose args={args} onDone={onDone} />;
} }
await refreshOpenAIModelOptionsCache(); if (getAdditionalModelOptionsCacheScope()?.startsWith('openai:')) {
void refreshOpenAIModelOptionsCache();
}
return <ModelPickerWrapper onDone={onDone} />; return <ModelPickerWrapper onDone={onDone} />;
}; };
function renderModelLabel(model: string | null): string { function renderModelLabel(model: string | null): string {

View File

@@ -197,6 +197,21 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
expect(message).not.toContain('sk-secret-12345678') 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', () => { test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => {
const message = buildProfileSaveMessage( const message = buildProfileSaveMessage(
'gemini', 'gemini',
@@ -230,6 +245,36 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
expect(summary.endpointLabel).toBe('sk-...5678') 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', () => { test('getProviderWizardDefaults ignores poisoned current provider values', () => {
const defaults = getProviderWizardDefaults({ const defaults = getProviderWizardDefaults({
OPENAI_API_KEY: 'sk-secret-12345678', OPENAI_API_KEY: 'sk-secret-12345678',

View File

@@ -15,6 +15,7 @@ import { Box, Text } from '../../ink.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_BASE_URL,
isLocalProviderUrl,
resolveCodexApiCredentials, resolveCodexApiCredentials,
resolveProviderRequest, resolveProviderRequest,
} from '../../services/api/providerConfig.js' } from '../../services/api/providerConfig.js'
@@ -52,7 +53,11 @@ import {
recommendOllamaModel, recommendOllamaModel,
type RecommendationGoal, type RecommendationGoal,
} from '../../utils/providerRecommendation.js' } from '../../utils/providerRecommendation.js'
import { hasLocalOllama, listOllamaModels } from '../../utils/providerDiscovery.js' import {
getLocalOpenAICompatibleProviderLabel,
hasLocalOllama,
listOllamaModels,
} from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'clear' type ProviderChoice = 'auto' | ProviderProfile | 'clear'
@@ -182,10 +187,8 @@ export function buildCurrentProviderSummary(options?: {
let providerLabel = 'OpenAI-compatible' let providerLabel = 'OpenAI-compatible'
if (request.transport === 'codex_responses') { if (request.transport === 'codex_responses') {
providerLabel = 'Codex' providerLabel = 'Codex'
} else if (request.baseUrl.includes('localhost:11434')) { } else if (isLocalProviderUrl(request.baseUrl)) {
providerLabel = 'Ollama' providerLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl)
} else if (request.baseUrl.includes('localhost:1234')) {
providerLabel = 'LM Studio'
} }
return { return {
@@ -272,16 +275,20 @@ function buildSavedProfileSummary(
), ),
} }
case 'openai': case 'openai':
default: default: {
const baseUrl = env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL
return { return {
providerLabel: 'OpenAI-compatible', providerLabel: isLocalProviderUrl(baseUrl)
? getLocalOpenAICompatibleProviderLabel(baseUrl)
: 'OpenAI-compatible',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
env.OPENAI_MODEL ?? 'gpt-4o', env.OPENAI_MODEL ?? 'gpt-4o',
process.env, process.env,
env, env,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL, baseUrl,
process.env, process.env,
env, env,
), ),
@@ -291,6 +298,7 @@ function buildSavedProfileSummary(
: undefined, : undefined,
} }
} }
}
} }
export function buildProfileSaveMessage( export function buildProfileSaveMessage(

View File

@@ -5,6 +5,9 @@
* Addresses: https://github.com/Gitlawb/openclaude/issues/55 * 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 } declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string }
const ESC = '\x1b[' const ESC = '\x1b['
@@ -99,7 +102,7 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
if (useOpenAI) { if (useOpenAI) {
const rawModel = process.env.OPENAI_MODEL || 'gpt-4o' const rawModel = process.env.OPENAI_MODEL || 'gpt-4o'
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' 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' let name = 'OpenAI'
if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek' if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek'
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter' 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 (/groq/i.test(baseUrl)) name = 'Groq'
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel)) name = 'Mistral' else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel)) name = 'Mistral'
else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI' 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 (/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 // Resolve model alias to actual model name + reasoning effort
let displayModel = rawModel let displayModel = rawModel

View File

@@ -14,7 +14,16 @@ import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js' import { logError } from '../../utils/log.js'
import { getAPIProvider } from '../../utils/model/providers.js' import { getAPIProvider } from '../../utils/model/providers.js'
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.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 { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
import {
getAdditionalModelOptionsCacheScope,
resolveProviderRequest,
} from './providerConfig.js'
const bootstrapResponseSchema = lazySchema(() => const bootstrapResponseSchema = lazySchema(() =>
z.object({ z.object({
@@ -39,6 +48,12 @@ const bootstrapResponseSchema = lazySchema(() =>
type BootstrapResponse = z.infer<ReturnType<typeof bootstrapResponseSchema>> type BootstrapResponse = z.infer<ReturnType<typeof bootstrapResponseSchema>>
type BootstrapCachePayload = {
clientData: Record<string, unknown> | null
additionalModelOptions: ModelOption[]
additionalModelOptionsScope: string
}
async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> { async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
if (isEssentialTrafficOnly()) { if (isEssentialTrafficOnly()) {
logForDebugging('[Bootstrap] Skipped: Nonessential traffic disabled') logForDebugging('[Bootstrap] Skipped: Nonessential traffic disabled')
@@ -108,22 +123,70 @@ async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
} }
} }
async function fetchLocalOpenAIModelOptions(): Promise<BootstrapCachePayload | null> {
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. * Fetch bootstrap data from the API and persist to disk cache.
*/ */
export async function fetchBootstrapData(): Promise<void> { export async function fetchBootstrapData(): Promise<void> {
try { try {
const scope = getAdditionalModelOptionsCacheScope()
let payload: BootstrapCachePayload | null = null
if (scope === 'firstParty') {
const response = await fetchBootstrapAPI() const response = await fetchBootstrapAPI()
if (!response) return if (!response) return
const clientData = response.client_data ?? null payload = {
const additionalModelOptions = response.additional_model_options ?? [] 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. // Only persist if data actually changed — avoids a config write on every startup.
const config = getGlobalConfig() const config = getGlobalConfig()
if ( if (
isEqual(config.clientDataCache, clientData) && isEqual(config.clientDataCache, clientData) &&
isEqual(config.additionalModelOptionsCache, additionalModelOptions) isEqual(config.additionalModelOptionsCache, additionalModelOptions) &&
config.additionalModelOptionsCacheScope === additionalModelOptionsScope
) { ) {
logForDebugging('[Bootstrap] Cache unchanged, skipping write') logForDebugging('[Bootstrap] Cache unchanged, skipping write')
return return
@@ -134,6 +197,7 @@ export async function fetchBootstrapData(): Promise<void> {
...current, ...current,
clientDataCache: clientData, clientDataCache: clientData,
additionalModelOptionsCache: additionalModelOptions, additionalModelOptionsCache: additionalModelOptions,
additionalModelOptionsCacheScope: additionalModelOptionsScope,
})) }))
} catch (error) { } catch (error) {
logError(error) logError(error)

View File

@@ -14,12 +14,19 @@ import {
} from './providerConfig.js' } from './providerConfig.js'
const tempDirs: string[] = [] const tempDirs: string[] = []
const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
}
afterEach(() => { afterEach(() => {
while (tempDirs.length > 0) { while (tempDirs.length > 0) {
const dir = tempDirs.pop() const dir = tempDirs.pop()
if (dir) rmSync(dir, { recursive: true, force: true }) 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, unknown>): string { function createTempAuthJson(payload: Record<string, unknown>): string {
@@ -62,12 +69,26 @@ describe('Codex provider config', () => {
}) })
test('resolves codexplan alias to Codex transport with reasoning', () => { 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' }) const resolved = resolveProviderRequest({ model: 'codexplan' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.resolvedModel).toBe('gpt-5.4')
expect(resolved.reasoning).toEqual({ effort: 'high' }) 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"', () => { 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 // On Windows, env vars can leak as the literal string "undefined" instead of
// the JS value undefined when not properly unset (issue #336). // the JS value undefined when not properly unset (issue #336).

View File

@@ -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', () => { test('treats localhost endpoints as local', () => {
expect(isLocalProviderUrl('http://localhost:11434/v1')).toBe(true) 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('https://example.com/v1')).toBe(false)
expect(isLocalProviderUrl('http://[2001:4860:4860::8888]:11434/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()
})

View File

@@ -219,6 +219,14 @@ export function isCodexAlias(model: string): boolean {
return base in CODEX_ALIAS_MODELS 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 { export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false if (!baseUrl) return false
try { try {
@@ -302,13 +310,8 @@ export function resolveProviderRequest(options?: {
asEnvUrl(options?.baseUrl) ?? asEnvUrl(options?.baseUrl) ??
asEnvUrl(process.env.OPENAI_BASE_URL) ?? asEnvUrl(process.env.OPENAI_BASE_URL) ??
asEnvUrl(process.env.OPENAI_API_BASE) 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 = const transport: ProviderTransport =
isCodexBaseUrl(rawBaseUrl) || (!rawBaseUrl && isCodexAlias(requestedModel)) shouldUseCodexTransport(requestedModel, rawBaseUrl)
? 'codex_responses' ? 'codex_responses'
: 'chat_completions' : '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( export function resolveCodexAuthPath(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): string { ): string {

View File

@@ -576,6 +576,7 @@ export type GlobalConfig = {
// Additional model options for the model picker (fetched during bootstrap). // Additional model options for the model picker (fetched during bootstrap).
additionalModelOptionsCache?: ModelOption[] additionalModelOptionsCache?: ModelOption[]
additionalModelOptionsCacheScope?: string
// Additional model options discovered from OpenAI-compatible endpoints. // Additional model options discovered from OpenAI-compatible endpoints.
openaiAdditionalModelOptionsCache?: ModelOption[] openaiAdditionalModelOptionsCache?: ModelOption[]

View File

@@ -1,5 +1,6 @@
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered // biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
import { getInitialMainLoopModel } from '../../bootstrap/state.js' import { getInitialMainLoopModel } from '../../bootstrap/state.js'
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'
import { import {
isClaudeAISubscriber, isClaudeAISubscriber,
isMaxSubscriber, isMaxSubscriber,
@@ -44,6 +45,25 @@ export type ModelOption = {
descriptionForModel?: string 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 { export function getDefaultOptionForUser(fastMode = false): ModelOption {
if (process.env.USER_TYPE === 'ant') { if (process.env.USER_TYPE === 'ant') {
const currentModel = renderDefaultModelSetting( const currentModel = renderDefaultModelSetting(
@@ -408,6 +428,16 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
return standardOptions 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 // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
if (getAPIProvider() === 'firstParty') { if (getAPIProvider() === 'firstParty') {
const payg1POptions = [getDefaultOptionForUser(fastMode)] const payg1POptions = [getDefaultOptionForUser(fastMode)]
@@ -566,13 +596,8 @@ export function getModelOptions(fastMode = false): ModelOption[] {
}) })
} }
const additionalOptions = // Append additional model options fetched during bootstrap
getAPIProvider() === 'openai' for (const opt of getScopedAdditionalModelOptions()) {
? getActiveOpenAIModelOptionsCache()
: getGlobalConfig().additionalModelOptionsCache ?? []
// Append additional model options fetched during bootstrap/endpoints.
for (const opt of additionalOptions) {
if (!options.some(existing => existing.value === opt.value)) { if (!options.some(existing => existing.value === opt.value)) {
options.push(opt) options.push(opt)
} }

View File

@@ -7,6 +7,9 @@ const originalEnv = {
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX, CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, 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(() => { afterEach(() => {
@@ -16,6 +19,9 @@ afterEach(() => {
process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK 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_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX
process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY 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() { async function importFreshProvidersModule() {
@@ -29,6 +35,9 @@ function clearProviderEnv(): void {
delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY 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', () => { 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') 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')
})

View File

@@ -1,5 +1,5 @@
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' 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' import { isEnvTruthy } from '../envUtils.js'
export type APIProvider = export type APIProvider =
@@ -34,11 +34,10 @@ export function usesAnthropicAccountFlow(): boolean {
return getAPIProvider() === 'firstParty' return getAPIProvider() === 'firstParty'
} }
function isCodexModel(): boolean { function isCodexModel(): boolean {
const model = (process.env.OPENAI_MODEL || '').trim() return shouldUseCodexTransport(
if (!model) return false process.env.OPENAI_MODEL || '',
// Delegate to the canonical alias table in providerConfig to keep process.env.OPENAI_BASE_URL ?? process.env.OPENAI_API_BASE,
// the two Codex detection systems (provider type + transport) in sync. )
return isCodexAlias(model)
} }
export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {

View File

@@ -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')
})

View File

@@ -1,4 +1,5 @@
import type { OllamaModelDescriptor } from './providerRecommendation.ts' 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_OLLAMA_BASE_URL = 'http://localhost:11434'
export const DEFAULT_ATOMIC_CHAT_BASE_URL = 'http://127.0.0.1:1337' 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` 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<boolean> { export async function hasLocalOllama(baseUrl?: string): Promise<boolean> {
const { signal, clear } = withTimeoutSignal(1200) const { signal, clear } = withTimeoutSignal(1200)
try { try {
@@ -111,6 +170,46 @@ export async function listOllamaModels(
} }
} }
export async function listOpenAICompatibleModels(options?: {
baseUrl?: string
apiKey?: string
}): Promise<string[] | null> {
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<boolean> { export async function hasLocalAtomicChat(baseUrl?: string): Promise<boolean> {
const { signal, clear } = withTimeoutSignal(1200) const { signal, clear } = withTimeoutSignal(1200)
try { try {