fix: enable the local apikey models while using openai auth subscription

This commit is contained in:
cynicalight 2026-04-29 21:00:34 +08:00
parent 24d31e284f
commit 2835426cf3
4 changed files with 294 additions and 34 deletions

View File

@ -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
// =============================================================================

View File

@ -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<Response> {
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<Response> {
@ -109,6 +205,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
const explicitModel = (settings.model as string) || ''
const contextTier = (settings.modelContext as string) || undefined
const env = (settings.env as Record<string, string>) || {}
const envModel = process.env.ANTHROPIC_MODEL?.trim() || ''
let currentModelId: string
let currentModelName: string
@ -127,7 +224,7 @@ async function handleCurrentModel(req: Request): Promise<Response> {
}
} 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<Response> {
// 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)

View File

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

View File

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