mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
fix(apismart): centralize route capability contracts
This commit is contained in:
@@ -268,6 +268,7 @@ Advanced and source-build guides:
|
||||
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
|
||||
| Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4` and defaults to `glm-5.2` |
|
||||
| AI/ML API | `/provider` or `AIMLAPI_API_KEY` ([setup guide](docs/aimlapi-setup.md)) | Uses `https://api.aimlapi.com/v1`, auto-detects the OpenAI-compatible route from `AIMLAPI_API_KEY`, sends OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog |
|
||||
| ApiSmart | `/provider` or `APISMART_API_KEY` | Uses `https://gw.apismart.ai/v1`, defaults to `DEEPSEEK_V4_FLASH`, and supports optional `APISMART_MODEL` plus authenticated model discovery |
|
||||
| Hicap | `/provider` or OpenAI-compatible env vars | Uses `api-key` auth, discovers models from unauthenticated `/models`, and supports Responses mode for `gpt-` models |
|
||||
| Fireworks AI | `/provider` or env vars | First-class provider with 276 curated models (DeepSeek, Qwen, Llama, Gemma, and more); uses `FIREWORKS_API_KEY` |
|
||||
| LongCat | `/provider` or env vars | Meituan LongCat OpenAI-compatible API at `https://api.longcat.chat/openai/v1`; uses `LONGCAT_API_KEY` and defaults to `LongCat-2.0` |
|
||||
|
||||
@@ -127,6 +127,7 @@ const PRESET_ORDER = [
|
||||
'Anthropic',
|
||||
'Alibaba Coding Plan (China)',
|
||||
'Alibaba Coding Plan',
|
||||
'ApiSmart',
|
||||
'Atlas Cloud',
|
||||
'Azure OpenAI',
|
||||
'Bankr',
|
||||
@@ -692,6 +693,8 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Base URL'),
|
||||
)
|
||||
mounted.stdin.write('\x7f'.repeat(64))
|
||||
mounted.stdin.write('https://proxy.example/v1')
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Default model'),
|
||||
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
routeShowsAuthHeader,
|
||||
routeShowsAuthHeaderValue,
|
||||
routeShowsCustomHeaders,
|
||||
resolveProfileCapabilityRouteId,
|
||||
resolveProfileRoute,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../integrations/index.js'
|
||||
import {
|
||||
provisionAimlapiKey,
|
||||
@@ -337,18 +337,6 @@ function getGithubCredentialSourceFromEnv(
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function resolveProviderEditorRouteId(
|
||||
provider: ProviderProfile['provider'],
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const route = resolveProfileRoute(provider).routeId
|
||||
if (route !== 'openai') {
|
||||
return route
|
||||
}
|
||||
|
||||
return resolveRouteIdFromBaseUrl(baseUrl) ?? route
|
||||
}
|
||||
|
||||
function routeSupportsResponsesModel(routeId: string, model: string): boolean {
|
||||
return openAIShimSupportsApiFormatForModel(
|
||||
getRouteDescriptor(routeId)?.transportConfig.openaiShim,
|
||||
@@ -885,7 +873,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
|
||||
const formSteps = React.useMemo(
|
||||
() => {
|
||||
const routeId = resolveProviderEditorRouteId(draftProvider, draft.baseUrl)
|
||||
const routeId = resolveProfileCapabilityRouteId(draftProvider, draft.baseUrl)
|
||||
const showsAuthHeader = routeShowsAuthHeader(routeId)
|
||||
const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId)
|
||||
const showsCustomHeaders = routeShowsCustomHeaders(routeId)
|
||||
@@ -1659,7 +1647,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
setErrorMessage('Base URL must be a real Anthropic-compatible endpoint.')
|
||||
return
|
||||
}
|
||||
const routeId = resolveProviderEditorRouteId(provider, nextDraft.baseUrl)
|
||||
const routeId = resolveProfileCapabilityRouteId(provider, nextDraft.baseUrl)
|
||||
const supportsApiFormat = routeSupportsApiFormatSelection(routeId)
|
||||
const showsAuthHeader = routeShowsAuthHeader(routeId)
|
||||
const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId)
|
||||
@@ -1768,7 +1756,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
nextDraft: ProviderDraft,
|
||||
provider: ProviderProfile['provider'],
|
||||
): ProviderDraft {
|
||||
const routeId = resolveProviderEditorRouteId(provider, nextDraft.baseUrl)
|
||||
const routeId = resolveProfileCapabilityRouteId(provider, nextDraft.baseUrl)
|
||||
const preferredResponsesMode = nextDraft.apiFormat === 'responses_compat' ? 'responses_compat' : 'responses'
|
||||
const apiFormat =
|
||||
routeSupportsApiFormatSelection(routeId) &&
|
||||
@@ -2198,7 +2186,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
Provider type:{' '}
|
||||
{getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)}
|
||||
</Text>
|
||||
{routeSupportsCustomHeaders(resolveProfileRoute(draftProvider).routeId) ? (
|
||||
{routeSupportsCustomHeaders(
|
||||
resolveProfileCapabilityRouteId(draftProvider, draft.baseUrl),
|
||||
) ? (
|
||||
<Text dimColor>
|
||||
Advanced: this provider supports custom request headers when you
|
||||
need them.
|
||||
|
||||
@@ -45,6 +45,9 @@ const ENV_KEYS = [
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'GEMINI_MODEL',
|
||||
'MISTRAL_MODEL',
|
||||
'ANTHROPIC_MODEL',
|
||||
@@ -52,6 +55,14 @@ const ENV_KEYS = [
|
||||
'NVIDIA_NIM',
|
||||
'MINIMAX_API_KEY',
|
||||
'XAI_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'VENICE_API_KEY',
|
||||
'NEARAI_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'LONGCAT_API_KEY',
|
||||
'CLINE_API_KEY',
|
||||
'CLINE_API_MODEL',
|
||||
'AIMLAPI_API_KEY',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
@@ -358,6 +369,28 @@ describe('detectProvider — rawModel fallback when URL is generic', () => {
|
||||
// --- Explicit env flags win over URL heuristics ---
|
||||
|
||||
describe('detectProvider — explicit dedicated-provider env flags', () => {
|
||||
test('credential-only ApiSmart setup uses route defaults before client initialization', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-test-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
expect(detectProvider()).toEqual({
|
||||
name: 'ApiSmart',
|
||||
model: 'KIMI_K3',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
isLocal: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('labels the resolved ApiSmart route when another dedicated key is ambient', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-test-key'
|
||||
process.env.MINIMAX_API_KEY = 'ambient-minimax-key'
|
||||
|
||||
expect(detectProvider()).toMatchObject({
|
||||
name: 'ApiSmart',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
})
|
||||
})
|
||||
|
||||
test('NVIDIA_NIM=1 overrides aggregator URL', () => {
|
||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'some-nim-model')
|
||||
process.env.NVIDIA_NIM = '1'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/prov
|
||||
import {
|
||||
getRouteLabel,
|
||||
isMiniMaxBaseUrl,
|
||||
resolveEnvOnlyProviderRouteId,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../integrations/routeMetadata.js'
|
||||
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
|
||||
@@ -80,7 +81,11 @@ const LOGO_CLAUDE = [
|
||||
export function detectProvider(modelOverride?: string): { name: string; model: string; baseUrl: string; isLocal: boolean } {
|
||||
const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
||||
const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
||||
const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env)
|
||||
const useOpenAI =
|
||||
process.env.CLAUDE_CODE_USE_OPENAI === '1' ||
|
||||
process.env.CLAUDE_CODE_USE_OPENAI === 'true' ||
|
||||
envOnlyProviderRouteId === 'apismart'
|
||||
const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true'
|
||||
|
||||
if (useGemini) {
|
||||
@@ -103,17 +108,21 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
|
||||
}
|
||||
|
||||
if (useOpenAI) {
|
||||
const rawModel = modelOverride || process.env.OPENAI_MODEL || 'gpt-4o'
|
||||
const resolvedRequest = resolveProviderRequest({
|
||||
model: rawModel,
|
||||
model: modelOverride,
|
||||
baseUrl: process.env.OPENAI_BASE_URL,
|
||||
fallbackModel:
|
||||
envOnlyProviderRouteId === 'apismart' ? undefined : 'gpt-4o',
|
||||
processEnv: process.env,
|
||||
})
|
||||
const rawModel = resolvedRequest.requestedModel
|
||||
const baseUrl = resolvedRequest.baseUrl
|
||||
const isLocal = isLocalProviderUrl(baseUrl)
|
||||
const routeId = resolveRouteIdFromBaseUrl(baseUrl)
|
||||
let name = 'OpenAI'
|
||||
// Explicit dedicated-provider env flags win.
|
||||
if (process.env.NVIDIA_NIM) name = 'NVIDIA NIM'
|
||||
else if (routeId === 'apismart') name = getRouteLabel(routeId) ?? name
|
||||
else if (process.env.MINIMAX_API_KEY) name = 'MiniMax'
|
||||
else if (
|
||||
resolvedRequest.transport === 'codex_responses' ||
|
||||
|
||||
@@ -110,6 +110,33 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('discoverModelsForRoute', () => {
|
||||
test('drops caller headers when the route disables custom headers', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ data: [{ id: 'KIMI_K3' }] }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('apismart', {
|
||||
forceRefresh: true,
|
||||
apiKey: 'apismart-test-key',
|
||||
headers: { 'X-Proxy-Secret': 'stale-secret' },
|
||||
})
|
||||
|
||||
expect(result?.source).toBe('network')
|
||||
expect(capturedHeaders?.get('x-proxy-secret')).toBeNull()
|
||||
expect(capturedHeaders?.get('authorization')).toBe(
|
||||
'Bearer apismart-test-key',
|
||||
)
|
||||
})
|
||||
|
||||
test('uses built-in openai-compatible discovery and caches results for dynamic routes', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
|
||||
@@ -181,7 +181,8 @@ export function getRouteDiscoveryHeaders(
|
||||
): Record<string, string> | undefined {
|
||||
const transportConfig = getRouteDescriptor(routeId)?.transportConfig
|
||||
const acceptsCallerHeaders =
|
||||
getRouteCatalog(routeId)?.discovery?.requiresAuth !== false
|
||||
getRouteCatalog(routeId)?.discovery?.requiresAuth !== false &&
|
||||
transportConfig?.openaiShim?.supportsAuthHeaders !== false
|
||||
// Descriptor headers are attribution, not transport plumbing: an `aimlapi`
|
||||
// profile keeps its route id while pointing at a user-controlled proxy, so the
|
||||
// `/models` request must be filtered on the same canonical predicate the
|
||||
|
||||
@@ -142,6 +142,7 @@ export {
|
||||
isLongcatBaseUrl,
|
||||
normalizeXiaomiMimoBaseUrl,
|
||||
resolveActiveRouteIdFromEnv,
|
||||
resolveProfileCapabilityRouteId,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
routeSupportsApiFormatSelection,
|
||||
routeSupportsAuthHeaders,
|
||||
|
||||
@@ -6,14 +6,98 @@ import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getRouteProviderTypeLabel,
|
||||
getUsableRouteConfigEnvValue,
|
||||
hasUsableRouteCredentialEnvValue,
|
||||
isApismartBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
profileTargetsRoute,
|
||||
resolveActiveRouteIdFromEnv,
|
||||
resolveProfileCapabilityRouteId,
|
||||
resolveRouteCredentialValue,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from './routeMetadata.js'
|
||||
|
||||
test('profile capability routing keeps provider identity and endpoint boundaries together', () => {
|
||||
expect(resolveProfileCapabilityRouteId('apismart')).toBe('apismart')
|
||||
expect(resolveProfileCapabilityRouteId('openai')).toBe('openai')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'custom',
|
||||
'http://localhost:11434/v1',
|
||||
),
|
||||
).toBe('ollama')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'custom',
|
||||
'https://gw.apismart.ai/v1',
|
||||
),
|
||||
).toBe('apismart')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'custom',
|
||||
'https://api.aimlapi.com/v1',
|
||||
),
|
||||
).toBe('aimlapi')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'gemini',
|
||||
'https://gw.apismart.ai/v1',
|
||||
),
|
||||
).toBe('gemini')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'openai',
|
||||
'https://gw.apismart.ai/v1',
|
||||
),
|
||||
).toBe('apismart')
|
||||
expect(
|
||||
resolveProfileCapabilityRouteId(
|
||||
'apismart',
|
||||
'https://proxy.example/v1',
|
||||
),
|
||||
).toBe('custom')
|
||||
})
|
||||
|
||||
test('profile endpoint routing separates credential ownership from editor capabilities', () => {
|
||||
expect(profileTargetsRoute('apismart', undefined, 'apismart')).toBe(true)
|
||||
expect(profileTargetsRoute('openai', undefined, 'apismart')).toBe(false)
|
||||
expect(
|
||||
profileTargetsRoute('custom', 'https://gw.apismart.ai/v1', 'apismart'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
profileTargetsRoute('apismart', 'https://proxy.example/v1', 'apismart'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('route credential usability applies OpenAI placeholder rules to dedicated keys', () => {
|
||||
expect(hasUsableRouteCredentialEnvValue('APISMART_API_KEY', 'SUA_CHAVE')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
hasUsableRouteCredentialEnvValue('APISMART_API_KEY', 'apismart-key'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('route config normalization rejects nullish environment sentinels', () => {
|
||||
expect(getUsableRouteConfigEnvValue(' https://proxy.example/v1 ')).toBe(
|
||||
'https://proxy.example/v1',
|
||||
)
|
||||
for (const value of [undefined, '', ' undefined ', 'NULL']) {
|
||||
expect(getUsableRouteConfigEnvValue(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('ApiSmart env-only intent respects an explicit OpenAI-compatible opt-out', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLAUDE_CODE_USE_OPENAI: '0',
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}),
|
||||
).toBe('anthropic')
|
||||
})
|
||||
|
||||
test('isCloudflareBaseUrl matches Workers AI host but not the shared AI Gateway', () => {
|
||||
// Workers AI lives on api.cloudflare.com.
|
||||
expect(
|
||||
@@ -476,6 +560,16 @@ test('resolveActiveRouteIdFromEnv treats ApiSmart credential-only env as ApiSmar
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv treats the generic OpenAI flag as compatible with ApiSmart intent', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv ignores placeholder ApiSmart credentials', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
|
||||
@@ -205,7 +205,7 @@ function readFirstNonEmptyEnvValue(
|
||||
): string | undefined {
|
||||
for (const envVar of envVars) {
|
||||
const value = processEnv[envVar]
|
||||
if (hasUsableEnvCredentialValue(envVar, value)) {
|
||||
if (hasUsableRouteCredentialEnvValue(envVar, value)) {
|
||||
return value!.trim()
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ function readFirstNonEmptyEnvValue(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hasUsableEnvCredentialValue(
|
||||
export function hasUsableRouteCredentialEnvValue(
|
||||
envVar: string,
|
||||
value: string | undefined,
|
||||
): boolean {
|
||||
@@ -221,11 +221,20 @@ function hasUsableEnvCredentialValue(
|
||||
return false
|
||||
}
|
||||
|
||||
if (envVar === 'APISMART_API_KEY') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return (
|
||||
normalized !== '' &&
|
||||
normalized !== 'sua_chave' &&
|
||||
normalized !== 'null' &&
|
||||
normalized !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
envVar === 'OPENAI_API_KEYS' ||
|
||||
envVar === 'OPENAI_API_KEY' ||
|
||||
envVar === 'AIMLAPI_API_KEY' ||
|
||||
envVar === 'APISMART_API_KEY'
|
||||
envVar === 'AIMLAPI_API_KEY'
|
||||
) {
|
||||
return hasUsableOpenAICredential(value)
|
||||
}
|
||||
@@ -234,8 +243,8 @@ function hasUsableEnvCredentialValue(
|
||||
|
||||
function hasAnyUsableOpenAICredential(processEnv: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
hasUsableEnvCredentialValue('OPENAI_API_KEYS', processEnv.OPENAI_API_KEYS) ||
|
||||
hasUsableEnvCredentialValue('OPENAI_API_KEY', processEnv.OPENAI_API_KEY)
|
||||
hasUsableRouteCredentialEnvValue('OPENAI_API_KEYS', processEnv.OPENAI_API_KEYS) ||
|
||||
hasUsableRouteCredentialEnvValue('OPENAI_API_KEY', processEnv.OPENAI_API_KEY)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,6 +253,32 @@ function hasNonEmptyEnvValue(value: string | undefined): boolean {
|
||||
return Boolean(trimmed && trimmed !== 'undefined' && trimmed !== 'null')
|
||||
}
|
||||
|
||||
export function getUsableRouteConfigEnvValue(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = trimmed.toLowerCase()
|
||||
return normalized === 'undefined' || normalized === 'null'
|
||||
? undefined
|
||||
: trimmed
|
||||
}
|
||||
|
||||
export function getUsableRouteModelEnvValue(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
return getUsableRouteConfigEnvValue(value)
|
||||
}
|
||||
|
||||
export function hasExplicitOpenAICompatibleOptOut(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined &&
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMiniMaxBaseUrl(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
@@ -661,7 +696,13 @@ function hasNoExplicitNonOpenAIProvider(
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) &&
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK) &&
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_VERTEX) &&
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY)
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY) &&
|
||||
!(
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) &&
|
||||
hasNonEmptyEnvValue(processEnv.ANTHROPIC_BASE_URL) &&
|
||||
(hasNonEmptyEnvValue(processEnv.ANTHROPIC_AUTH_TOKEN) ||
|
||||
hasNonEmptyEnvValue(processEnv.ANTHROPIC_API_KEY))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -816,13 +857,35 @@ export function hasClinePassEnvOnlyProviderIntent(
|
||||
)
|
||||
}
|
||||
|
||||
function hasApismartProviderIntent(
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
hasCredential: boolean,
|
||||
): boolean {
|
||||
const explicitRouteId = processEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim()
|
||||
return (
|
||||
hasCredential &&
|
||||
(!explicitRouteId || explicitRouteId === 'apismart') &&
|
||||
!hasExplicitOpenAICompatibleOptOut(processEnv) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) &&
|
||||
hasNoExplicitNonOpenAIProvider(processEnv)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasConfiguredApismartProviderIntent(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return hasApismartProviderIntent(
|
||||
processEnv,
|
||||
Boolean(processEnv.APISMART_API_KEY?.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasApismartEnvOnlyProviderIntent(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
hasUsableOpenAICredential(processEnv.APISMART_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
return hasApismartProviderIntent(
|
||||
processEnv,
|
||||
hasUsableOpenAICredential(processEnv.APISMART_API_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1115,6 +1178,64 @@ function profileRouteHonorsBaseUrlBoundary(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a profile's endpoint belongs to a specific route. A concrete
|
||||
* endpoint owns credential routing regardless of the editor label; without an
|
||||
* endpoint, only the declared provider may supply that route's default.
|
||||
*/
|
||||
export function profileTargetsRoute(
|
||||
provider: string,
|
||||
baseUrl: string | undefined,
|
||||
targetRouteId: string,
|
||||
): boolean {
|
||||
const trimmedBaseUrl = baseUrl?.trim()
|
||||
if (trimmedBaseUrl) {
|
||||
return resolveRouteIdFromBaseUrl(trimmedBaseUrl) === targetRouteId
|
||||
}
|
||||
return resolveProfileRoute(provider).routeId === targetRouteId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the route whose capabilities apply to a saved or edited profile.
|
||||
* A concrete endpoint refines OpenAI-compatible profiles, while native
|
||||
* provider families retain their declared transport contract. An
|
||||
* endpoint-scoped provider retargeted elsewhere becomes a generic route.
|
||||
*/
|
||||
export function resolveProfileCapabilityRouteId(
|
||||
provider: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const providerRouteId = resolveProfileRoute(provider).routeId
|
||||
if (providerRouteId === 'custom-anthropic') {
|
||||
return providerRouteId
|
||||
}
|
||||
|
||||
const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl)
|
||||
if (providerRouteId === 'custom') {
|
||||
return routeIdFromBaseUrl ?? providerRouteId
|
||||
}
|
||||
|
||||
const providerTransportKind = getTransportKindForRoute(providerRouteId)
|
||||
if (
|
||||
providerTransportKind !== 'openai-compatible' &&
|
||||
providerTransportKind !== 'local'
|
||||
) {
|
||||
return providerRouteId
|
||||
}
|
||||
if (routeIdFromBaseUrl) {
|
||||
return routeIdFromBaseUrl
|
||||
}
|
||||
|
||||
if (
|
||||
baseUrl?.trim() &&
|
||||
!profileRouteHonorsBaseUrlBoundary(providerRouteId, baseUrl)
|
||||
) {
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
return providerRouteId
|
||||
}
|
||||
|
||||
export function resolveActiveRouteIdFromEnv(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
options?: {
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
resolveOutOfProcessTeammateProviderFromCliArgs,
|
||||
shouldEnforceModelAllowlist,
|
||||
} from './agentRouting.js'
|
||||
import { resolveRouteCredentialValue } from '../../integrations/routeMetadata.js'
|
||||
import { resolveProviderRequest } from './providerConfig.js'
|
||||
import { getAgentModel } from '../../utils/model/agent.js'
|
||||
import * as agentModelModule from '../../utils/model/agent.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
@@ -731,6 +733,34 @@ describe('applyAgentProviderOverrideToEnv', () => {
|
||||
expect(env.GEMINI_API_KEY).toBe('gemini-key')
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('anthropic-key')
|
||||
})
|
||||
|
||||
test('replaces parent ApiSmart model and credential for an ApiSmart agent override', () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'KIMI_K3',
|
||||
OPENAI_API_KEY: 'parent-secret',
|
||||
APISMART_API_KEY: 'parent-secret',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}
|
||||
|
||||
applyAgentProviderOverrideToEnv(
|
||||
{
|
||||
model: 'GLM_5.2',
|
||||
baseURL: 'https://gw.apismart.ai/v1',
|
||||
apiKey: 'child-secret',
|
||||
},
|
||||
env,
|
||||
)
|
||||
|
||||
expect(resolveProviderRequest({ processEnv: env }).requestedModel).toBe('GLM_5.2')
|
||||
expect(
|
||||
resolveRouteCredentialValue({ routeId: 'apismart', processEnv: env }),
|
||||
).toBe('child-secret')
|
||||
expect(env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
expect(env.APISMART_MODEL).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldEnforceModelAllowlist', () => {
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
|
||||
import { getAgentModel } from '../../utils/model/agent.js'
|
||||
import { isModelAlias } from '../../utils/model/aliases.js'
|
||||
import {
|
||||
getRouteCredentialEnvVars,
|
||||
getRouteDescriptor,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
|
||||
/**
|
||||
* Provider override resolved from agent routing config.
|
||||
@@ -44,6 +49,7 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [
|
||||
'CLAUDE_CODE_USE_MISTRAL',
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'NVIDIA_NIM',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
@@ -57,6 +63,8 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [
|
||||
'OPENAI_AUTH_HEADER',
|
||||
'OPENAI_AUTH_SCHEME',
|
||||
'OPENAI_AUTH_HEADER_VALUE',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -362,4 +370,15 @@ export function applyAgentProviderOverrideToEnv(
|
||||
env.OPENAI_MODEL = providerOverride.model
|
||||
env.OPENAI_BASE_URL = providerOverride.baseURL
|
||||
env.OPENAI_API_KEY = providerOverride.apiKey
|
||||
|
||||
const routeId = resolveRouteIdFromBaseUrl(providerOverride.baseURL)
|
||||
if (routeId) {
|
||||
env.CLAUDE_CODE_PROVIDER_ROUTE_ID = routeId
|
||||
const descriptor = getRouteDescriptor(routeId)
|
||||
if (descriptor?.setup.dedicatedCredentialsOnly) {
|
||||
for (const credentialEnvVar of getRouteCredentialEnvVars(routeId)) {
|
||||
env[credentialEnvVar] = providerOverride.apiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
registerGateway,
|
||||
} from '../../integrations/index.js'
|
||||
import { publicBuildVersion } from '../../utils/version.js'
|
||||
import { resolveEnvOnlyProviderRouteId } from '../../integrations/routeMetadata.js'
|
||||
|
||||
// bun:test keeps mock.module() registrations process-global across test files.
|
||||
// Load and re-register the real module before importing the client so a prior
|
||||
@@ -317,7 +318,7 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou
|
||||
expect(capturedHeaders).toBeDefined()
|
||||
})
|
||||
|
||||
test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN without requiring an API key', async () => {
|
||||
test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN despite an ambient ApiSmart key', async () => {
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
@@ -331,10 +332,13 @@ test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN without requi
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-anthropic-token'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://anthropic.example/api/v1'
|
||||
process.env.APISMART_API_KEY = 'ambient-apismart-key'
|
||||
process.env.USER_TYPE = 'ant'
|
||||
process.env.USE_STAGING_OAUTH = '1'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant: tenant-a\nauthorization: stale-value'
|
||||
|
||||
expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull()
|
||||
|
||||
const fetchOverride = (async (input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
@@ -1536,6 +1540,123 @@ test('strips Anthropic-specific custom headers before sending OpenAI-compatible
|
||||
expect(capturedHeaders?.get('authorization')).toBe('Bearer openai-test-key')
|
||||
})
|
||||
|
||||
test('does not forward ambient custom headers to routes that disable custom headers', async () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
let capturedUrl: string | undefined
|
||||
|
||||
clearEnvForMiniMaxOnlyTest()
|
||||
process.env.APISMART_API_KEY = 'apismart-test-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Secret: stale-secret'
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-apismart',
|
||||
model: 'KIMI_K3',
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as unknown as FetchType
|
||||
|
||||
const client = (await getAnthropicClient({
|
||||
maxRetries: 0,
|
||||
model: 'KIMI_K3',
|
||||
})) as unknown as ShimClient
|
||||
await client.beta.messages.create({
|
||||
model: 'KIMI_K3',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
expect(capturedUrl).toBe('https://gw.apismart.ai/v1/chat/completions')
|
||||
expect(capturedHeaders?.get('x-proxy-secret')).toBeNull()
|
||||
expect(capturedHeaders?.get('authorization')).toBe('Bearer apismart-test-key')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['OPENAI_BASE_URL', 'null'],
|
||||
['OPENAI_BASE_URL', ' UNDEFINED '],
|
||||
['OPENAI_API_BASE', 'NULL'],
|
||||
['OPENAI_API_BASE', ' undefined '],
|
||||
] as const)(
|
||||
'ApiSmart env-only defaults ignore nullish %s value %s',
|
||||
async (envVar, sentinel) => {
|
||||
clearEnvForMiniMaxOnlyTest()
|
||||
process.env.APISMART_API_KEY = 'apismart-test-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
process.env[envVar] = sentinel
|
||||
|
||||
await getAnthropicClient({ maxRetries: 0, model: 'KIMI_K3' })
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
},
|
||||
)
|
||||
|
||||
test('enforces route header capabilities for an ApiSmart provider override', async () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
clearEnvForMiniMaxOnlyTest()
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Secret: stale-secret'
|
||||
process.env.OPENAI_AUTH_HEADER = 'X-Parent-Secret'
|
||||
process.env.OPENAI_AUTH_SCHEME = 'raw'
|
||||
process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-parent-token'
|
||||
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-apismart-override',
|
||||
model: 'KIMI_K3',
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as unknown as FetchType
|
||||
|
||||
const client = (await getAnthropicClient({
|
||||
maxRetries: 0,
|
||||
providerOverride: {
|
||||
model: 'KIMI_K3',
|
||||
baseURL: 'https://gw.apismart.ai/v1',
|
||||
apiKey: 'child-apismart-key',
|
||||
},
|
||||
})) as unknown as ShimClient
|
||||
await client.beta.messages.create({
|
||||
model: 'unused',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
expect(capturedHeaders?.get('x-proxy-secret')).toBeNull()
|
||||
expect(capturedHeaders?.get('x-parent-secret')).toBeNull()
|
||||
expect(capturedHeaders?.get('authorization')).toBe(
|
||||
'Bearer child-apismart-key',
|
||||
)
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific custom headers on providerOverride shim requests too', async () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
|
||||
@@ -45,9 +45,13 @@ import {
|
||||
getNearaiBaseUrlOverride,
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getUsableRouteConfigEnvValue,
|
||||
getUsableRouteModelEnvValue,
|
||||
getXaiBaseUrlOverride,
|
||||
getXiaomiMimoBaseUrlOverride,
|
||||
resolveEnvOnlyProviderRouteId,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
routeSupportsCustomHeaders,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js'
|
||||
import {
|
||||
@@ -376,12 +380,12 @@ function applyAimlapiEnvOnlyDefaults(): void {
|
||||
|
||||
function applyApismartEnvOnlyDefaults(): void {
|
||||
const baseUrlOverride =
|
||||
process.env.OPENAI_BASE_URL?.trim() ||
|
||||
process.env.OPENAI_API_BASE?.trim() ||
|
||||
getUsableRouteConfigEnvValue(process.env.OPENAI_BASE_URL) ||
|
||||
getUsableRouteConfigEnvValue(process.env.OPENAI_API_BASE) ||
|
||||
undefined
|
||||
const modelOverride =
|
||||
process.env.APISMART_MODEL?.trim() ||
|
||||
process.env.OPENAI_MODEL?.trim() ||
|
||||
getUsableRouteModelEnvValue(process.env.APISMART_MODEL) ||
|
||||
getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) ||
|
||||
undefined
|
||||
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
@@ -489,7 +493,17 @@ export async function getAnthropicClient({
|
||||
const containerId = process.env.CLAUDE_CODE_CONTAINER_ID
|
||||
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
|
||||
const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP
|
||||
const customHeaders = getCustomHeaders()
|
||||
const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env)
|
||||
const outboundRouteId =
|
||||
resolveRouteIdFromBaseUrl(
|
||||
providerOverride?.baseURL ??
|
||||
process.env.OPENAI_BASE_URL ??
|
||||
process.env.OPENAI_API_BASE,
|
||||
) ?? envOnlyProviderRouteId
|
||||
const customHeaders =
|
||||
outboundRouteId && !routeSupportsCustomHeaders(outboundRouteId)
|
||||
? {}
|
||||
: getCustomHeaders()
|
||||
const defaultHeaders: { [key: string]: string } = {
|
||||
'x-app': 'cli',
|
||||
'User-Agent': getUserAgent(),
|
||||
@@ -516,7 +530,6 @@ export async function getAnthropicClient({
|
||||
defaultHeaders['x-anthropic-additional-protection'] = 'true'
|
||||
}
|
||||
|
||||
const envOnlyProviderRouteId = resolveEnvOnlyProviderRouteId(process.env)
|
||||
const useMiniMaxEnvOnlyProvider = shouldUseMiniMaxEnvOnlyProvider(
|
||||
model,
|
||||
envOnlyProviderRouteId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { CredentialPool, firstUsableCredential, hasInvalidCredentialPlaceholder, parseCredentialList } from './credentialPool.js'
|
||||
import { CredentialPool, firstUsableCredential, hasInvalidCredentialPlaceholder, hasUsableOpenAICredential, parseCredentialList } from './credentialPool.js'
|
||||
|
||||
test('parseCredentialList trims comma-separated keys', () => {
|
||||
expect(parseCredentialList(' key-a, key-b ,,key-c ')).toEqual([
|
||||
|
||||
@@ -522,6 +522,12 @@ class OpenAIShimMessages {
|
||||
runtimeShimContext.routeId === null ||
|
||||
getRouteDescriptor(runtimeShimContext.routeId)?.setup
|
||||
.dedicatedCredentialsOnly !== true,
|
||||
routeSupportsAuthHeaders:
|
||||
runtimeShimContext.descriptor?.transportConfig.openaiShim
|
||||
?.supportsAuthHeaders !== false,
|
||||
routeSupportsCustomHeaders:
|
||||
runtimeShimContext.descriptor?.transportConfig.openaiShim
|
||||
?.supportsAuthHeaders !== false,
|
||||
getCredentialPool: value => this.getCredentialPool(value),
|
||||
filterAnthropicHeaders, isGeminiMode, resolveRouteCredentialValue, isXaiBaseUrl, isLongcatBaseUrl, parseCredentialList, resolveXaiAccessToken, hasInvalidCredentialPlaceholder, buildOpenAICompatibilityErrorMessage, isAzureStyleBaseUrl, resolveGeminiCredential, COPILOT_HEADERS, getSessionId, getLocalProviderRetryBaseUrls, buildOllamaChatUrl, logForDebugging, redactUrlForDiagnostics, redactSecretValueForDisplay, headersWithRequestUrl, classifyOpenAINetworkFailure, classifyOpenAIHttpFailure, markOpenAIRequestNonReplayable, fetchRequest: (url, init) => fetchWithHeadersDeadline(url, init, { callerSignal: options?.signal, timeoutMs: apiTimeoutMs }), isResponseHeadersTimeout: error => error instanceof ResponseHeadersTimeoutError, requestBodyContainsImages, formatRetryAfterHint, redactUrlsInMessage, sleepMs, shouldAttemptLocalToollessRetry, refreshCopilotTokenOn401, isCopilotTokenExpiredError, convertOllamaStreamingResponse, convertOllamaNonStreamingResponse, logApiCallStart, logApiCallEnd, stableStringifyJson, APIError, GITHUB_429_MAX_RETRIES, GITHUB_429_BASE_DELAY_SEC, GITHUB_429_MAX_DELAY_SEC, request, params, options, requestProcessEnv, fastPath, shimConfig, runtimeShimContext, body, effectiveTransport, useNativeOllamaChat, buildResponsesBody, serializeBody, isLocal, isGithub, isGithubCopilot, isGithubModels, omitTools,
|
||||
})
|
||||
|
||||
@@ -585,7 +585,7 @@ test('OPENAI_API_KEYS rejects placeholder values before sending requests', async
|
||||
max_tokens: 32,
|
||||
stream: false,
|
||||
}),
|
||||
).rejects.toThrow(/SUA_CHAVE|Authentication failed/)
|
||||
).rejects.toThrow(/invalid credential placeholder|Authentication failed/)
|
||||
|
||||
expect(authorizations).toEqual([])
|
||||
})
|
||||
|
||||
@@ -47,6 +47,8 @@ type RequestExecutorContext = {
|
||||
defaultHeaders: Record<string, string>
|
||||
providerOverride?: { apiKey?: string }
|
||||
routeAcceptsGenericOpenAICredentials: boolean
|
||||
routeSupportsAuthHeaders: boolean
|
||||
routeSupportsCustomHeaders: boolean
|
||||
getCredentialPool: (rawCredentials: string) => CredentialPool | null
|
||||
filterAnthropicHeaders: (
|
||||
headers?: Record<string, string>,
|
||||
@@ -181,6 +183,8 @@ export async function executeOpenAIRequest(
|
||||
defaultHeaders,
|
||||
providerOverride,
|
||||
routeAcceptsGenericOpenAICredentials,
|
||||
routeSupportsAuthHeaders,
|
||||
routeSupportsCustomHeaders,
|
||||
getCredentialPool,
|
||||
filterAnthropicHeaders,
|
||||
isGeminiMode,
|
||||
@@ -240,11 +244,25 @@ export async function executeOpenAIRequest(
|
||||
isGithubCopilot,
|
||||
isGithubModels,
|
||||
} = context
|
||||
const managedClientHeaders = Object.fromEntries(
|
||||
Object.entries(defaultHeaders).filter(([name]) =>
|
||||
[
|
||||
'user-agent',
|
||||
'x-app',
|
||||
'x-client-app',
|
||||
'x-claude-code-session-id',
|
||||
'x-claude-remote-container-id',
|
||||
'x-claude-remote-session-id',
|
||||
].includes(name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...filterAnthropicHeaders(shimConfig.headers),
|
||||
...defaultHeaders,
|
||||
...filterAnthropicHeaders(options?.headers),
|
||||
...(routeSupportsCustomHeaders ? defaultHeaders : managedClientHeaders),
|
||||
...(routeSupportsCustomHeaders
|
||||
? filterAnthropicHeaders(options?.headers)
|
||||
: {}),
|
||||
}
|
||||
|
||||
const isGemini = isGeminiMode()
|
||||
@@ -331,7 +349,9 @@ export async function executeOpenAIRequest(
|
||||
?.defaultAuthHeader
|
||||
const configuredAuthHeaderValue = catalogAuthHeader
|
||||
? undefined
|
||||
: requestProcessEnv.OPENAI_AUTH_HEADER_VALUE?.trim()
|
||||
: routeSupportsAuthHeaders
|
||||
? requestProcessEnv.OPENAI_AUTH_HEADER_VALUE?.trim()
|
||||
: undefined
|
||||
if (configuredAuthHeaderValue && /[\r\n]/.test(configuredAuthHeaderValue)) {
|
||||
throw new Error(
|
||||
'OPENAI_AUTH_HEADER_VALUE must not contain CR/LF characters',
|
||||
@@ -339,7 +359,9 @@ export async function executeOpenAIRequest(
|
||||
}
|
||||
const customAuthHeader = catalogAuthHeader
|
||||
? undefined
|
||||
: requestProcessEnv.OPENAI_AUTH_HEADER?.trim()
|
||||
: routeSupportsAuthHeaders
|
||||
? requestProcessEnv.OPENAI_AUTH_HEADER?.trim()
|
||||
: undefined
|
||||
const hasCustomAuthHeader = Boolean(
|
||||
customAuthHeader && /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(customAuthHeader),
|
||||
)
|
||||
@@ -354,7 +376,7 @@ export async function executeOpenAIRequest(
|
||||
401,
|
||||
undefined,
|
||||
buildOpenAICompatibilityErrorMessage(
|
||||
'OpenAI API error 401: invalid credential pool placeholder SUA_CHAVE detected',
|
||||
'OpenAI API error 401: invalid credential placeholder detected',
|
||||
{
|
||||
category: 'auth_invalid',
|
||||
requestUrl: request.baseUrl,
|
||||
|
||||
@@ -340,6 +340,19 @@ test('resolveProviderRequest uses APISMART_MODEL when APISMART_API_KEY is presen
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest keeps ApiSmart intent with the generic OpenAI compatibility flag', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('KIMI_K3')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest falls back to OPENAI_MODEL for ApiSmart when APISMART_MODEL is unset', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
@@ -365,6 +378,52 @@ test('resolveProviderRequest treats blank APISMART_MODEL as unset for ApiSmart',
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test.each(['undefined', 'null', ' NULL '])(
|
||||
'resolveProviderRequest treats APISMART_MODEL=%s as unset',
|
||||
placeholder => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: placeholder,
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
},
|
||||
)
|
||||
|
||||
test.each(['undefined', 'null', ' NULL '])(
|
||||
'resolveProviderRequest treats ApiSmart OPENAI_MODEL fallback %s as unset',
|
||||
placeholder => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
OPENAI_MODEL: placeholder,
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
},
|
||||
)
|
||||
|
||||
test.each(['null', ' NULL ', 'undefined'])(
|
||||
'resolveProviderRequest treats OPENAI_BASE_URL=%s as unset for ApiSmart',
|
||||
placeholder => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
OPENAI_BASE_URL: placeholder,
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('KIMI_K3')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
},
|
||||
)
|
||||
|
||||
test('resolveProviderRequest uses the ApiSmart route default when no model env is set', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
@@ -431,6 +490,19 @@ test('resolveProviderRequest ignores ApiSmart model when explicit OPENAI_BASE_UR
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores an ambient ApiSmart key for an explicit native provider', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
CLAUDE_CODE_USE_BEDROCK: '1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('codexplan')
|
||||
expect(request.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest resolves the GPT-5.6 family Codex aliases', () => {
|
||||
const sol = resolveProviderRequest({ model: 'gpt-5.6-sol', processEnv: {} })
|
||||
expect(sol.resolvedModel).toBe('gpt-5.6-sol')
|
||||
|
||||
@@ -26,10 +26,11 @@ import { getCatalogEntriesForRoute } from '../../integrations/registry.js'
|
||||
import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getUsableRouteModelEnvValue,
|
||||
hasApismartEnvOnlyProviderIntent,
|
||||
isApismartBaseUrl,
|
||||
isClinePassBaseUrl,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import { hasUsableOpenAICredential } from './credentialPool.js'
|
||||
import {
|
||||
openAIShimSupportsApiFormatForModel,
|
||||
resolveOpenAIShimRuntimeContext,
|
||||
@@ -235,13 +236,13 @@ function isPrivateIpv6Address(hostname: string): boolean {
|
||||
}
|
||||
|
||||
// Reads an env-var-style string intended as a URL or path, rejecting both
|
||||
// empty strings and the literal string "undefined" that Windows shells can
|
||||
// empty strings and literal nullish strings that shells and dotenv templates
|
||||
// write when a variable is unset-then-referenced without quotes (issue #336).
|
||||
function asEnvUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed === 'undefined') {
|
||||
if (trimmed.toLowerCase() === 'undefined' || trimmed.toLowerCase() === 'null') {
|
||||
return undefined
|
||||
}
|
||||
return trimmed
|
||||
@@ -256,11 +257,12 @@ function asNamedEnvUrl(
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
if (trimmed === 'undefined') {
|
||||
const normalized = trimmed.toLowerCase()
|
||||
if (normalized === 'undefined' || normalized === 'null') {
|
||||
if (!warnedUndefinedEnvNames.has(envName)) {
|
||||
warnedUndefinedEnvNames.add(envName)
|
||||
logForDebugging(
|
||||
`[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`,
|
||||
`[provider-config] Environment variable ${envName} is the literal string "${trimmed}"; ignoring it.`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
@@ -933,7 +935,7 @@ export function resolveProviderRequest(options?: {
|
||||
const isMistralMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
const isGeminiMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
const isClinePassMode = Boolean(processEnv.CLINE_API_KEY?.trim())
|
||||
const isApismartMode = hasUsableOpenAICredential(processEnv.APISMART_API_KEY)
|
||||
const isApismartMode = hasApismartEnvOnlyProviderIntent(processEnv)
|
||||
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
||||
|
||||
const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
|
||||
@@ -1012,8 +1014,8 @@ export function resolveProviderRequest(options?: {
|
||||
? processEnv.CLINE_API_MODEL?.trim() ||
|
||||
processEnv.OPENAI_MODEL?.trim()
|
||||
: effectiveApismartMode
|
||||
? processEnv.APISMART_MODEL?.trim() ||
|
||||
processEnv.OPENAI_MODEL?.trim()
|
||||
? getUsableRouteModelEnvValue(processEnv.APISMART_MODEL) ||
|
||||
getUsableRouteModelEnvValue(processEnv.OPENAI_MODEL)
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
options?.fallbackModel?.trim() ||
|
||||
(isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) ||
|
||||
|
||||
@@ -75,6 +75,8 @@ const SAVED_ENV = {
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
MIMO_API_KEY: process.env.MIMO_API_KEY,
|
||||
APISMART_API_KEY: process.env.APISMART_API_KEY,
|
||||
APISMART_MODEL: process.env.APISMART_MODEL,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
CODEX_API_KEY: process.env.CODEX_API_KEY,
|
||||
@@ -131,6 +133,8 @@ beforeEach(async () => {
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.NVIDIA_NIM
|
||||
delete process.env.MINIMAX_API_KEY
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.APISMART_MODEL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
delete process.env.MIMO_API_KEY
|
||||
delete process.env.OPENAI_MODEL
|
||||
@@ -340,6 +344,41 @@ test('getDefaultMainLoopModelSetting defaults MiniMax to M3', async () => {
|
||||
expect(getDefaultMainLoopModel()).toBe('MiniMax-M3')
|
||||
})
|
||||
|
||||
test('getDefaultMainLoopModelSetting uses the credential-only ApiSmart model', async () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
const {
|
||||
getDefaultMainLoopModel,
|
||||
getDefaultMainLoopModelSetting,
|
||||
} = await importFreshModelModule()
|
||||
expect(getDefaultMainLoopModelSetting()).toBe('KIMI_K3')
|
||||
expect(getDefaultMainLoopModel()).toBe('KIMI_K3')
|
||||
})
|
||||
|
||||
test('ApiSmart model selection is consistent across main and helper tiers', async () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
const {
|
||||
getDefaultHaikuModel,
|
||||
getDefaultOpusModel,
|
||||
getDefaultSonnetModel,
|
||||
getSmallFastModel,
|
||||
} = await importFreshModelModule()
|
||||
expect(getDefaultOpusModel()).toBe('KIMI_K3')
|
||||
expect(getDefaultSonnetModel()).toBe('KIMI_K3')
|
||||
expect(getDefaultHaikuModel()).toBe('KIMI_K3')
|
||||
expect(getSmallFastModel()).toBe('KIMI_K3')
|
||||
})
|
||||
|
||||
test('getDefaultMainLoopModelSetting uses the ApiSmart descriptor default', async () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
|
||||
const { getDefaultMainLoopModelSetting } = await importFreshModelModule()
|
||||
expect(getDefaultMainLoopModelSetting()).toBe('DEEPSEEK_V4_FLASH')
|
||||
})
|
||||
|
||||
test('getDefaultMainLoopModelSetting uses the NVIDIA NIM route model', async () => {
|
||||
process.env.NVIDIA_NIM = '1'
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
|
||||
@@ -35,7 +35,11 @@ import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
import { DEFAULT_GEMINI_MODEL } from '../providerProfile.js'
|
||||
import { getAntModelOverrideConfig, resolveAntModel } from './antModels.js'
|
||||
import { getRouteDefaultModel } from '../../integrations/routeMetadata.js'
|
||||
import {
|
||||
getRouteDefaultModel,
|
||||
getUsableRouteModelEnvValue,
|
||||
resolveEnvOnlyProviderRouteId,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
|
||||
export type ModelShortName = string
|
||||
export type ModelName = string
|
||||
@@ -45,6 +49,18 @@ function getMiniMaxModelEnv(): string | undefined {
|
||||
return process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
function getApiSmartModelEnv(): string | undefined {
|
||||
if (resolveEnvOnlyProviderRouteId(process.env) !== 'apismart') {
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
getUsableRouteModelEnvValue(process.env.APISMART_MODEL) ||
|
||||
getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) ||
|
||||
getRouteDefaultModel('apismart') ||
|
||||
'DEEPSEEK_V4_FLASH'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
@@ -56,6 +72,8 @@ export function getSmallFastModel(): ModelName {
|
||||
if (isCustomAnthropicProvider()) {
|
||||
return process.env.ANTHROPIC_MODEL || getDefaultHaikuModel()
|
||||
}
|
||||
const apiSmartModel = getApiSmartModelEnv()
|
||||
if (apiSmartModel) return apiSmartModel
|
||||
// For Gemini provider, use a fast model
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite'
|
||||
@@ -195,6 +213,8 @@ export function getDefaultOpusModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
}
|
||||
const apiSmartModel = getApiSmartModelEnv()
|
||||
if (apiSmartModel) return apiSmartModel
|
||||
// Gemini provider
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
return process.env.GEMINI_MODEL || 'gemini-2.5-pro'
|
||||
@@ -245,6 +265,8 @@ export function getDefaultSonnetModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
}
|
||||
const apiSmartModel = getApiSmartModelEnv()
|
||||
if (apiSmartModel) return apiSmartModel
|
||||
// Gemini provider
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
return process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL
|
||||
@@ -293,6 +315,8 @@ export function getDefaultHaikuModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
}
|
||||
const apiSmartModel = getApiSmartModelEnv()
|
||||
if (apiSmartModel) return apiSmartModel
|
||||
// Mistral provider
|
||||
if (getAPIProvider() === 'mistral') {
|
||||
return process.env.MISTRAL_MODEL || 'ministral-3b-latest'
|
||||
@@ -379,6 +403,8 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
if (isCustomAnthropicProvider()) {
|
||||
return process.env.ANTHROPIC_MODEL || getDefaultSonnetModel()
|
||||
}
|
||||
const apiSmartModel = getApiSmartModelEnv()
|
||||
if (apiSmartModel) return apiSmartModel
|
||||
// GitHub Copilot provider: check settings.model first, then env, then default
|
||||
if (getAPIProvider() === 'github') {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
applyModelFlagFromArgs,
|
||||
VALID_PROVIDERS,
|
||||
} from './providerFlag.js'
|
||||
import { resolveEnvOnlyProviderRouteId } from '../integrations/routeMetadata.js'
|
||||
|
||||
const ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
@@ -22,6 +23,7 @@ const ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_API_KEY',
|
||||
@@ -73,6 +75,7 @@ const RESET_KEYS = [
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_API_KEY',
|
||||
@@ -191,6 +194,16 @@ describe('applyProviderFlag - anthropic', () => {
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe('first-party-key')
|
||||
})
|
||||
|
||||
test('explicit Anthropic selection suppresses an ambient ApiSmart key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'first-party-key'
|
||||
process.env.APISMART_API_KEY = 'ambient-apismart-key'
|
||||
|
||||
applyProviderFlag('anthropic', [])
|
||||
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('anthropic')
|
||||
expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - custom Anthropic-compatible', () => {
|
||||
@@ -1019,6 +1032,41 @@ describe('applyProviderFlag - apismart', () => {
|
||||
expect(process.env.OPENAI_MODEL).toBe('KIMI_K3')
|
||||
})
|
||||
|
||||
test('ApiSmart-specific model replaces a stale generic OpenAI model', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
process.env.OPENAI_MODEL = 'gpt-4o'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_MODEL).toBe('KIMI_K3')
|
||||
})
|
||||
|
||||
test.each(['undefined', 'null', ' NULL '])(
|
||||
'ignores stale generic model sentinel %s for ApiSmart',
|
||||
placeholder => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_MODEL = placeholder
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH')
|
||||
},
|
||||
)
|
||||
|
||||
test.each(['undefined', 'null', ' NULL '])(
|
||||
'ignores stale generic base URL sentinel %s for ApiSmart',
|
||||
placeholder => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_BASE_URL = placeholder
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-secret-key')
|
||||
},
|
||||
)
|
||||
|
||||
test('makes an explicit --model override APISMART_MODEL', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
@@ -1029,6 +1077,16 @@ describe('applyProviderFlag - apismart', () => {
|
||||
expect(process.env.APISMART_MODEL).toBe('GLM_5.2')
|
||||
})
|
||||
|
||||
test('standalone --model overrides credential-only ApiSmart model selection', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyModelFlagFromArgs(['--model', 'GLM_5.2'])
|
||||
|
||||
expect(process.env.OPENAI_MODEL).toBe('GLM_5.2')
|
||||
expect(process.env.APISMART_MODEL).toBe('GLM_5.2')
|
||||
})
|
||||
|
||||
test('dedicated key overrides a lingering OPENAI_API_KEY from another provider', () => {
|
||||
process.env.OPENAI_API_KEY = 'existing-openai-key'
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
@@ -1046,14 +1104,54 @@ describe('applyProviderFlag - apismart', () => {
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])(
|
||||
'does not mirror placeholder ApiSmart credential %s',
|
||||
placeholder => {
|
||||
process.env.APISMART_API_KEY = placeholder
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
test('clears a copied ApiSmart key from OPENAI_API_KEY when switching to another provider', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1'
|
||||
|
||||
applyProviderFlag('openai', [])
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('openai')
|
||||
expect(resolveEnvOnlyProviderRouteId(process.env)).toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
['openai', 'https://api.openai.com/v1'],
|
||||
['github', 'https://api.githubcopilot.com'],
|
||||
['ollama', 'http://localhost:11434/v1'],
|
||||
['nvidia-nim', 'https://integrate.api.nvidia.com/v1'],
|
||||
['bankr', 'https://llm.bankr.bot/v1'],
|
||||
['xai', 'https://api.x.ai/v1'],
|
||||
['xiaomi-mimo', 'https://api.xiaomimimo.com/v1'],
|
||||
['venice', 'https://api.venice.ai/api/v1'],
|
||||
])(
|
||||
'explicit %s selection replaces a stale ApiSmart endpoint',
|
||||
(provider, expectedBaseUrl) => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1'
|
||||
process.env.OPENAI_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderFlag(provider, [])
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe(expectedBaseUrl)
|
||||
expect(process.env.OPENAI_API_KEY).not.toBe('apismart-secret-key')
|
||||
expect(process.env.OPENAI_MODEL).not.toBe('KIMI_K3')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - xai', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getAllVendors,
|
||||
getGateway,
|
||||
getVendor,
|
||||
getTransportKindForRoute,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
routeSupportsApiFormatSelection,
|
||||
@@ -29,7 +30,13 @@ import {
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../integrations/index.js'
|
||||
import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js'
|
||||
import { isApismartBaseUrl } from '../integrations/routeMetadata.js'
|
||||
import {
|
||||
hasApismartEnvOnlyProviderIntent,
|
||||
getUsableRouteConfigEnvValue,
|
||||
getUsableRouteModelEnvValue,
|
||||
hasUsableRouteCredentialEnvValue,
|
||||
isApismartBaseUrl,
|
||||
} from '../integrations/routeMetadata.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js'
|
||||
|
||||
const PREFERRED_PROVIDER_ORDER = [
|
||||
@@ -170,8 +177,7 @@ function getRouteDefaults(provider: string): {
|
||||
}
|
||||
|
||||
function normalizeBaseUrlEnv(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed && trimmed !== 'undefined' ? trimmed : undefined
|
||||
return getUsableRouteConfigEnvValue(value)
|
||||
}
|
||||
|
||||
function getConfiguredOpenAIBaseUrl(): string | undefined {
|
||||
@@ -193,7 +199,6 @@ function shouldReplaceStaleKnownBaseUrl(provider: string): boolean {
|
||||
|
||||
const targetRouteId = resolveProfileRoute(provider).routeId
|
||||
return (
|
||||
targetRouteId !== 'openai' &&
|
||||
targetRouteId !== 'custom' &&
|
||||
targetRouteId !== 'unknown-fallback' &&
|
||||
currentRouteId !== targetRouteId
|
||||
@@ -231,6 +236,25 @@ function applyOpenAIBaseUrlDefault(provider: string, baseUrl?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function replaceStaleKnownBaseUrlForExplicitProvider(
|
||||
provider: string,
|
||||
baseUrl: string | undefined,
|
||||
): boolean {
|
||||
const routeId = resolveProfileRoute(provider).routeId
|
||||
const transportKind = getTransportKindForRoute(routeId)
|
||||
if (
|
||||
(transportKind !== 'openai-compatible' && transportKind !== 'local') ||
|
||||
!baseUrl ||
|
||||
isPlaceholderBaseUrl(baseUrl) ||
|
||||
!shouldReplaceStaleKnownBaseUrl(provider)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
process.env.OPENAI_BASE_URL = baseUrl.trim()
|
||||
return true
|
||||
}
|
||||
|
||||
function clearUnsupportedOpenAIShimSettings(routeId: string): void {
|
||||
if (!routeSupportsApiFormatSelection(routeId)) {
|
||||
delete process.env.OPENAI_API_FORMAT
|
||||
@@ -273,6 +297,7 @@ export function applyModelFlagFromArgs(args: string[]): void {
|
||||
const useGithub =
|
||||
process.env.CLAUDE_CODE_USE_GITHUB === '1' ||
|
||||
process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||
const useApismartEnvOnly = hasApismartEnvOnlyProviderIntent(process.env)
|
||||
|
||||
if (useGemini) {
|
||||
process.env.GEMINI_MODEL = model
|
||||
@@ -280,6 +305,12 @@ export function applyModelFlagFromArgs(args: string[]): void {
|
||||
process.env.MISTRAL_MODEL = model
|
||||
} else if (useOpenAI || useGithub) {
|
||||
process.env.OPENAI_MODEL = model
|
||||
if (useApismartEnvOnly) {
|
||||
process.env.APISMART_MODEL = model
|
||||
}
|
||||
} else if (useApismartEnvOnly) {
|
||||
process.env.OPENAI_MODEL = model
|
||||
process.env.APISMART_MODEL = model
|
||||
} else {
|
||||
process.env.ANTHROPIC_MODEL = model
|
||||
}
|
||||
@@ -305,6 +336,12 @@ export function applyProviderFlag(
|
||||
}
|
||||
}
|
||||
|
||||
// Record explicit CLI provider intent separately from compatibility flags.
|
||||
// In particular, built-in Anthropic has no CLAUDE_CODE_USE_* flag, while an
|
||||
// OpenAI-compatible flag alone does not identify a concrete gateway.
|
||||
process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID =
|
||||
resolveProfileRoute(provider).routeId
|
||||
|
||||
const opengatewayApiKey = process.env.OPENGATEWAY_API_KEY?.trim()
|
||||
const copiedOpenAIKeyProvider =
|
||||
process.env.OPENAI_API_KEY !== undefined &&
|
||||
@@ -366,6 +403,19 @@ export function applyProviderFlag(
|
||||
const model = parseModelFlag(args)
|
||||
const { defaultBaseUrl, defaultModel } = getRouteDefaults(provider)
|
||||
|
||||
// A CLI provider selection is a route transition, not just a flag change.
|
||||
// Replace endpoints owned by the previously selected known route for every
|
||||
// OpenAI-shim provider, while preserving unknown user-managed proxy URLs.
|
||||
const replacedKnownProviderEndpoint =
|
||||
replaceStaleKnownBaseUrlForExplicitProvider(provider, defaultBaseUrl)
|
||||
if (replacedKnownProviderEndpoint && !model) {
|
||||
if (defaultModel) {
|
||||
process.env.OPENAI_MODEL = defaultModel
|
||||
} else {
|
||||
delete process.env.OPENAI_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
// Azure-style routing changes both request paths and authentication. It is
|
||||
// only meaningful for an explicit OpenAI/Azure configuration, so never let
|
||||
// it follow a provider switch to another OpenAI-compatible endpoint.
|
||||
@@ -428,6 +478,10 @@ export function applyProviderFlag(
|
||||
|
||||
case 'openai':
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
applyOpenAIBaseUrlDefault(
|
||||
provider,
|
||||
defaultBaseUrl ?? 'https://api.openai.com/v1',
|
||||
)
|
||||
if (model) process.env.OPENAI_MODEL = model
|
||||
break
|
||||
|
||||
@@ -607,10 +661,19 @@ export function applyProviderFlag(
|
||||
provider,
|
||||
defaultBaseUrl ?? 'https://gw.apismart.ai/v1',
|
||||
)
|
||||
process.env.OPENAI_MODEL ??=
|
||||
process.env.APISMART_MODEL?.trim() ||
|
||||
defaultModel ||
|
||||
'DEEPSEEK_V4_FLASH'
|
||||
{
|
||||
const apismartModel = getUsableRouteModelEnvValue(
|
||||
process.env.APISMART_MODEL,
|
||||
)
|
||||
if (apismartModel) {
|
||||
process.env.OPENAI_MODEL = apismartModel
|
||||
} else {
|
||||
process.env.OPENAI_MODEL =
|
||||
getUsableRouteModelEnvValue(process.env.OPENAI_MODEL) ??
|
||||
defaultModel ??
|
||||
'DEEPSEEK_V4_FLASH'
|
||||
}
|
||||
}
|
||||
if (model) {
|
||||
process.env.OPENAI_MODEL = model
|
||||
process.env.APISMART_MODEL = model
|
||||
@@ -620,7 +683,10 @@ export function applyProviderFlag(
|
||||
// and clear any stale generic key so another provider's credential is
|
||||
// never forwarded to ApiSmart.
|
||||
if (
|
||||
process.env.APISMART_API_KEY &&
|
||||
hasUsableRouteCredentialEnvValue(
|
||||
'APISMART_API_KEY',
|
||||
process.env.APISMART_API_KEY,
|
||||
) &&
|
||||
isApismartBaseUrl(getConfiguredOpenAIBaseUrl())
|
||||
) {
|
||||
process.env.OPENAI_API_KEY = process.env.APISMART_API_KEY
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PROFILE_FILE_NAME,
|
||||
redactSecretValueForDisplay,
|
||||
saveProfileFile,
|
||||
sanitizeOpenAICredentialPool,
|
||||
sanitizeProviderConfigValue,
|
||||
hasInvalidOpenAICredentialPool,
|
||||
selectAutoProfile,
|
||||
@@ -278,6 +279,47 @@ test('openai launch preserves persisted ApiSmart dedicated credentials across re
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart')
|
||||
})
|
||||
|
||||
test('openai launch withholds a persisted mirrored ApiSmart key after live endpoint retargeting', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
OPENAI_API_KEY: 'apismart-secret-key',
|
||||
APISMART_API_KEY: 'apismart-secret-key',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example/v1',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example/v1')
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.APISMART_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('openai launch keeps a live generic key when retargeting a persisted ApiSmart profile', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
OPENAI_API_KEY: 'apismart-secret-key',
|
||||
APISMART_API_KEY: 'apismart-secret-key',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example/v1',
|
||||
OPENAI_API_KEY: 'proxy-key',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example/v1')
|
||||
assert.equal(env.OPENAI_API_KEY, 'proxy-key')
|
||||
assert.equal(env.APISMART_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('buildApismartProfileEnv prefers APISMART_MODEL over OPENAI_MODEL', () => {
|
||||
const env = buildApismartProfileEnv({
|
||||
apiKey: 'apismart-secret-key',
|
||||
@@ -300,6 +342,12 @@ test('buildApismartProfileEnv refuses to copy the dedicated credential to a cust
|
||||
assert.equal(env, null)
|
||||
})
|
||||
|
||||
for (const placeholder of ['SUA_CHAVE', 'null', 'undefined', ' NULL ']) {
|
||||
test(`buildApismartProfileEnv rejects placeholder credential ${placeholder}`, () => {
|
||||
assert.equal(buildApismartProfileEnv({ apiKey: placeholder }), null)
|
||||
})
|
||||
}
|
||||
|
||||
test('openai launch carries APISMART_API_KEY only when the route resolves to apismart', async () => {
|
||||
const offRoute = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
@@ -966,6 +1014,30 @@ test('applyStartupEnvFromProfile applies valid startup env (issue #1651)', async
|
||||
assert.equal(processEnv.OPENGATEWAY_API_KEY, 'test-key')
|
||||
})
|
||||
|
||||
test('invalid ambient ApiSmart placeholder does not suppress a saved profile', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
APISMART_API_KEY: ' SUA_CHAVE ',
|
||||
}
|
||||
const warnings: string[] = []
|
||||
|
||||
const error = await applyStartupEnvFromProfile({
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'saved-openai-key',
|
||||
}),
|
||||
processEnv,
|
||||
onValidationError: message => warnings.push(message),
|
||||
})
|
||||
|
||||
assert.equal(error, null)
|
||||
assert.deepEqual(warnings, [])
|
||||
assert.equal(processEnv.OPENAI_BASE_URL, 'https://api.openai.com/v1')
|
||||
assert.equal(processEnv.OPENAI_MODEL, 'gpt-4o')
|
||||
assert.equal(processEnv.OPENAI_API_KEY, 'saved-openai-key')
|
||||
assert.equal(processEnv.APISMART_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile preserves explicit OpenAI-compatible env without a saved profile', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: null,
|
||||
@@ -2728,6 +2800,7 @@ test('openai launch preserves invalid live pooled credentials for launch validat
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(hasInvalidOpenAICredentialPool(env.OPENAI_API_KEYS), true)
|
||||
})
|
||||
|
||||
test('openai launch lets a live singular key override a saved pool', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
|
||||
@@ -25,6 +25,9 @@ import { getErrnoCode } from './errors.js'
|
||||
import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getRouteCredentialEnvVars,
|
||||
hasExplicitOpenAICompatibleOptOut,
|
||||
hasUsableRouteCredentialEnvValue,
|
||||
isApismartBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
normalizeXiaomiMimoBaseUrl,
|
||||
@@ -644,7 +647,7 @@ export function buildApismartProfileEnv(options: {
|
||||
}): ProfileEnv | null {
|
||||
const processEnv = options.processEnv ?? process.env
|
||||
const key = sanitizeApiKey(options.apiKey ?? processEnv.APISMART_API_KEY)
|
||||
if (!key) {
|
||||
if (!key || !hasUsableRouteCredentialEnvValue('APISMART_API_KEY', key)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1316,15 +1319,6 @@ function hasExplicitNonOpenAIProviderSelection(
|
||||
)
|
||||
}
|
||||
|
||||
function hasExplicitOpenAICompatibleOptOut(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined &&
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
)
|
||||
}
|
||||
|
||||
function hasConcreteProviderSelection(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
@@ -1401,7 +1395,10 @@ function hasConcreteProviderSelection(
|
||||
sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.APISMART_API_KEY) !== undefined
|
||||
hasUsableRouteCredentialEnvValue(
|
||||
'APISMART_API_KEY',
|
||||
processEnv.APISMART_API_KEY,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2015,10 +2012,9 @@ export async function buildLaunchEnv(options: {
|
||||
} else {
|
||||
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||
}
|
||||
const openAICredential = resolveOpenAICredentialEnvOverride(
|
||||
processEnv,
|
||||
persistedEnv,
|
||||
)
|
||||
const liveOpenAICredential = resolveOpenAICredentialEnvSelection(processEnv)
|
||||
const openAICredential =
|
||||
liveOpenAICredential || resolveOpenAICredentialEnvSelection(persistedEnv)
|
||||
if (openAICredential) {
|
||||
env[openAICredential.envVar] = openAICredential.value
|
||||
}
|
||||
@@ -2045,6 +2041,30 @@ export async function buildLaunchEnv(options: {
|
||||
const effectiveOpenAIRouteId =
|
||||
resolvedOpenAIRouteId ||
|
||||
(shouldUsePersistedOpenAIRouteId ? persistedOpenAIRouteId : undefined)
|
||||
const persistedCredentialRouteId =
|
||||
resolveRouteIdFromBaseUrl(persistedOpenAIBaseUrl) || persistedOpenAIRouteId
|
||||
const persistedOpenAICredential =
|
||||
resolveOpenAICredentialEnvSelection(persistedEnv)
|
||||
const persistedGenericCredentialMirrorsDedicatedRoute =
|
||||
!liveOpenAICredential &&
|
||||
persistedOpenAICredential?.envVar === 'OPENAI_API_KEY' &&
|
||||
!!persistedCredentialRouteId &&
|
||||
getRouteCredentialEnvVars(persistedCredentialRouteId)
|
||||
.filter(
|
||||
envVar =>
|
||||
envVar !== 'OPENAI_API_KEY' && envVar !== 'OPENAI_API_KEYS',
|
||||
)
|
||||
.some(
|
||||
envVar =>
|
||||
sanitizeApiKey(persistedEnv[envVar]) ===
|
||||
persistedOpenAICredential.value,
|
||||
)
|
||||
if (
|
||||
persistedGenericCredentialMirrorsDedicatedRoute &&
|
||||
effectiveOpenAIRouteId !== persistedCredentialRouteId
|
||||
) {
|
||||
delete env.OPENAI_API_KEY
|
||||
}
|
||||
if (resolvedOpenAIRouteId && resolvedOpenAIRouteId !== 'openai') {
|
||||
env.CLAUDE_CODE_PROVIDER_ROUTE_ID = resolvedOpenAIRouteId
|
||||
} else if (shouldUsePersistedOpenAIRouteId && persistedOpenAIRouteId) {
|
||||
|
||||
@@ -830,6 +830,24 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])(
|
||||
'rejects an ApiSmart profile with invalid credential placeholder %p',
|
||||
async apiKey => {
|
||||
const { addProviderProfile } = await importFreshProviderProfileModules()
|
||||
|
||||
const saved = addProviderProfile({
|
||||
provider: 'apismart',
|
||||
name: 'ApiSmart invalid',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
model: 'KIMI_K3',
|
||||
apiKey,
|
||||
})
|
||||
|
||||
expect(saved).toBeNull()
|
||||
expect(mockConfigState.providerProfiles).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test('apismart profile clears a stale route-specific model before applying its saved model', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
@@ -857,12 +875,46 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({ baseUrl: 'https://proxy.example/v1' }),
|
||||
buildApismartProfile({
|
||||
baseUrl: 'https://proxy.example/v1',
|
||||
authHeader: 'X-Proxy-Key',
|
||||
authScheme: 'raw',
|
||||
authHeaderValue: 'proxy-secret',
|
||||
customHeaders: { 'X-Team': 'devtools' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER).toBe('X-Proxy-Key')
|
||||
expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw')
|
||||
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('proxy-secret')
|
||||
expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Team: devtools')
|
||||
})
|
||||
|
||||
test('custom profile targeting ApiSmart mirrors its key into the endpoint credential', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
provider: 'custom',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
model: 'KIMI_K3',
|
||||
apiKey: 'apismart-custom-profile-key',
|
||||
apiFormat: 'responses',
|
||||
authHeader: 'X-Api-Key',
|
||||
authScheme: 'raw',
|
||||
authHeaderValue: 'unsupported-custom-auth',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-custom-profile-key')
|
||||
expect(process.env.APISMART_API_KEY).toBe('apismart-custom-profile-key')
|
||||
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined()
|
||||
})
|
||||
|
||||
test('cloudflare profile applies OpenAI-compatible env with CLOUDFLARE_API_TOKEN mirror', async () => {
|
||||
@@ -3189,6 +3241,46 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('removes stale startup persistence when an active profile has no usable startup credential', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
try {
|
||||
const { setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const { createProfileFile, saveProfileFile } = await import(
|
||||
`./providerProfile.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
saveProfileFile(
|
||||
createProfileFile('openai', {
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'stale-openai-key',
|
||||
}),
|
||||
{ configDir },
|
||||
)
|
||||
const apismartProfile = buildApismartProfile({
|
||||
id: 'apismart_keyless',
|
||||
apiKey: undefined,
|
||||
})
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [apismartProfile],
|
||||
}))
|
||||
|
||||
expect(
|
||||
setActiveProviderProfile('apismart_keyless', { configDir })?.id,
|
||||
).toBe('apismart_keyless')
|
||||
expect(existsSync(join(configDir, '.openclaude-profile.json'))).toBe(false)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('retargeted ApiSmart profiles persist without their dedicated credential', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
@@ -3201,6 +3293,10 @@ describe('setActiveProviderProfile', () => {
|
||||
const apismartProfile = buildApismartProfile({
|
||||
id: 'apismart_proxy',
|
||||
baseUrl: 'https://proxy.example/v1',
|
||||
authHeader: 'X-Proxy-Key',
|
||||
authScheme: 'raw',
|
||||
authHeaderValue: 'proxy-secret',
|
||||
customHeaders: { 'X-Team': 'devtools' },
|
||||
})
|
||||
|
||||
saveMockGlobalConfig(current => ({
|
||||
@@ -3218,6 +3314,10 @@ describe('setActiveProviderProfile', () => {
|
||||
expect(persisted.env).toEqual({
|
||||
OPENAI_BASE_URL: 'https://proxy.example/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
OPENAI_AUTH_HEADER: 'X-Proxy-Key',
|
||||
OPENAI_AUTH_SCHEME: 'raw',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'proxy-secret',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Team: devtools',
|
||||
})
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
@@ -3226,6 +3326,46 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('persists credentials by concrete ApiSmart endpoint before provider label', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
try {
|
||||
const { setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const profile = buildAtlasCloudProfile({
|
||||
id: 'atlas_label_apismart_endpoint',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
model: 'KIMI_K3',
|
||||
apiKey: 'endpoint-owned-key',
|
||||
})
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [profile],
|
||||
}))
|
||||
|
||||
expect(
|
||||
setActiveProviderProfile(profile.id, { configDir })?.id,
|
||||
).toBe(profile.id)
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
expect(persisted.env).toMatchObject({
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'KIMI_K3',
|
||||
OPENAI_API_KEY: 'endpoint-owned-key',
|
||||
APISMART_API_KEY: 'endpoint-owned-key',
|
||||
})
|
||||
expect(persisted.env.ATLAS_CLOUD_API_KEY).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('persists Xiaomi MiMo profiles using a legacy-compatible openai startup profile', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
routeSupportsApiFormatSelection,
|
||||
routeSupportsAuthHeaders,
|
||||
routeSupportsCustomHeaders,
|
||||
resolveProfileCapabilityRouteId,
|
||||
resolveProfileRoute,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
type ResolvedProfileRoute,
|
||||
@@ -54,10 +55,11 @@ import {
|
||||
getRouteDefaultBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isClinePassBaseUrl,
|
||||
isApismartBaseUrl,
|
||||
isFireworksBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
isNearaiBaseUrl,
|
||||
hasUsableRouteCredentialEnvValue,
|
||||
profileTargetsRoute,
|
||||
isXaiBaseUrl,
|
||||
isXiaomiMimoBaseUrl,
|
||||
resolveEnvOnlyProviderRouteId,
|
||||
@@ -153,8 +155,7 @@ function isClinePassProfile(profile: ProviderProfile): boolean {
|
||||
}
|
||||
|
||||
function isApismartProfile(profile: ProviderProfile): boolean {
|
||||
const baseUrl = profile.baseUrl?.trim()
|
||||
return !baseUrl || isApismartBaseUrl(baseUrl)
|
||||
return profileTargetsRoute(profile.provider, profile.baseUrl, 'apismart')
|
||||
}
|
||||
|
||||
function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined {
|
||||
@@ -221,36 +222,6 @@ function normalizeBaseUrl(value: string): string {
|
||||
return trimValue(value).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function resolveProfileCapabilityRouteId(
|
||||
provider: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const providerRouteId = resolveProfileRoute(provider).routeId
|
||||
if (providerRouteId === 'custom-anthropic') {
|
||||
return providerRouteId
|
||||
}
|
||||
|
||||
const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl)
|
||||
if (routeIdFromBaseUrl) {
|
||||
return routeIdFromBaseUrl
|
||||
}
|
||||
|
||||
// Cloudflare and LongCat profiles retargeted away from their dedicated
|
||||
// endpoints run generically at runtime. Mirror that boundary here so
|
||||
// capability-driven surfaces are not stripped based on stale route ids.
|
||||
if (
|
||||
(providerRouteId === 'cloudflare' || providerRouteId === 'longcat') &&
|
||||
baseUrl &&
|
||||
!(providerRouteId === 'cloudflare'
|
||||
? isCloudflareBaseUrl(baseUrl)
|
||||
: isLongcatBaseUrl(baseUrl))
|
||||
) {
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
return providerRouteId
|
||||
}
|
||||
|
||||
function normalizeProfileModelLookupKey(model: string | undefined): string {
|
||||
// Strip a trailing [1m] tag along with any ?query suffix: the tag is a
|
||||
// client-side context opt-in, not part of the model's identity, so a saved
|
||||
@@ -331,6 +302,7 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
||||
const provider = trimValue(profile.provider)
|
||||
const baseUrl = normalizeBaseUrl(profile.baseUrl)
|
||||
const model = trimValue(profile.model)
|
||||
const apiKey = trimOrUndefined(profile.apiKey)
|
||||
const apiFormat = parseOpenAICompatibleApiFormat(profile.apiFormat)
|
||||
const azureStyle = profile.azureStyle === true
|
||||
const authHeader = sanitizeAuthHeader(profile.authHeader)
|
||||
@@ -346,6 +318,13 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
||||
if (!id || !name || !baseUrl || !model || !provider) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
apiKey &&
|
||||
profileTargetsRoute(provider, baseUrl, 'apismart') &&
|
||||
!hasUsableRouteCredentialEnvValue('APISMART_API_KEY', apiKey)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxContextLength =
|
||||
typeof profile.maxContextLength === 'number' &&
|
||||
@@ -361,7 +340,7 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
||||
provider,
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey: trimOrUndefined(profile.apiKey),
|
||||
apiKey,
|
||||
}
|
||||
if (supportsApiFormat && apiFormat) {
|
||||
sanitized.apiFormat = apiFormat
|
||||
@@ -996,8 +975,14 @@ export function applyProviderProfileToProcessEnv(
|
||||
|
||||
const withholdRetargetedApismartCredential =
|
||||
route.routeId === 'apismart' && !isApismartProfile(profile)
|
||||
if (profile.apiKey && !withholdRetargetedApismartCredential) {
|
||||
openAIProfileEnv.OPENAI_API_KEY = profile.apiKey
|
||||
const profileApiKey =
|
||||
isApismartProfile(profile) &&
|
||||
profile.apiKey &&
|
||||
!hasUsableRouteCredentialEnvValue('APISMART_API_KEY', profile.apiKey)
|
||||
? undefined
|
||||
: profile.apiKey
|
||||
if (profileApiKey && !withholdRetargetedApismartCredential) {
|
||||
openAIProfileEnv.OPENAI_API_KEY = profileApiKey
|
||||
if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) {
|
||||
openAIProfileEnv.MINIMAX_API_KEY = profile.apiKey
|
||||
}
|
||||
@@ -1031,7 +1016,7 @@ export function applyProviderProfileToProcessEnv(
|
||||
openAIProfileEnv.ATLAS_CLOUD_API_KEY = profile.apiKey
|
||||
}
|
||||
if (isApismartProfile(profile)) {
|
||||
openAIProfileEnv.APISMART_API_KEY = profile.apiKey
|
||||
openAIProfileEnv.APISMART_API_KEY = profileApiKey
|
||||
}
|
||||
if (isClinePassProfile(profile)) {
|
||||
openAIProfileEnv.CLINE_API_KEY = profile.apiKey
|
||||
@@ -1590,6 +1575,23 @@ function buildStartupProfileFromActiveProfile(
|
||||
})),
|
||||
}
|
||||
case 'openai': {
|
||||
// A concrete endpoint owns persistence just as it owns live credential
|
||||
// routing. Handle dedicated ApiSmart endpoints before provider-label
|
||||
// special cases (Atlas, NVIDIA, MiniMax, etc.), otherwise a retargeted
|
||||
// profile works live but persists the wrong provider's credential.
|
||||
if (isApismartProfile(activeProfile)) {
|
||||
const env =
|
||||
buildApismartProfileEnv({
|
||||
model: getPrimaryModel(activeProfile.model),
|
||||
baseUrl: activeProfile.baseUrl,
|
||||
apiKey: activeProfile.apiKey,
|
||||
processEnv: process.env,
|
||||
}) ?? null
|
||||
return env
|
||||
? { profile: 'openai', env: applySupportedProfileCustomHeaders(activeProfile, env) }
|
||||
: null
|
||||
}
|
||||
|
||||
if (route.gatewayId === 'nvidia-nim') {
|
||||
const env =
|
||||
buildNvidiaNimProfileEnv({
|
||||
@@ -1655,19 +1657,6 @@ function buildStartupProfileFromActiveProfile(
|
||||
: null
|
||||
}
|
||||
|
||||
if (route.routeId === 'apismart' && isApismartProfile(activeProfile)) {
|
||||
const env =
|
||||
buildApismartProfileEnv({
|
||||
model: getPrimaryModel(activeProfile.model),
|
||||
baseUrl: activeProfile.baseUrl,
|
||||
apiKey: activeProfile.apiKey,
|
||||
processEnv: process.env,
|
||||
}) ?? null
|
||||
return env
|
||||
? { profile: 'openai', env: applySupportedProfileCustomHeaders(activeProfile, env) }
|
||||
: null
|
||||
}
|
||||
|
||||
if (route.vendorId === 'nearai') {
|
||||
const env = buildOpenAICompatibleStartupEnv(activeProfile)
|
||||
return env ? { profile: 'openai', env } : null
|
||||
@@ -1754,6 +1743,8 @@ export function setActiveProviderProfile(
|
||||
if (startupProfile) {
|
||||
const file = createProfileFile(startupProfile.profile, startupProfile.env)
|
||||
saveProfileFile(file, options)
|
||||
} else {
|
||||
deleteProfileFile(options)
|
||||
}
|
||||
|
||||
return activeProfile
|
||||
|
||||
@@ -23,6 +23,9 @@ describe('clearStartupProviderOverrides', () => {
|
||||
MINIMAX_API_KEY: 'sk-minimax',
|
||||
VENICE_API_KEY: 'sk-venice',
|
||||
LONGCAT_API_KEY: 'sk-longcat',
|
||||
APISMART_API_KEY: 'sk-apismart',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
ANTHROPIC_AUTH_TOKEN: 'stale-proxy-token',
|
||||
KEEP_ME: '1',
|
||||
},
|
||||
@@ -47,6 +50,9 @@ describe('clearStartupProviderOverrides', () => {
|
||||
MINIMAX_API_KEY: undefined,
|
||||
VENICE_API_KEY: undefined,
|
||||
LONGCAT_API_KEY: undefined,
|
||||
APISMART_API_KEY: undefined,
|
||||
APISMART_MODEL: undefined,
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: undefined,
|
||||
ANTHROPIC_AUTH_TOKEN: undefined,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_MODEL',
|
||||
@@ -42,6 +43,8 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [
|
||||
'NVIDIA_NIM',
|
||||
'VENICE_API_KEY',
|
||||
'LONGCAT_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
] as const
|
||||
|
||||
type GlobalConfigWithEnv = {
|
||||
|
||||
@@ -195,6 +195,43 @@ test('non-canonical ApiSmart host falls back to generic OpenAI validation', asyn
|
||||
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])(
|
||||
'ApiSmart validation rejects placeholder dedicated credential %s',
|
||||
async placeholder => {
|
||||
await expect(
|
||||
getProviderValidationError({
|
||||
APISMART_API_KEY: placeholder,
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}),
|
||||
).resolves.toBe('ApiSmart auth is required. Set APISMART_API_KEY.')
|
||||
},
|
||||
)
|
||||
|
||||
test.each(['SUA_CHAVE', 'null', 'undefined', ' NULL '])(
|
||||
'ApiSmart compatibility mode rejects placeholder dedicated credential %s',
|
||||
async placeholder => {
|
||||
await expect(
|
||||
getProviderValidationError({
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
APISMART_API_KEY: placeholder,
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}),
|
||||
).resolves.toBe('ApiSmart auth is required. Set APISMART_API_KEY.')
|
||||
},
|
||||
)
|
||||
|
||||
test('ApiSmart placeholder does not override a runnable explicit OpenAI setup', async () => {
|
||||
await expect(
|
||||
getProviderValidationError({
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_API_KEY: 'sk-valid-openai-key',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
APISMART_API_KEY: 'SUA_CHAVE',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
}),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('codex auth error redacts descriptor-declared provider secret values used as model text', async () => {
|
||||
const providerSecret = 'ogw-provider-secret'
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
getRouteCredentialValue,
|
||||
getRouteDescriptor,
|
||||
getRouteDefaultModel,
|
||||
hasConfiguredApismartProviderIntent,
|
||||
hasUsableRouteCredentialEnvValue,
|
||||
isApismartBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
resolveProviderRequest,
|
||||
shouldUseCodexTransport,
|
||||
} from '../services/api/providerConfig.js'
|
||||
import { hasUsableOpenAICredential } from '../services/api/credentialPool.js'
|
||||
import { getGlobalClaudeFile } from './env.js'
|
||||
import { isBareMode } from './envUtils.js'
|
||||
import {
|
||||
@@ -126,26 +127,10 @@ function hasNonEmptyEnvValue(
|
||||
return typeof env[envVar] === 'string' && env[envVar]!.trim() !== ''
|
||||
}
|
||||
|
||||
function hasUsableCredentialEnvValue(
|
||||
env: NodeJS.ProcessEnv,
|
||||
envVar: string,
|
||||
): boolean {
|
||||
const value = env[envVar]
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') {
|
||||
return hasUsableOpenAICredential(value)
|
||||
}
|
||||
|
||||
return value.trim() !== ''
|
||||
}
|
||||
|
||||
function hasOpenAICredential(env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
hasUsableCredentialEnvValue(env, 'OPENAI_API_KEYS') ||
|
||||
hasUsableCredentialEnvValue(env, 'OPENAI_API_KEY')
|
||||
hasUsableRouteCredentialEnvValue('OPENAI_API_KEYS', env.OPENAI_API_KEYS) ||
|
||||
hasUsableRouteCredentialEnvValue('OPENAI_API_KEY', env.OPENAI_API_KEY)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,6 +241,37 @@ function getRuntimeValidationTarget(
|
||||
return enabledTarget
|
||||
}
|
||||
|
||||
const activeRouteId = resolveActiveRouteIdFromEnv(env)
|
||||
if (
|
||||
activeRouteId === 'openai' &&
|
||||
!hasOpenAICredential(env) &&
|
||||
hasConfiguredApismartProviderIntent(env)
|
||||
) {
|
||||
return validationTargets.find(
|
||||
target => target.descriptor.id === 'apismart',
|
||||
)
|
||||
}
|
||||
if (activeRouteId) {
|
||||
const activeRouteTarget = validationTargets.find(
|
||||
target => target.descriptor.id === activeRouteId,
|
||||
)
|
||||
const activeRouting = activeRouteTarget
|
||||
? getValidationRouting(activeRouteTarget)
|
||||
: undefined
|
||||
if (
|
||||
activeRouteTarget &&
|
||||
!(useOpenAI && activeRouting?.skipWhenUseOpenAI)
|
||||
) {
|
||||
return activeRouteTarget
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConfiguredApismartProviderIntent(env)) {
|
||||
return validationTargets.find(
|
||||
target => target.descriptor.id === 'apismart',
|
||||
)
|
||||
}
|
||||
|
||||
if (!useOpenAI) {
|
||||
return undefined
|
||||
}
|
||||
@@ -264,6 +280,7 @@ function getRuntimeValidationTarget(
|
||||
model: env.OPENAI_MODEL,
|
||||
baseUrl: env.OPENAI_BASE_URL,
|
||||
fallbackModel: getRouteDefaultModel('openai'),
|
||||
processEnv: env,
|
||||
})
|
||||
|
||||
const baseUrlMatchedTarget = validationTargets.find(target => {
|
||||
@@ -360,7 +377,9 @@ function getCredentialEnvValidationError(
|
||||
}
|
||||
|
||||
if (
|
||||
credentialEnvVars.some(envVar => hasUsableCredentialEnvValue(env, envVar))
|
||||
credentialEnvVars.some(envVar =>
|
||||
hasUsableRouteCredentialEnvValue(envVar, env[envVar]),
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -535,6 +554,7 @@ export async function getProviderValidationError(
|
||||
model: env.OPENAI_MODEL,
|
||||
baseUrl: env.OPENAI_BASE_URL,
|
||||
fallbackModel: getRouteDefaultModel('openai'),
|
||||
processEnv: env,
|
||||
})
|
||||
const genericRouteValidation = getGenericRouteCredentialValidationError(
|
||||
env,
|
||||
|
||||
@@ -78,6 +78,8 @@ export const envVars: EnvVar[] = [
|
||||
{ name: 'GEMINI_API_KEY', description: 'Gemini API key (the preset reads this, not GOOGLE_API_KEY).' },
|
||||
{ name: 'XAI_API_KEY', description: "xAI Grok key (or sign in with 'openclaude auth xai login')." },
|
||||
{ name: 'AIMLAPI_API_KEY', description: 'AI/ML API key.' },
|
||||
{ name: 'APISMART_API_KEY', description: 'ApiSmart gateway key; selects the ApiSmart route when no conflicting endpoint is configured.' },
|
||||
{ name: 'APISMART_MODEL', description: 'Optional ApiSmart model override; defaults to DEEPSEEK_V4_FLASH.' },
|
||||
{ name: 'CLOUDFLARE_API_TOKEN', description: 'Cloudflare Workers AI token.' },
|
||||
{ name: 'NVIDIA_API_KEY', description: 'NVIDIA NIM key.' },
|
||||
{ name: 'NEARAI_API_KEY', description: 'NEAR AI unified gateway key.' },
|
||||
|
||||
Reference in New Issue
Block a user