From 2835426cf31bbdf3bc9f9bda81f2431262fb85ef Mon Sep 17 00:00:00 2001 From: cynicalight <391900788@qq.com> Date: Wed, 29 Apr 2026 21:00:34 +0800 Subject: [PATCH] fix: enable the local apikey models while using openai auth subscription --- src/server/__tests__/settings.test.ts | 134 ++++++++++++++++++++++++++ src/server/api/models.ts | 124 +++++++++++++++++++++--- src/utils/auth.ts | 5 + src/utils/model/modelOptions.ts | 65 +++++++++---- 4 files changed, 294 insertions(+), 34 deletions(-) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index f776ab58..0f80f3b0 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -11,6 +11,12 @@ import { handleSettingsApi } from '../api/settings.js' import { handleModelsApi } from '../api/models.js' import { handleStatusApi, resetUsage, addUsage } from '../api/status.js' import { ProviderService } from '../services/providerService.js' +import { + clearOpenAIOAuthTokenCache, + deleteOpenAIOAuthTokens, + saveOpenAIOAuthTokens, +} from '../../services/openaiAuth/storage.js' +import { getModelOptions } from '../../utils/model/modelOptions.js' // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -21,6 +27,12 @@ let originalUserProfile: string | undefined let originalShell: string | undefined let originalPath: string | undefined let originalCliPath: string | undefined +let originalAnthropicApiKey: string | undefined +let originalAnthropicBaseUrl: string | undefined +let originalAnthropicModel: string | undefined +let originalAnthropicDefaultHaikuModel: string | undefined +let originalAnthropicDefaultSonnetModel: string | undefined +let originalAnthropicDefaultOpusModel: string | undefined async function setup() { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-')) @@ -30,11 +42,24 @@ async function setup() { originalShell = process.env.SHELL originalPath = process.env.PATH originalCliPath = process.env.CLAUDE_CLI_PATH + originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY + originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL + originalAnthropicModel = process.env.ANTHROPIC_MODEL + originalAnthropicDefaultHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + originalAnthropicDefaultSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + originalAnthropicDefaultOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.HOME = tmpDir process.env.USERPROFILE = tmpDir process.env.SHELL = '/bin/zsh' process.env.PATH = '' + delete process.env.ANTHROPIC_API_KEY + delete process.env.ANTHROPIC_BASE_URL + delete process.env.ANTHROPIC_MODEL + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + clearOpenAIOAuthTokenCache() } async function teardown() { @@ -74,6 +99,45 @@ async function teardown() { delete process.env.CLAUDE_CLI_PATH } + if (originalAnthropicApiKey !== undefined) { + process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey + } else { + delete process.env.ANTHROPIC_API_KEY + } + + if (originalAnthropicBaseUrl !== undefined) { + process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl + } else { + delete process.env.ANTHROPIC_BASE_URL + } + + if (originalAnthropicModel !== undefined) { + process.env.ANTHROPIC_MODEL = originalAnthropicModel + } else { + delete process.env.ANTHROPIC_MODEL + } + + if (originalAnthropicDefaultHaikuModel !== undefined) { + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = originalAnthropicDefaultHaikuModel + } else { + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + } + + if (originalAnthropicDefaultSonnetModel !== undefined) { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = originalAnthropicDefaultSonnetModel + } else { + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + } + + if (originalAnthropicDefaultOpusModel !== undefined) { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = originalAnthropicDefaultOpusModel + } else { + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + } + + deleteOpenAIOAuthTokens() + clearOpenAIOAuthTokenCache() + await fs.rm(tmpDir, { recursive: true, force: true }) } @@ -324,6 +388,34 @@ describe('Models API', () => { expect(body.models[0].id).toContain('claude') }) + it('GET /api/models should merge env-configured provider models with saved OpenAI OAuth models', async () => { + process.env.ANTHROPIC_API_KEY = 'deepseek-key' + process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic' + process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro' + saveOpenAIOAuthTokens({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000, + }) + + const { req, url, segments } = makeRequest('GET', '/api/models') + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() + const ids = body.models.map((model: { id: string }) => model.id) + + expect(ids).toContain('deepseek-v4-pro') + expect(ids).toContain('deepseek-v4-flash') + expect(ids).toContain('gpt-5.3-codex') + expect(ids).toContain('gpt-5.4') + expect(ids).toContain('gpt-5.4-mini') + expect(ids.filter((id: string) => id === 'deepseek-v4-pro')).toHaveLength(1) + }) + it('GET /api/models/current should return default model when not set', async () => { const { req, url, segments } = makeRequest('GET', '/api/models/current') const res = await handleModelsApi(req, url, segments) @@ -333,6 +425,17 @@ describe('Models API', () => { expect(body.model.id).toBe('claude-opus-4-7') }) + it('GET /api/models/current should respect env-configured default model when no provider is active', async () => { + process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' + + const { req, url, segments } = makeRequest('GET', '/api/models/current') + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.model.id).toBe('deepseek-v4-pro') + }) + it('PUT /api/models/current should switch model', async () => { const { req, url, segments } = makeRequest('PUT', '/api/models/current', { modelId: 'claude-opus-4-7', @@ -450,6 +553,37 @@ describe('Models API', () => { }) }) +describe('Model Options', () => { + beforeEach(setup) + afterEach(teardown) + + it('should keep OpenAI OAuth models visible alongside env-configured provider models', () => { + process.env.ANTHROPIC_API_KEY = 'deepseek-key' + process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic' + process.env.ANTHROPIC_MODEL = 'deepseek-v4-pro' + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'deepseek-v4-flash' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'deepseek-v4-pro' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'deepseek-v4-pro' + saveOpenAIOAuthTokens({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000, + }) + + const options = getModelOptions() + const values = options + .map(option => option.value) + .filter((value): value is string => typeof value === 'string') + const labels = options.map(option => option.label) + + expect(values).toContain('gpt-5.3-codex') + expect(values).toContain('gpt-5.4') + expect(values).toContain('gpt-5.4-mini') + expect(labels).toContain('deepseek-v4-pro') + expect(labels).toContain('deepseek-v4-flash') + }) +}) + // ============================================================================= // Status API // ============================================================================= diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 5811aabd..29f07bbe 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -11,6 +11,8 @@ import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { hasOpenAIAuthLogin } from '../../utils/auth.js' +import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' // ─── Fallback models (used when no provider is configured) ──────────────────── @@ -43,6 +45,106 @@ const DEFAULT_EFFORT = 'medium' const settingsService = new SettingsService() const providerService = new ProviderService() +type ApiModelInfo = { + id: string + name: string + description: string + context: string +} + +function addUniqueModel( + models: ApiModelInfo[], + model: ApiModelInfo | null, +): void { + if (!model || !model.id.trim()) { + return + } + + if (models.some(existing => existing.id === model.id)) { + return + } + + models.push(model) +} + +function buildProviderModelList(models: { + main: string + haiku: string + sonnet: string + opus: string +}): ApiModelInfo[] { + const modelList: ApiModelInfo[] = [] + + addUniqueModel(modelList, { + id: models.main, + name: models.main, + description: 'Main model', + context: '', + }) + addUniqueModel(modelList, models.haiku + ? { + id: models.haiku, + name: models.haiku, + description: 'Haiku model', + context: '', + } + : null) + addUniqueModel(modelList, models.sonnet + ? { + id: models.sonnet, + name: models.sonnet, + description: 'Sonnet model', + context: '', + } + : null) + addUniqueModel(modelList, models.opus + ? { + id: models.opus, + name: models.opus, + description: 'Opus model', + context: '', + } + : null) + + return modelList +} + +function getEnvConfiguredAnthropicModels(): ApiModelInfo[] { + return buildProviderModelList({ + main: process.env.ANTHROPIC_MODEL?.trim() || '', + haiku: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || '', + sonnet: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || '', + opus: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || '', + }) +} + +function getOpenAIAuthModels(): ApiModelInfo[] { + if (!hasOpenAIAuthLogin()) { + return [] + } + + return OPENAI_CODEX_MODEL_CATALOG.map(model => ({ + id: model.value, + name: model.label, + description: model.description, + context: '', + })) +} + +function getStandaloneModelList(): ApiModelInfo[] { + const models = [...getEnvConfiguredAnthropicModels()] + + if (models.length === 0) { + models.push(...DEFAULT_MODELS) + } + + for (const model of getOpenAIAuthModels()) { + addUniqueModel(models, model) + } + + return models +} + // ─── Router ─────────────────────────────────────────────────────────────────── export async function handleModelsApi( @@ -83,19 +185,13 @@ async function handleModelsList(): Promise { const { providers, activeId } = await providerService.listProviders() const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null if (activeProvider) { - // Convert ModelMapping to model list for API compatibility - const modelList = [ - { id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' }, - ...(activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []), - ...(activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []), - ...(activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []), - ] + const modelList = buildProviderModelList(activeProvider.models) return Response.json({ models: modelList, provider: { id: activeProvider.id, name: activeProvider.name }, }) } - return Response.json({ models: DEFAULT_MODELS, provider: null }) + return Response.json({ models: getStandaloneModelList(), provider: null }) } async function handleCurrentModel(req: Request): Promise { @@ -109,6 +205,7 @@ async function handleCurrentModel(req: Request): Promise { const explicitModel = (settings.model as string) || '' const contextTier = (settings.modelContext as string) || undefined const env = (settings.env as Record) || {} + const envModel = process.env.ANTHROPIC_MODEL?.trim() || '' let currentModelId: string let currentModelName: string @@ -127,7 +224,7 @@ async function handleCurrentModel(req: Request): Promise { } } else { // No provider — use settings model with context tier - currentModelId = explicitModel || DEFAULT_MODEL + currentModelId = explicitModel || envModel || DEFAULT_MODEL currentModelName = currentModelId } @@ -135,13 +232,8 @@ async function handleCurrentModel(req: Request): Promise { // Build available models for name lookup const availableModels = activeProvider - ? [ - { id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' }, - ...(activeProvider.models.haiku && activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []), - ...(activeProvider.models.sonnet && activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []), - ...(activeProvider.models.opus && activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []), - ] - : DEFAULT_MODELS + ? buildProviderModelList(activeProvider.models) + : getStandaloneModelList() const modelEntry = availableModels.find((m) => m.id === lookupId) || availableModels.find((m) => m.id === currentModelId) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 4e5806cc..e2ec4093 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1911,6 +1911,11 @@ export function getOpenAIAuthOverrideWarning(): string | null { return null } +export function hasOpenAIAuthLogin(): boolean { + const openaiTokens = getOpenAIOAuthTokens() + return !!openaiTokens?.refreshToken && !isUsing3PServices() +} + export function isOpenAIAuthActive(): boolean { return getOpenAIAccountInformationIfActive() !== undefined } diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index ce9d0871..5e72ad30 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -1,6 +1,7 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { getInitialMainLoopModel } from '../../bootstrap/state.js' import { + hasOpenAIAuthLogin, isClaudeAISubscriber, isMaxSubscriber, isOpenAIAuthActive, @@ -15,7 +16,7 @@ import { } from '../modelCost.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js' -import { getAPIProvider } from './providers.js' +import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js' import { isModelAllowed } from './modelAllowlist.js' import { getCanonicalName, @@ -44,6 +45,25 @@ export type ModelOption = { descriptionForModel?: string } +function hasAnthropicCompatibleThirdPartyConfig(): boolean { + return getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl() +} + +function pushUniqueOption( + options: ModelOption[], + option: ModelOption | undefined, +): void { + if (!option) { + return + } + + if (options.some(existing => existing.value === option.value)) { + return + } + + options.push(option) +} + export function getDefaultOptionForUser(fastMode = false): ModelOption { if (process.env.USER_TYPE === 'ant') { const currentModel = renderDefaultModelSetting( @@ -79,7 +99,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption { } // PAYG - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: null, label: 'Default (recommended)', @@ -88,7 +108,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption { } function getCustomSonnetOption(): ModelOption | undefined { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL // When a 3P user has a custom sonnet model string, show it directly if (is3P && customSonnetModel) { @@ -108,7 +128,7 @@ function getCustomSonnetOption(): ModelOption | undefined { // @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.) // with the new model's label and description. These appear in the /model picker. function getSonnet46Option(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: is3P ? getModelStrings().sonnet46 : 'sonnet', label: 'Sonnet', @@ -119,7 +139,7 @@ function getSonnet46Option(): ModelOption { } function getCustomOpusOption(): ModelOption | undefined { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() const customOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL // When a 3P user has a custom opus model string, show it directly if (is3P && customOpusModel) { @@ -155,7 +175,7 @@ function getOpus46Option(fastMode = false): ModelOption { } export function getSonnet46_1MOption(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]', label: 'Sonnet (1M context)', @@ -166,7 +186,7 @@ export function getSonnet46_1MOption(): ModelOption { } export function getOpus46_1MOption(fastMode = false): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', @@ -177,7 +197,7 @@ export function getOpus46_1MOption(fastMode = false): ModelOption { } function getCustomHaikuOption(): ModelOption | undefined { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() const customHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL // When a 3P user has a custom haiku model string, show it directly if (is3P && customHaikuModel) { @@ -193,7 +213,7 @@ function getCustomHaikuOption(): ModelOption | undefined { } function getHaiku45Option(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: 'haiku', label: 'Haiku', @@ -231,7 +251,7 @@ function getMaxOpusOption(fastMode = false): ModelOption { } export function getMaxSonnet46_1MOption(): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : '' return { value: 'sonnet[1m]', @@ -250,7 +270,7 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption { } function getMergedOpus1MOption(fastMode = false): ModelOption { - const is3P = getAPIProvider() !== 'firstParty' + const is3P = hasAnthropicCompatibleThirdPartyConfig() return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', @@ -319,8 +339,17 @@ function getOpenAIModelOptions(): ModelOption[] { descriptionForModel: model.descriptionForModel, })), ] -} + if (!hasAnthropicCompatibleThirdPartyConfig()) { + return options + } + + pushUniqueOption(options, getCustomSonnetOption()) + pushUniqueOption(options, getCustomOpusOption()) + pushUniqueOption(options, getCustomHaikuOption()) + + return options +} // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model. // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list. function getModelOptionsBase(fastMode = false): ModelOption[] { @@ -346,8 +375,8 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { ] } - if (isOpenAIAuthActive()) { - return getOpenAIModelOptions() + if (hasOpenAIAuthLogin()) { + return getOpenAIModelOptions(fastMode) } if (isClaudeAISubscriber()) { @@ -387,7 +416,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.7 + Opus 1M + Haiku - if (getAPIProvider() === 'firstParty') { + if (!hasAnthropicCompatibleThirdPartyConfig()) { const payg1POptions = [getDefaultOptionForUser(fastMode)] if (checkSonnet1mAccess()) { payg1POptions.push(getSonnet46_1MOption()) @@ -409,7 +438,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const customSonnet = getCustomSonnetOption() if (customSonnet !== undefined) { - payg3pOptions.push(customSonnet) + pushUniqueOption(payg3pOptions, customSonnet) } else { // Add Sonnet 4.6 since Sonnet 4.5 is the default payg3pOptions.push(getSonnet46Option()) @@ -420,7 +449,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { const customOpus = getCustomOpusOption() if (customOpus !== undefined) { - payg3pOptions.push(customOpus) + pushUniqueOption(payg3pOptions, customOpus) } else { // Add Opus 4.1, Opus 4.7 and Opus 4.7 1M payg3pOptions.push(getOpus41Option()) // This is the default opus @@ -431,7 +460,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } const customHaiku = getCustomHaikuOption() if (customHaiku !== undefined) { - payg3pOptions.push(customHaiku) + pushUniqueOption(payg3pOptions, customHaiku) } else { payg3pOptions.push(getHaikuOption()) }