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:
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
78
src/utils/providerDiscovery.test.ts
Normal file
78
src/utils/providerDiscovery.test.ts
Normal 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')
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
const { signal, clear } = withTimeoutSignal(1200)
|
||||
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> {
|
||||
const { signal, clear } = withTimeoutSignal(1200)
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user