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:
@@ -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<ReturnType<typeof bootstrapResponseSchema>>
|
||||
|
||||
type BootstrapCachePayload = {
|
||||
clientData: Record<string, unknown> | null
|
||||
additionalModelOptions: ModelOption[]
|
||||
additionalModelOptionsScope: string
|
||||
}
|
||||
|
||||
async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
|
||||
if (isEssentialTrafficOnly()) {
|
||||
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.
|
||||
*/
|
||||
export async function fetchBootstrapData(): Promise<void> {
|
||||
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<void> {
|
||||
...current,
|
||||
clientDataCache: clientData,
|
||||
additionalModelOptionsCache: additionalModelOptions,
|
||||
additionalModelOptionsCacheScope: additionalModelOptionsScope,
|
||||
}))
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
|
||||
@@ -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, unknown>): 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).
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user