mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
feat(integrations): add ApiSmart OpenAI-compatible gateway provider (#2109)
* feat(integrations): add ApiSmart OpenAI-compatible gateway provider
Add a hybrid-catalog ApiSmart gateway with dedicated APISMART_API_KEY/APISMART_MODEL
env wiring, route detection, profile persistence, and regression tests so the
provider works via --provider, env-only setup, and saved profiles.
* fix(apismart): protect dedicated credentials
* fix(apismart): enforce route credential boundaries
* fix(apismart): honor explicit competing route models
* fix(apismart): validate credentials and default profiles
* test(apismart): isolate env-only client tests
* fix(apismart): align routing and validation contracts
* fix(apismart): centralize route capability contracts
* Revert "fix(apismart): centralize route capability contracts"
This reverts commit f8cd975e68.
* fix(apismart): reject template credential placeholders at the shared root
Expand the shared credential usability helpers so dotenv sentinels like
null/undefined cannot win ApiSmart env-only precedence or get mirrored into
OPENAI_API_KEY, and restore focused ApiSmart docs after dropping the
shared routing centralization.
* fix(apismart): reject null base-URL and placeholder profile keys
Treat dotenv `null`/`undefined` OPENAI_BASE_URL sentinels as unset so
--provider apismart can apply defaults, and align profile/validation
credential checks with the shared placeholder contract.
* fix(apismart): match AIMLAPI env-only intent and proxy credential withholding
Use the OpenAI-compatible env-only gate so lingering CLAUDE_CODE_USE_OPENAI still keeps ApiSmart identity, retain route id on retargeted profiles, and withhold ambient credentials on non-canonical relaunches.
* fix(apismart): restore AIMLAPI credential and canonical URL parity
Backfill APISMART_API_KEY on relaunch and keyless canonical profiles, and gate credential forwarding on an exact /v1 inference URL so dedicatedCredentialsOnly auth and ambient keys stay aligned with AIMLAPI.
* fix: simplify apismart gateway integration
* fix: reject credential placeholders consistently
* test: remove obsolete apismart exception coverage
* test: remove stale apismart model fixture
* fix(apismart): restore dedicated provider contract
* fix(apismart): enforce credential boundaries
* fix(apismart): clear inherited auth headers
* test(apismart): strengthen credential boundaries
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` |
|
||||
|
||||
@@ -82,6 +82,8 @@ const ENV_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'NEARAI_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'CLINE_API_KEY',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { ProviderPreset } from '../utils/providerProfiles.js'
|
||||
const EXPECTED_PRESETS = [
|
||||
'anthropic',
|
||||
'atlas-cloud',
|
||||
'apismart',
|
||||
'aimlapi',
|
||||
'openai',
|
||||
'ollama',
|
||||
|
||||
@@ -18,6 +18,7 @@ const originalEnv = {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_API_KEYS: process.env.OPENAI_API_KEYS,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
APISMART_API_KEY: process.env.APISMART_API_KEY,
|
||||
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||
@@ -59,6 +60,7 @@ function clearProviderEnv(): void {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_API_KEYS
|
||||
delete process.env.OPENAI_MODEL
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
@@ -93,6 +95,7 @@ afterEach(() => {
|
||||
restoreEnvValue('OPENAI_API_KEY')
|
||||
restoreEnvValue('OPENAI_API_KEYS')
|
||||
restoreEnvValue('OPENAI_MODEL')
|
||||
restoreEnvValue('APISMART_API_KEY')
|
||||
restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_OPENAI')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_GEMINI')
|
||||
@@ -110,6 +113,30 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('discoverModelsForRoute', () => {
|
||||
test('does not send an ApiSmart key to an overridden discovery URL', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
process.env.APISMART_API_KEY = 'apismart-secret'
|
||||
let didFetch = false
|
||||
let authorization: string | null | undefined
|
||||
setMockFetch(mock((_input: string | URL | Request, init?: RequestInit) => {
|
||||
didFetch = true
|
||||
authorization = new Headers(init?.headers).get('authorization')
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ data: [] }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
await discoverModelsForRoute('apismart', {
|
||||
baseUrl: 'https://proxy.example/v1',
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(didFetch).toBe(true)
|
||||
expect(authorization).not.toBe('Bearer apismart-secret')
|
||||
})
|
||||
|
||||
test('uses built-in openai-compatible discovery and caches results for dynamic routes', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { resolveRouteIdFromBaseUrl } from './index.js'
|
||||
import {
|
||||
getRouteDescriptor,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
resolveActiveRouteIdFromEnv,
|
||||
resolveRouteCredentialValue,
|
||||
} from './routeMetadata.js'
|
||||
@@ -152,12 +153,23 @@ function getRouteBaseUrl(
|
||||
|
||||
function getRouteDiscoveryApiKey(
|
||||
routeId: string,
|
||||
options?: { apiKey?: string },
|
||||
options?: { baseUrl?: string; apiKey?: string },
|
||||
): string | undefined {
|
||||
if (getRouteCatalog(routeId)?.discovery?.requiresAuth === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseUrl = getRouteBaseUrl(routeId, options)
|
||||
// ApiSmart's dedicated token must never be used for an overridden discovery
|
||||
// URL. Apply the same exact inference-endpoint boundary used by requests and
|
||||
// profiles before considering either a caller-provided or ambient key.
|
||||
if (
|
||||
routeId === 'apismart' &&
|
||||
!isCanonicalApismartInferenceBaseUrl(baseUrl)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (hasInvalidCredentialPlaceholder(options?.apiKey)) {
|
||||
return undefined
|
||||
}
|
||||
@@ -170,6 +182,7 @@ function getRouteDiscoveryApiKey(
|
||||
return firstUsableCredential(
|
||||
resolveRouteCredentialValue({
|
||||
routeId,
|
||||
baseUrl,
|
||||
processEnv: process.env,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import apismart from './apismart.js'
|
||||
|
||||
const mapModel = apismart.catalog?.discovery?.mapModel
|
||||
|
||||
describe('apismart gateway', () => {
|
||||
test('uses hybrid discovery with dedicated credentials', () => {
|
||||
expect(apismart.id).toBe('apismart')
|
||||
expect(apismart.catalog?.source).toBe('hybrid')
|
||||
expect(apismart.catalog?.discovery?.kind).toBe('openai-compatible')
|
||||
expect(apismart.catalog?.discovery?.requiresAuth).toBe(true)
|
||||
expect(apismart.setup.dedicatedCredentialsOnly).toBe(true)
|
||||
expect(apismart.setup.credentialEnvVars).toEqual(['APISMART_API_KEY'])
|
||||
expect(apismart.defaultBaseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
expect(apismart.defaultModel).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(mapModel).toBeDefined()
|
||||
})
|
||||
|
||||
test('curated catalog keeps ApiSmart case-sensitive model ids', () => {
|
||||
const models = apismart.catalog?.models ?? []
|
||||
expect(models.some(model => model.apiName === 'DEEPSEEK_V4_FLASH')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(models.some(model => model.apiName === 'GLM_5.2')).toBe(true)
|
||||
expect(models.some(model => model.apiName === 'QWEN_3_7_MAX')).toBe(true)
|
||||
})
|
||||
|
||||
test('mapModel keeps chat models and drops image/video ids', () => {
|
||||
if (!mapModel) throw new Error('mapModel missing')
|
||||
expect(mapModel({ id: 'DEEPSEEK_V4_FLASH', owned_by: 'DeepSeek' })).toEqual(
|
||||
{
|
||||
id: 'DEEPSEEK_V4_FLASH',
|
||||
apiName: 'DEEPSEEK_V4_FLASH',
|
||||
label: 'DEEPSEEK_V4_FLASH (DeepSeek)',
|
||||
},
|
||||
)
|
||||
expect(mapModel({ id: 'seedream-5.0' })).toBeNull()
|
||||
expect(mapModel({ id: 'seedance-2.0' })).toBeNull()
|
||||
expect(mapModel({ id: 'happyhorse-1.0' })).toBeNull()
|
||||
expect(mapModel({})).toBeNull()
|
||||
expect(mapModel(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { defineGateway } from '../define.js'
|
||||
|
||||
const NON_CHAT_MODEL_PATTERN =
|
||||
/(seedream|seedance|happyhorse|image|video|vision-preview)/i
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getTrimmedString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function mapApismartModel(raw: unknown) {
|
||||
if (!isRecord(raw)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = getTrimmedString(raw, 'id')
|
||||
if (!id || NON_CHAT_MODEL_PATTERN.test(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ownedBy =
|
||||
getTrimmedString(raw, 'owned_by') || getTrimmedString(raw, 'ownedBy')
|
||||
const label = ownedBy ? `${id} (${ownedBy})` : id
|
||||
|
||||
return {
|
||||
id,
|
||||
apiName: id,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
// Curated from https://www.apismart.ai/models (PAGE_MODEL_LIST LLM entries).
|
||||
// Exact Model IDs are case-sensitive per ApiSmart docs.
|
||||
const curatedModels = [
|
||||
{
|
||||
id: 'DEEPSEEK_V4_FLASH',
|
||||
apiName: 'DEEPSEEK_V4_FLASH',
|
||||
aliases: ['deepseek-v4-flash'],
|
||||
modelDescriptorId: 'deepseek-v4-flash',
|
||||
label: 'DeepSeek V4 Flash',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 8_192,
|
||||
},
|
||||
{
|
||||
id: 'DEEPSEEK_V4_PRO',
|
||||
apiName: 'DEEPSEEK_V4_PRO',
|
||||
aliases: ['deepseek-v4-pro'],
|
||||
modelDescriptorId: 'deepseek-v4-pro',
|
||||
label: 'DeepSeek V4 Pro',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'DEEPSEEK_V3_2',
|
||||
apiName: 'DEEPSEEK_V3_2',
|
||||
aliases: ['deepseek-v3.2'],
|
||||
modelDescriptorId: 'deepseek-ai/deepseek-v3.2',
|
||||
label: 'DeepSeek V3.2',
|
||||
contextWindow: 131_072,
|
||||
maxOutputTokens: 8_192,
|
||||
},
|
||||
{
|
||||
id: 'KIMI_K2_6',
|
||||
apiName: 'KIMI_K2_6',
|
||||
aliases: ['kimi-k2.6'],
|
||||
modelDescriptorId: 'kimi-k2.6',
|
||||
label: 'Kimi K2.6',
|
||||
contextWindow: 262_144,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'KIMI_K2_5',
|
||||
apiName: 'KIMI_K2_5',
|
||||
aliases: ['kimi-k2.5'],
|
||||
modelDescriptorId: 'kimi-k2.5',
|
||||
label: 'Kimi K2.5',
|
||||
contextWindow: 262_144,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'KIMI_K3',
|
||||
apiName: 'KIMI_K3',
|
||||
aliases: ['kimi-k3', 'k3'],
|
||||
modelDescriptorId: 'k3',
|
||||
label: 'Kimi K3',
|
||||
contextWindow: 1_075_200,
|
||||
maxOutputTokens: 8_192,
|
||||
},
|
||||
{
|
||||
id: 'GLM_5.2',
|
||||
apiName: 'GLM_5.2',
|
||||
aliases: ['glm-5.2'],
|
||||
modelDescriptorId: 'glm-5.2',
|
||||
label: 'GLM 5.2',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'GLM_5_1',
|
||||
apiName: 'GLM_5_1',
|
||||
aliases: ['glm-5.1'],
|
||||
modelDescriptorId: 'glm-5.1',
|
||||
label: 'GLM 5.1',
|
||||
contextWindow: 204_800,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'GLM_5',
|
||||
apiName: 'GLM_5',
|
||||
aliases: ['glm-5'],
|
||||
modelDescriptorId: 'glm-5',
|
||||
label: 'GLM 5',
|
||||
contextWindow: 209_920,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'QWEN_3_7_MAX',
|
||||
apiName: 'QWEN_3_7_MAX',
|
||||
aliases: ['qwen3.7-max'],
|
||||
modelDescriptorId: 'qwen3.7-max',
|
||||
label: 'Qwen 3.7 Max',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'QWEN_3_6_PLUS',
|
||||
apiName: 'QWEN_3_6_PLUS',
|
||||
aliases: ['qwen3.6-plus'],
|
||||
modelDescriptorId: 'qwen3.6-plus',
|
||||
label: 'Qwen 3.6 Plus',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'MINIMAX_M3',
|
||||
apiName: 'MINIMAX_M3',
|
||||
aliases: ['minimax-m3'],
|
||||
modelDescriptorId: 'minimax-m3',
|
||||
label: 'MiniMax M3',
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'MINIMAX_M2_5',
|
||||
apiName: 'MINIMAX_M2_5',
|
||||
aliases: ['minimax-m2.5'],
|
||||
modelDescriptorId: 'minimax-m2.5',
|
||||
label: 'MiniMax M2.5',
|
||||
contextWindow: 200_704,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: 'MINIMAX_M2_5_HIGHSPEED',
|
||||
apiName: 'MINIMAX_M2_5_HIGHSPEED',
|
||||
aliases: ['minimax-m2.5-highspeed'],
|
||||
modelDescriptorId: 'minimax-m2.5-highspeed',
|
||||
label: 'MiniMax M2.5 HighSpeed',
|
||||
contextWindow: 204_800,
|
||||
maxOutputTokens: 16_384,
|
||||
},
|
||||
]
|
||||
|
||||
export default defineGateway({
|
||||
id: 'apismart',
|
||||
label: 'ApiSmart',
|
||||
category: 'aggregating',
|
||||
defaultBaseUrl: 'https://gw.apismart.ai/v1',
|
||||
defaultModel: 'DEEPSEEK_V4_FLASH',
|
||||
supportsModelRouting: true,
|
||||
setup: {
|
||||
requiresAuth: true,
|
||||
authMode: 'api-key',
|
||||
credentialEnvVars: ['APISMART_API_KEY'],
|
||||
dedicatedCredentialsOnly: true,
|
||||
},
|
||||
startup: {
|
||||
probeReadiness: 'openai-compatible-models',
|
||||
},
|
||||
transportConfig: {
|
||||
kind: 'openai-compatible',
|
||||
openaiShim: {
|
||||
supportsApiFormatSelection: false,
|
||||
supportsAuthHeaders: false,
|
||||
// ApiSmart chat-completions examples use max_tokens.
|
||||
maxTokensField: 'max_tokens',
|
||||
},
|
||||
},
|
||||
preset: {
|
||||
id: 'apismart',
|
||||
description: 'ApiSmart unified OpenAI-compatible gateway',
|
||||
vendorId: 'openai',
|
||||
apiKeyEnvVars: ['APISMART_API_KEY'],
|
||||
modelEnvVars: ['APISMART_MODEL', 'OPENAI_MODEL'],
|
||||
},
|
||||
validation: {
|
||||
kind: 'credential-env',
|
||||
routing: {
|
||||
matchDefaultBaseUrl: true,
|
||||
matchBaseUrlHosts: ['gw.apismart.ai'],
|
||||
},
|
||||
credentialEnvVars: ['APISMART_API_KEY'],
|
||||
missingCredentialMessage:
|
||||
'ApiSmart auth is required. Set APISMART_API_KEY.',
|
||||
},
|
||||
catalog: {
|
||||
// /v1/models exists and requires a Bearer key; curated LLM ids stay visible
|
||||
// before discovery and when refresh fails.
|
||||
source: 'hybrid',
|
||||
discovery: {
|
||||
kind: 'openai-compatible',
|
||||
requiresAuth: true,
|
||||
mapModel: mapApismartModel,
|
||||
},
|
||||
discoveryCacheTtl: '1d',
|
||||
discoveryRefreshMode: 'background-if-stale',
|
||||
allowManualRefresh: true,
|
||||
models: [...curatedModels],
|
||||
},
|
||||
usage: { supported: false },
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import vendorXai from '../vendors/xai.js'
|
||||
import vendorXiaomiMimo from '../vendors/xiaomi-mimo.js'
|
||||
import vendorZai from '../vendors/zai.js'
|
||||
import gatewayAimlapi from '../gateways/aimlapi.js'
|
||||
import gatewayApismart from '../gateways/apismart.js'
|
||||
import gatewayAtlasCloud from '../gateways/atlas-cloud.js'
|
||||
import gatewayAtomicChat from '../gateways/atomic-chat.js'
|
||||
import gatewayAzureOpenai from '../gateways/azure-openai.js'
|
||||
@@ -89,7 +90,7 @@ import modelXai from '../models/xai.js'
|
||||
import modelXiaomiMimo from '../models/xiaomi-mimo.js'
|
||||
|
||||
export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[]
|
||||
export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[]
|
||||
export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[]
|
||||
export const ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[]
|
||||
export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLing, brandLlama, brandLongcat, brandMacaron, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandTencent, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[]
|
||||
export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLing, modelLlama, modelLongcat, modelMacaron, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[]
|
||||
|
||||
@@ -90,6 +90,21 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"DASHSCOPE_API_KEY"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "apismart",
|
||||
"routeKind": "gateway",
|
||||
"routeId": "apismart",
|
||||
"vendorId": "openai",
|
||||
"gatewayId": "apismart",
|
||||
"description": "ApiSmart unified OpenAI-compatible gateway",
|
||||
"apiKeyEnvVars": [
|
||||
"APISMART_API_KEY"
|
||||
],
|
||||
"modelEnvVars": [
|
||||
"APISMART_MODEL",
|
||||
"OPENAI_MODEL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "atlas-cloud",
|
||||
"routeKind": "gateway",
|
||||
@@ -527,6 +542,7 @@ export const ORDERED_PROVIDER_PRESETS = [
|
||||
"anthropic",
|
||||
"dashscope-cn",
|
||||
"dashscope-intl",
|
||||
"apismart",
|
||||
"atlas-cloud",
|
||||
"azure-openai",
|
||||
"bankr",
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getRouteProviderTypeLabel,
|
||||
isApismartBaseUrl,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
resolveActiveRouteIdFromEnv,
|
||||
@@ -221,6 +223,18 @@ test('getRouteCredentialEnvVars omits the openai fallback for dedicatedCredentia
|
||||
ATLAS_CLOUD_API_KEY: 'atlas-key',
|
||||
}),
|
||||
).toBe('atlas-key')
|
||||
expect(getRouteCredentialEnvVars('apismart')).toEqual(['APISMART_API_KEY'])
|
||||
expect(
|
||||
getRouteCredentialValue('apismart', {
|
||||
OPENAI_API_KEY: 'sk-openai-generic',
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
getRouteCredentialValue('apismart', {
|
||||
OPENAI_API_KEY: 'sk-openai-generic',
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
}),
|
||||
).toBe('apismart-key')
|
||||
})
|
||||
|
||||
test('getRouteCredentialValue reads the first configured route credential', () => {
|
||||
@@ -291,6 +305,32 @@ test('route credential discovery ignores mixed placeholder OpenAI pools before s
|
||||
).toBe('sk-openai-single')
|
||||
})
|
||||
|
||||
test('ApiSmart dedicated credential is limited to the canonical inference base URL', () => {
|
||||
const processEnv = { APISMART_API_KEY: 'apismart-secret' }
|
||||
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
processEnv,
|
||||
}),
|
||||
).toBe('apismart-secret')
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
baseUrl: 'https://gw.apismart.ai/v1/models',
|
||||
processEnv,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
baseUrl: 'https://gw.apismart.ai/v2',
|
||||
processEnv,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('Venice route metadata uses official OpenAI-compatible defaults', () => {
|
||||
expect(getRouteDefaultBaseUrl('venice')).toBe('https://api.venice.ai/api/v1')
|
||||
expect(getRouteDefaultModel('venice')).toBe('venice-uncensored')
|
||||
@@ -305,6 +345,53 @@ test('AI/ML API route metadata uses official OpenAI-compatible defaults', () =>
|
||||
expect(resolveRouteIdFromBaseUrl('https://api.aimlapi.com/v1/chat/completions')).toBe('aimlapi')
|
||||
})
|
||||
|
||||
test('ApiSmart route metadata uses official OpenAI-compatible defaults', () => {
|
||||
expect(getRouteDefaultBaseUrl('apismart')).toBe('https://gw.apismart.ai/v1')
|
||||
expect(getRouteDefaultModel('apismart')).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(resolveRouteIdFromBaseUrl('https://gw.apismart.ai/v1')).toBe('apismart')
|
||||
expect(resolveRouteIdFromBaseUrl('https://gw.apismart.ai/v1/chat/completions')).toBe(
|
||||
'apismart',
|
||||
)
|
||||
})
|
||||
|
||||
test('isApismartBaseUrl requires the documented HTTPS endpoint', () => {
|
||||
expect(isApismartBaseUrl('https://gw.apismart.ai/v1')).toBe(true)
|
||||
expect(isApismartBaseUrl('http://gw.apismart.ai/v1')).toBe(false)
|
||||
expect(isApismartBaseUrl('https://gw.apismart.ai:8443/v1')).toBe(false)
|
||||
expect(resolveRouteIdFromBaseUrl('http://gw.apismart.ai/v1')).toBe(null)
|
||||
expect(resolveRouteIdFromBaseUrl('https://gw.apismart.ai:8443/v1')).toBe(null)
|
||||
})
|
||||
|
||||
test('isCanonicalApismartInferenceBaseUrl requires the exact /v1 inference path', () => {
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v1')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v1/')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v1?x=1')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v1#fragment')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v1/models'),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/staging/v1'),
|
||||
).toBe(false)
|
||||
expect(isCanonicalApismartInferenceBaseUrl('https://gw.apismart.ai/v2')).toBe(
|
||||
false,
|
||||
)
|
||||
// Host-scoped route match still accepts path suffixes for identity.
|
||||
expect(isApismartBaseUrl('https://gw.apismart.ai/v1/models')).toBe(true)
|
||||
expect(isApismartBaseUrl('https://gw.apismart.ai')).toBe(true)
|
||||
})
|
||||
|
||||
test('AI/ML API route credential discovery ignores placeholder dedicated key', () => {
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
@@ -438,6 +525,110 @@ test('resolveActiveRouteIdFromEnv treats AI/ML API credential-only env as AI/ML
|
||||
).toBe('aimlapi')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv treats ApiSmart credential-only env as ApiSmart', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv ignores placeholder ApiSmart credentials', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'SUA_CHAVE',
|
||||
}),
|
||||
).not.toBe('apismart')
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'null',
|
||||
}),
|
||||
).not.toBe('apismart')
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'undefined',
|
||||
}),
|
||||
).not.toBe('apismart')
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'sua_chave',
|
||||
AIMLAPI_API_KEY: 'aimlapi-key',
|
||||
}),
|
||||
).toBe('aimlapi')
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'null',
|
||||
AIMLAPI_API_KEY: 'aimlapi-key',
|
||||
}),
|
||||
).toBe('aimlapi')
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'SUA_CHAVE',
|
||||
AIMLAPI_API_KEY: 'aimlapi-key',
|
||||
}),
|
||||
).toBe('aimlapi')
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
processEnv: { APISMART_API_KEY: 'SUA_CHAVE' },
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
processEnv: { APISMART_API_KEY: 'null' },
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv prefers ApiSmart over ClinePass when both dedicated keys are set', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
CLINE_API_KEY: 'cline-key',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv prefers ApiSmart over AI/ML API when both dedicated keys are set', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
AIMLAPI_API_KEY: 'aimlapi-key',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv refines generic OpenAI profile by ApiSmart base URL', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_API_KEY: 'sk-openai-generic',
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv does not retain ApiSmart identity for a retargeted profile', () => {
|
||||
const baseUrl = 'https://proxy.example/v1'
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv(
|
||||
{ CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: baseUrl },
|
||||
{ activeProfileProvider: 'apismart', activeProfileBaseUrl: baseUrl },
|
||||
),
|
||||
).toBe('custom')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv honors an explicit competing route over an ambient ApiSmart key', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
AIMLAPI_API_KEY: 'aimlapi-key',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
}),
|
||||
).toBe('aimlapi')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv prefers dedicated AI/ML API key over ambient OpenAI keys', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
@@ -467,6 +658,15 @@ test('resolveActiveRouteIdFromEnv keeps explicit OpenAI mode compatible with AI/
|
||||
).toBe('aimlapi')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv keeps explicit OpenAI mode compatible with ApiSmart key-only setup', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
}),
|
||||
).toBe('apismart')
|
||||
})
|
||||
|
||||
test('resolveActiveRouteIdFromEnv does not infer AI/ML API with a conflicting OpenAI base URL', () => {
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
|
||||
@@ -224,7 +224,8 @@ function hasUsableEnvCredentialValue(
|
||||
if (
|
||||
envVar === 'OPENAI_API_KEYS' ||
|
||||
envVar === 'OPENAI_API_KEY' ||
|
||||
envVar === 'AIMLAPI_API_KEY'
|
||||
envVar === 'AIMLAPI_API_KEY' ||
|
||||
envVar === 'APISMART_API_KEY'
|
||||
) {
|
||||
return hasUsableOpenAICredential(value)
|
||||
}
|
||||
@@ -402,6 +403,65 @@ export function isClinePassBaseUrl(value: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-scoped ApiSmart route match. Used for env-only conflict detection and
|
||||
* base-URL route identity (including `/v1/chat/completions` path suffixes that
|
||||
* still target the ApiSmart host). Credential forwarding and ambient-key
|
||||
* withholding use {@link isCanonicalApismartInferenceBaseUrl} instead — same
|
||||
* split AIMLAPI uses between host match and canonical inference URL.
|
||||
*/
|
||||
export function isApismartBaseUrl(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
return (
|
||||
parsed.protocol === 'https:' &&
|
||||
!parsed.port &&
|
||||
parsed.hostname.toLowerCase() === 'gw.apismart.ai'
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact documented ApiSmart inference endpoint (`https://gw.apismart.ai/v1`).
|
||||
* Path suffixes (`/v1/models`), alternate versions (`/v2`), and host-only URLs
|
||||
* are not canonical — forwarding dedicated credentials there would send the
|
||||
* key to the wrong request/discovery path.
|
||||
*/
|
||||
const APISMART_CANONICAL_INFERENCE_BASE_URL = 'https://gw.apismart.ai/v1'
|
||||
|
||||
export function isCanonicalApismartInferenceBaseUrl(
|
||||
value: string | undefined,
|
||||
): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const canonical = new URL(APISMART_CANONICAL_INFERENCE_BASE_URL)
|
||||
const candidate = new URL(trimmed)
|
||||
const normalizePath = (pathname: string): string =>
|
||||
pathname.replace(/\/+$/, '') || '/'
|
||||
return (
|
||||
candidate.protocol === 'https:' &&
|
||||
!candidate.port &&
|
||||
!candidate.search &&
|
||||
!candidate.hash &&
|
||||
candidate.hostname.toLowerCase() === canonical.hostname.toLowerCase() &&
|
||||
normalizePath(candidate.pathname) === normalizePath(canonical.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given URL value targets the Cloudflare Workers AI
|
||||
* OpenAI-compatible API, i.e. `api.cloudflare.com` **and** the Workers AI path
|
||||
@@ -591,6 +651,30 @@ function hasConflictingOpenAIBaseUrlForRoute(
|
||||
)
|
||||
}
|
||||
|
||||
function hasExplicitOpenAIBaseUrlForRoute(
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
isRouteBaseUrl: (value: string | undefined) => boolean,
|
||||
): boolean {
|
||||
if (hasNonEmptyEnvValue(processEnv.OPENAI_BASE_URL)) {
|
||||
return isRouteBaseUrl(processEnv.OPENAI_BASE_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
hasNonEmptyEnvValue(processEnv.OPENAI_API_BASE) &&
|
||||
isRouteBaseUrl(processEnv.OPENAI_API_BASE)
|
||||
)
|
||||
}
|
||||
|
||||
function hasCompetingApismartCredential(
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
isRouteBaseUrl: (value: string | undefined) => boolean,
|
||||
): boolean {
|
||||
return (
|
||||
hasUsableOpenAICredential(processEnv.APISMART_API_KEY) &&
|
||||
!hasExplicitOpenAIBaseUrlForRoute(processEnv, isRouteBaseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
function isAimlapiBaseUrl(baseUrl?: string): boolean {
|
||||
return normalizeHost(baseUrl) === 'api.aimlapi.com'
|
||||
}
|
||||
@@ -627,6 +711,7 @@ export function hasAimlapiEnvOnlyProviderIntent(
|
||||
): boolean {
|
||||
return (
|
||||
hasUsableOpenAICredential(processEnv.AIMLAPI_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isAimlapiBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isAimlapiBaseUrl) &&
|
||||
hasNoExplicitNonOpenAIProvider(processEnv)
|
||||
)
|
||||
@@ -638,6 +723,7 @@ export function hasXaiEnvOnlyProviderIntent(
|
||||
return (
|
||||
hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isXaiBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isXaiBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -659,6 +745,7 @@ export function hasMiniMaxEnvOnlyProviderIntent(
|
||||
(!hasAnyUsableOpenAICredential(processEnv) &&
|
||||
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isMiniMaxBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)))
|
||||
)
|
||||
}
|
||||
@@ -672,6 +759,7 @@ export function hasVeniceEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.XAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isVeniceBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isVeniceBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -687,6 +775,7 @@ export function hasXiaomiMimoEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isXiaomiMimoBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isXiaomiMimoBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -704,6 +793,7 @@ export function hasNearaiEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.FIREWORKS_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.LONGCAT_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isNearaiBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isNearaiBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -726,6 +816,7 @@ export function hasFireworksEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.NEARAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.LONGCAT_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isFireworksBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isFireworksBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -744,6 +835,7 @@ export function hasLongcatEnvOnlyProviderIntent(
|
||||
!hasNonEmptyEnvValue(processEnv.NEARAI_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.FIREWORKS_API_KEY) &&
|
||||
!hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isLongcatBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isLongcatBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
@@ -759,11 +851,26 @@ export function hasClinePassEnvOnlyProviderIntent(
|
||||
): boolean {
|
||||
return (
|
||||
hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) &&
|
||||
!hasCompetingApismartCredential(processEnv, isClinePassBaseUrl) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isClinePassBaseUrl) &&
|
||||
hasNoExplicitNonOpenAICompatibleProvider(processEnv)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasApismartEnvOnlyProviderIntent(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
// Match AIMLAPI: ApiSmart is an OpenAI-compatible dedicated-key route, so a
|
||||
// lingering CLAUDE_CODE_USE_OPENAI=1 from a prior OpenAI session must not
|
||||
// suppress env-only ApiSmart identity. Only true non-OpenAI providers
|
||||
// (Gemini/GitHub/Bedrock/...) block this intent.
|
||||
return (
|
||||
hasUsableOpenAICredential(processEnv.APISMART_API_KEY) &&
|
||||
!hasConflictingOpenAIBaseUrlForRoute(processEnv, isApismartBaseUrl) &&
|
||||
hasNoExplicitNonOpenAIProvider(processEnv)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveEnvOnlyProviderRouteId(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
):
|
||||
@@ -776,6 +883,7 @@ export function resolveEnvOnlyProviderRouteId(
|
||||
| 'fireworks'
|
||||
| 'longcat'
|
||||
| 'clinepass'
|
||||
| 'apismart'
|
||||
| null {
|
||||
if (
|
||||
hasMiniMaxRouteIntent(processEnv) &&
|
||||
@@ -820,6 +928,10 @@ export function resolveEnvOnlyProviderRouteId(
|
||||
return 'clinepass'
|
||||
}
|
||||
|
||||
if (hasApismartEnvOnlyProviderIntent(processEnv)) {
|
||||
return 'apismart'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -884,6 +996,18 @@ export function resolveRouteCredentialValue(
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ApiSmart's host is intentionally sufficient for route identity, but its
|
||||
// dedicated credential is valid only for the documented inference base.
|
||||
// Keep those concerns separate so discovery, versioned, or custom paths on
|
||||
// the same host cannot receive the bearer token.
|
||||
if (
|
||||
routeId === 'apismart' &&
|
||||
options?.baseUrl !== undefined &&
|
||||
!isCanonicalApismartInferenceBaseUrl(options.baseUrl)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return getRouteCredentialValue(routeId, processEnv)
|
||||
}
|
||||
|
||||
@@ -1004,7 +1128,8 @@ export function resolveRouteIdFromBaseUrl(
|
||||
// unrelated Cloudflare API URL would inherit Workers-AI routing.
|
||||
if (
|
||||
(route.id === 'cloudflare' && !isCloudflareBaseUrl(baseUrl)) ||
|
||||
(route.id === 'longcat' && !isLongcatBaseUrl(baseUrl))
|
||||
(route.id === 'longcat' && !isLongcatBaseUrl(baseUrl)) ||
|
||||
(route.id === 'apismart' && !isApismartBaseUrl(baseUrl))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -1041,6 +1166,9 @@ function profileRouteHonorsBaseUrlBoundary(
|
||||
if (routeId === 'longcat') {
|
||||
return isLongcatBaseUrl(baseUrl)
|
||||
}
|
||||
if (routeId === 'apismart') {
|
||||
return isApismartBaseUrl(baseUrl)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ const originalEnv = {
|
||||
FIREWORKS_API_KEY: process.env.FIREWORKS_API_KEY,
|
||||
LONGCAT_API_KEY: process.env.LONGCAT_API_KEY,
|
||||
AIMLAPI_API_KEY: process.env.AIMLAPI_API_KEY,
|
||||
APISMART_API_KEY: process.env.APISMART_API_KEY,
|
||||
NVIDIA_NIM: process.env.NVIDIA_NIM,
|
||||
NVIDIA_API_KEY: process.env.NVIDIA_API_KEY,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
@@ -121,6 +122,7 @@ function clearEnvForMiniMaxOnlyTest(): void {
|
||||
delete process.env.FIREWORKS_API_KEY
|
||||
delete process.env.LONGCAT_API_KEY
|
||||
delete process.env.AIMLAPI_API_KEY
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.NVIDIA_NIM
|
||||
delete process.env.NVIDIA_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
@@ -162,6 +164,7 @@ beforeEach(async () => {
|
||||
delete process.env.FIREWORKS_API_KEY
|
||||
delete process.env.LONGCAT_API_KEY
|
||||
delete process.env.AIMLAPI_API_KEY
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.OPENAI_AUTH_HEADER
|
||||
delete process.env.OPENAI_AUTH_SCHEME
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
@@ -211,6 +214,7 @@ afterEach(() => {
|
||||
restoreEnv('FIREWORKS_API_KEY', originalEnv.FIREWORKS_API_KEY)
|
||||
restoreEnv('LONGCAT_API_KEY', originalEnv.LONGCAT_API_KEY)
|
||||
restoreEnv('AIMLAPI_API_KEY', originalEnv.AIMLAPI_API_KEY)
|
||||
restoreEnv('APISMART_API_KEY', originalEnv.APISMART_API_KEY)
|
||||
restoreEnv('NVIDIA_NIM', originalEnv.NVIDIA_NIM)
|
||||
restoreEnv('NVIDIA_API_KEY', originalEnv.NVIDIA_API_KEY)
|
||||
restoreEnv('ANTHROPIC_API_KEY', originalEnv.ANTHROPIC_API_KEY)
|
||||
@@ -751,6 +755,22 @@ test('env-only MiniMax fallback ignores non-MiniMax base overrides', async () =>
|
||||
expect(process.env.OPENAI_MODEL).toBe('MiniMax-M2.7')
|
||||
})
|
||||
|
||||
test('env-only ApiSmart setup withholds its key from a noncanonical same-host URL', async () => {
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.GEMINI_API_KEY
|
||||
delete process.env.GEMINI_MODEL
|
||||
delete process.env.GEMINI_BASE_URL
|
||||
delete process.env.GEMINI_AUTH_MODE
|
||||
process.env.APISMART_API_KEY = 'apismart-test-key'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1/models'
|
||||
|
||||
await getAnthropicClient({ maxRetries: 0, model: 'DEEPSEEK_V4_FLASH' })
|
||||
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1/models')
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test('routes env-only AI/ML API requests through the OpenAI-compatible shim despite an ambient OpenAI key', async () => {
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
getLongcatBaseUrlOverride,
|
||||
getMiniMaxBaseUrlOverride,
|
||||
getNearaiBaseUrlOverride,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
getXaiBaseUrlOverride,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
shouldUseFirstPartyAnthropicAuthForProvider,
|
||||
type ProviderOverride,
|
||||
} from './authRouting.js'
|
||||
import { hasUsableOpenAICredential } from './credentialPool.js'
|
||||
import { AnthropicVertex } from './vertexClient.js'
|
||||
import { importOptionalRuntimeModule } from '../../utils/optionalRuntimeModule.js'
|
||||
|
||||
@@ -374,6 +376,52 @@ function applyAimlapiEnvOnlyDefaults(): void {
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
}
|
||||
|
||||
function applyApismartEnvOnlyDefaults(): void {
|
||||
const baseUrlOverride =
|
||||
usableProviderConfigEnvValue(process.env.OPENAI_BASE_URL) ||
|
||||
usableProviderConfigEnvValue(process.env.OPENAI_API_BASE) ||
|
||||
undefined
|
||||
const modelOverride =
|
||||
usableProviderConfigEnvValue(process.env.APISMART_MODEL) ||
|
||||
usableProviderConfigEnvValue(process.env.OPENAI_MODEL) ||
|
||||
undefined
|
||||
const apiKey = process.env.APISMART_API_KEY
|
||||
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL =
|
||||
baseUrlOverride ?? getRouteDefaultBaseUrl('apismart')
|
||||
process.env.OPENAI_MODEL = modelOverride ?? getRouteDefaultModel('apismart')
|
||||
// Mirror only a usable dedicated key. Template placeholders must not become
|
||||
// OPENAI_API_KEY side effects for later routes in this process. A same-host
|
||||
// URL alone is not enough: the dedicated key belongs only to ApiSmart's
|
||||
// documented /v1 inference endpoint.
|
||||
if (
|
||||
hasUsableOpenAICredential(apiKey) &&
|
||||
isCanonicalApismartInferenceBaseUrl(process.env.OPENAI_BASE_URL)
|
||||
) {
|
||||
process.env.OPENAI_API_KEY = apiKey
|
||||
} else {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
}
|
||||
delete process.env.OPENAI_API_FORMAT
|
||||
delete process.env.OPENAI_AZURE_STYLE
|
||||
delete process.env.OPENAI_AUTH_HEADER
|
||||
delete process.env.OPENAI_AUTH_SCHEME
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
}
|
||||
|
||||
function usableProviderConfigEnvValue(
|
||||
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 async function getAnthropicClient({
|
||||
apiKey,
|
||||
maxRetries,
|
||||
@@ -511,6 +559,8 @@ export async function getAnthropicClient({
|
||||
envOnlyProviderRouteId === 'longcat' && !useMiniMaxEnvOnlyProvider
|
||||
const useAimlapiEnvOnlyProvider =
|
||||
envOnlyProviderRouteId === 'aimlapi' && !useMiniMaxEnvOnlyProvider
|
||||
const useApismartEnvOnlyProvider =
|
||||
envOnlyProviderRouteId === 'apismart' && !useMiniMaxEnvOnlyProvider
|
||||
if (useMiniMaxEnvOnlyProvider) {
|
||||
applyMiniMaxEnvOnlyDefaults(model)
|
||||
}
|
||||
@@ -532,6 +582,9 @@ export async function getAnthropicClient({
|
||||
if (useAimlapiEnvOnlyProvider) {
|
||||
applyAimlapiEnvOnlyDefaults()
|
||||
}
|
||||
if (useApismartEnvOnlyProvider) {
|
||||
applyApismartEnvOnlyDefaults()
|
||||
}
|
||||
|
||||
const apiProvider = getAPIProvider()
|
||||
const isFirstPartyBaseUrl = isFirstPartyAnthropicBaseUrl()
|
||||
@@ -636,6 +689,7 @@ export async function getAnthropicClient({
|
||||
useNearaiEnvOnlyProvider ||
|
||||
useFireworksEnvOnlyProvider ||
|
||||
useAimlapiEnvOnlyProvider ||
|
||||
useApismartEnvOnlyProvider ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
||||
|
||||
@@ -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([
|
||||
@@ -13,6 +13,12 @@ test('firstUsableCredential rejects pools containing placeholder credentials', (
|
||||
expect(firstUsableCredential('key-a,key-b')).toBe('key-a')
|
||||
expect(firstUsableCredential('key-a,SUA_CHAVE')).toBeUndefined()
|
||||
expect(hasInvalidCredentialPlaceholder('key-a,SUA_CHAVE')).toBe(true)
|
||||
expect(hasInvalidCredentialPlaceholder('null')).toBe(true)
|
||||
expect(hasInvalidCredentialPlaceholder('undefined')).toBe(true)
|
||||
expect(hasInvalidCredentialPlaceholder(' NULL ')).toBe(true)
|
||||
expect(hasUsableOpenAICredential('null')).toBe(false)
|
||||
expect(hasUsableOpenAICredential('sua_chave')).toBe(false)
|
||||
expect(hasUsableOpenAICredential('apismart-key')).toBe(true)
|
||||
})
|
||||
test('CredentialPool rotates through healthy credentials', () => {
|
||||
const pool = new CredentialPool(['key-a', 'key-b'])
|
||||
|
||||
@@ -130,15 +130,34 @@ export function parseCredentialList(value: string | undefined): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Template/dotenv sentinels that must never authenticate or win route
|
||||
// precedence. Keep this list case-insensitive and shared so dedicated-key
|
||||
// providers (ApiSmart, AIMLAPI, OpenAI pools) agree with profile sanitizers.
|
||||
const INVALID_CREDENTIAL_PLACEHOLDERS = new Set([
|
||||
'sua_chave',
|
||||
'null',
|
||||
'undefined',
|
||||
])
|
||||
|
||||
export function isCredentialPlaceholder(value: string | undefined): boolean {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return INVALID_CREDENTIAL_PLACEHOLDERS.has(trimmed.toLowerCase())
|
||||
}
|
||||
|
||||
export function hasInvalidCredentialPlaceholder(value: string | undefined): boolean {
|
||||
return parseCredentialList(value).some(credential => credential === 'SUA_CHAVE')
|
||||
return parseCredentialList(value).some(credential =>
|
||||
isCredentialPlaceholder(credential),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasUsableOpenAICredential(value: string | undefined): boolean {
|
||||
const credentials = parseCredentialList(value)
|
||||
return (
|
||||
credentials.length > 0 &&
|
||||
credentials.every(credential => credential !== 'SUA_CHAVE')
|
||||
credentials.every(credential => !isCredentialPlaceholder(credential))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -287,6 +287,7 @@ export async function executeOpenAIRequest(
|
||||
requestProcessEnv.VENICE_API_KEY,
|
||||
requestProcessEnv.MINIMAX_API_KEY,
|
||||
requestProcessEnv.ATLAS_CLOUD_API_KEY,
|
||||
requestProcessEnv.APISMART_API_KEY,
|
||||
requestProcessEnv.NEARAI_API_KEY,
|
||||
requestProcessEnv.FIREWORKS_API_KEY,
|
||||
requestProcessEnv.LONGCAT_API_KEY,
|
||||
@@ -353,7 +354,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,
|
||||
|
||||
@@ -314,6 +314,133 @@ test('resolveProviderRequest uses ClinePass model when no explicit base URL is s
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest honors an explicit ClinePass endpoint over an ambient ApiSmart key', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
CLINE_API_KEY: 'cp-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
OPENAI_BASE_URL: 'https://api.cline.bot/api/v1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('cline-pass/qwen3.7-max')
|
||||
expect(request.baseUrl).toBe('https://api.cline.bot/api/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest uses APISMART_MODEL when APISMART_API_KEY is present', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
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: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
OPENAI_MODEL: 'GLM_5.2',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('GLM_5.2')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest treats blank APISMART_MODEL as unset for ApiSmart', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: ' ',
|
||||
OPENAI_MODEL: 'QWEN_3_7_MAX',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('QWEN_3_7_MAX')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test.each(['null', 'undefined', ' NULL '])(
|
||||
'resolveProviderRequest treats placeholder APISMART_MODEL %s as unset',
|
||||
APISMART_MODEL => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: { APISMART_API_KEY: 'apismart-key', APISMART_MODEL },
|
||||
})
|
||||
expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH')
|
||||
},
|
||||
)
|
||||
|
||||
test('resolveProviderRequest uses the ApiSmart route default when no model env is set', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores APISMART_MODEL without APISMART_API_KEY', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
OPENAI_API_KEY: 'openai-key',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('gpt-4o')
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores placeholder ApiSmart credentials', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'SUA_CHAVE',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
OPENAI_API_KEY: 'openai-key',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('gpt-4o')
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest prefers ApiSmart over ClinePass when both dedicated keys are set', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
CLINE_API_KEY: 'cline-key',
|
||||
CLINE_API_MODEL: 'cline-pass/qwen3.7-max',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('KIMI_K3')
|
||||
expect(request.baseUrl).toBe('https://gw.apismart.ai/v1')
|
||||
})
|
||||
|
||||
test('resolveProviderRequest ignores ApiSmart model when explicit OPENAI_BASE_URL points elsewhere', () => {
|
||||
const request = resolveProviderRequest({
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
|
||||
expect(request.requestedModel).toBe('gpt-4o')
|
||||
expect(request.baseUrl).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -24,9 +24,12 @@ import {
|
||||
} from './clinepassUsage/types.js'
|
||||
import { getCatalogEntriesForRoute } from '../../integrations/registry.js'
|
||||
import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
isApismartBaseUrl,
|
||||
isClinePassBaseUrl,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import { hasUsableOpenAICredential } from './credentialPool.js'
|
||||
import {
|
||||
openAIShimSupportsApiFormatForModel,
|
||||
resolveOpenAIShimRuntimeContext,
|
||||
@@ -238,7 +241,10 @@ function asEnvUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed === 'undefined') {
|
||||
const normalized = trimmed.toLowerCase()
|
||||
// Windows/dotenv templates often materialize unset vars as the literal
|
||||
// strings "undefined" or "null". Neither is a usable endpoint.
|
||||
if (normalized === 'undefined' || normalized === 'null') {
|
||||
return undefined
|
||||
}
|
||||
return trimmed
|
||||
@@ -253,11 +259,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' },
|
||||
)
|
||||
}
|
||||
@@ -267,6 +274,15 @@ function asNamedEnvUrl(
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function asUsableModelEnvValue(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = trimmed.toLowerCase()
|
||||
return normalized === 'undefined' || normalized === 'null'
|
||||
? undefined
|
||||
: trimmed
|
||||
}
|
||||
|
||||
function readNestedString(
|
||||
value: unknown,
|
||||
paths: string[][],
|
||||
@@ -930,6 +946,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 explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
||||
|
||||
const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
|
||||
@@ -971,12 +988,33 @@ export function resolveProviderRequest(options?: {
|
||||
explicitBaseUrl ?? primaryEnvBaseUrl ?? fallbackEnvBaseUrl
|
||||
const hasConcreteNonClinePassBaseUrl =
|
||||
Boolean(concreteBaseUrlBeforeDefault) && !isClinePassBaseUrl(concreteBaseUrlBeforeDefault)
|
||||
const hasConcreteClinePassBaseUrl =
|
||||
Boolean(concreteBaseUrlBeforeDefault) && isClinePassBaseUrl(concreteBaseUrlBeforeDefault)
|
||||
const effectiveClinePassMode =
|
||||
isClinePassMode && !isGithubMode && !hasConcreteNonClinePassBaseUrl
|
||||
isClinePassMode &&
|
||||
// With no endpoint identity, ApiSmart wins the ambiguous dedicated-key
|
||||
// case. An explicit ClinePass endpoint is authoritative, however: an
|
||||
// ambient ApiSmart key must not suppress CLINE_API_MODEL for that route.
|
||||
(!isApismartMode || hasConcreteClinePassBaseUrl) &&
|
||||
!isGithubMode &&
|
||||
!hasConcreteNonClinePassBaseUrl
|
||||
const clinePassDefaultModel = effectiveClinePassMode
|
||||
? getRouteDefaultModel('clinepass')
|
||||
: undefined
|
||||
|
||||
// ApiSmart model selection is only valid when no concrete non-ApiSmart
|
||||
// base URL is explicitly provided via options or env. This prevents stale
|
||||
// APISMART_API_KEY/APISMART_MODEL from overriding an explicit OPENAI_BASE_URL
|
||||
// pointing at a different provider.
|
||||
const hasConcreteNonApismartBaseUrl =
|
||||
Boolean(concreteBaseUrlBeforeDefault) &&
|
||||
!isApismartBaseUrl(concreteBaseUrlBeforeDefault)
|
||||
const effectiveApismartMode =
|
||||
isApismartMode && !isGithubMode && !hasConcreteNonApismartBaseUrl
|
||||
const apismartDefaultModel = effectiveApismartMode
|
||||
? getRouteDefaultModel('apismart')
|
||||
: undefined
|
||||
|
||||
const requestedModel =
|
||||
options?.model?.trim() ||
|
||||
(isMistralMode
|
||||
@@ -986,10 +1024,14 @@ export function resolveProviderRequest(options?: {
|
||||
: effectiveClinePassMode
|
||||
? processEnv.CLINE_API_MODEL?.trim() ||
|
||||
processEnv.OPENAI_MODEL?.trim()
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
: effectiveApismartMode
|
||||
? asUsableModelEnvValue(processEnv.APISMART_MODEL) ||
|
||||
asUsableModelEnvValue(processEnv.OPENAI_MODEL)
|
||||
: processEnv.OPENAI_MODEL?.trim()) ||
|
||||
options?.fallbackModel?.trim() ||
|
||||
(isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) ||
|
||||
clinePassDefaultModel ||
|
||||
apismartDefaultModel ||
|
||||
(isGithubMode ? 'github:copilot' : 'codexplan')
|
||||
const descriptor = parseModelDescriptor(requestedModel)
|
||||
|
||||
@@ -997,7 +1039,10 @@ export function resolveProviderRequest(options?: {
|
||||
explicitBaseUrl ??
|
||||
primaryEnvBaseUrl ??
|
||||
fallbackEnvBaseUrl ??
|
||||
(effectiveClinePassMode ? DEFAULT_CLINEPASS_API_BASE_URL : undefined)
|
||||
(effectiveClinePassMode ? DEFAULT_CLINEPASS_API_BASE_URL : undefined) ??
|
||||
(effectiveApismartMode
|
||||
? getRouteDefaultBaseUrl('apismart') ?? undefined
|
||||
: undefined)
|
||||
|
||||
const githubEnterpriseEnvUrl = asGithubEnterpriseEnvUrl(
|
||||
processEnv.GITHUB_ENTERPRISE_URL,
|
||||
|
||||
@@ -18,6 +18,8 @@ const TEST_ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CODEX_AUTH_JSON_PATH',
|
||||
'CODEX_HOME',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'OPENAI_API_KEYS',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_AZURE_STYLE',
|
||||
@@ -274,6 +276,22 @@ describe('loadEnvFile', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads documented ApiSmart env-only provider setup values', () => {
|
||||
const filePath = writeTempEnvFile([
|
||||
'APISMART_API_KEY=apismart-key',
|
||||
'APISMART_MODEL=KIMI_K3',
|
||||
].join('\n'))
|
||||
|
||||
const loaded = loadEnvFile(filePath)
|
||||
|
||||
expect(process.env.APISMART_API_KEY).toBe('apismart-key')
|
||||
expect(process.env.APISMART_MODEL).toBe('KIMI_K3')
|
||||
expect(loaded).toEqual({
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads documented Azure OpenAI API version values', () => {
|
||||
const filePath = writeTempEnvFile(
|
||||
'AZURE_OPENAI_API_VERSION=2024-12-01-preview',
|
||||
|
||||
@@ -15,6 +15,8 @@ const ALLOWED_ENV_FILE_KEYS = new Set([
|
||||
'ANTHROPIC_VERTEX_BASE_URL',
|
||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_DEFAULT_REGION',
|
||||
'AWS_PROFILE',
|
||||
|
||||
@@ -40,6 +40,8 @@ const ENV_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'LONGCAT_API_KEY',
|
||||
'OPENGATEWAY_API_KEY',
|
||||
'OPENGATEWAY_BASE_URL',
|
||||
@@ -89,6 +91,8 @@ const RESET_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'LONGCAT_API_KEY',
|
||||
'OPENGATEWAY_API_KEY',
|
||||
'OPENGATEWAY_BASE_URL',
|
||||
@@ -187,6 +191,16 @@ describe('applyProviderFlag - anthropic', () => {
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe('first-party-key')
|
||||
})
|
||||
|
||||
test('does not leave ApiSmart env-only selection active', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderFlag('anthropic', [])
|
||||
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_MODEL).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - custom Anthropic-compatible', () => {
|
||||
@@ -285,6 +299,16 @@ describe('applyProviderFlag - openai', () => {
|
||||
applyProviderFlag('openai', ['--model', 'gpt-4o'])
|
||||
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('does not leave ApiSmart env-only selection active', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderFlag('openai', [])
|
||||
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_MODEL).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - cloudflare', () => {
|
||||
@@ -968,6 +992,123 @@ describe('applyProviderFlag - atlas-cloud', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - apismart', () => {
|
||||
test('sets ApiSmart OpenAI-compatible defaults and mirrors APISMART_API_KEY', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
|
||||
const result = applyProviderFlag('apismart', [])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-secret-key')
|
||||
})
|
||||
|
||||
test('does not forward the dedicated key to a preserved custom base URL', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_BASE_URL = 'https://llm-proxy.internal.example/v1'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://llm-proxy.internal.example/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(['null', 'undefined', ' NULL ', ' Undefined '])(
|
||||
'treats placeholder OPENAI_BASE_URL %s as unset and applies ApiSmart defaults',
|
||||
sentinel => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_BASE_URL = sentinel
|
||||
|
||||
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('clears unsupported OpenAI shim settings from a previous route', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.OPENAI_API_FORMAT = 'responses'
|
||||
process.env.OPENAI_AUTH_HEADER = 'x-api-key'
|
||||
process.env.OPENAI_AUTH_SCHEME = 'raw'
|
||||
process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-value'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined()
|
||||
})
|
||||
|
||||
test('clears inherited Anthropic custom headers', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Proxy-Auth: proxy-secret'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined()
|
||||
})
|
||||
|
||||
test('uses APISMART_MODEL from env when --model is not provided', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_MODEL).toBe('KIMI_K3')
|
||||
})
|
||||
|
||||
test('makes an explicit --model override APISMART_MODEL', () => {
|
||||
process.env.APISMART_API_KEY = 'apismart-secret-key'
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderFlag('apismart', ['--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'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-secret-key')
|
||||
})
|
||||
|
||||
test('clears a stale OPENAI_API_KEY when no ApiSmart key is set', () => {
|
||||
process.env.OPENAI_API_KEY = 'existing-openai-key'
|
||||
|
||||
applyProviderFlag('apismart', [])
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', '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'
|
||||
|
||||
applyProviderFlag('openai', [])
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - xai', () => {
|
||||
test('sets CLAUDE_CODE_USE_OPENAI=1 with xAI defaults when unset', () => {
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
|
||||
@@ -23,10 +23,14 @@ import {
|
||||
getVendor,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
routeSupportsApiFormatSelection,
|
||||
routeSupportsAuthHeaders,
|
||||
resolveProfileRoute,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../integrations/index.js'
|
||||
import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js'
|
||||
import { isCanonicalApismartInferenceBaseUrl } from '../integrations/routeMetadata.js'
|
||||
import { hasUsableOpenAICredential } from '../services/api/credentialPool.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js'
|
||||
|
||||
const PREFERRED_PROVIDER_ORDER = [
|
||||
@@ -168,7 +172,16 @@ function getRouteDefaults(provider: string): {
|
||||
|
||||
function normalizeBaseUrlEnv(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed && trimmed !== 'undefined' ? trimmed : undefined
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
const normalized = trimmed.toLowerCase()
|
||||
// dotenv / shell sentinels are not usable endpoints. Treating them as
|
||||
// configured would preserve an invalid OPENAI_BASE_URL and block dedicated
|
||||
// provider defaults (for example ApiSmart key mirroring).
|
||||
return normalized === 'undefined' || normalized === 'null'
|
||||
? undefined
|
||||
: trimmed
|
||||
}
|
||||
|
||||
function getConfiguredOpenAIBaseUrl(): string | undefined {
|
||||
@@ -228,6 +241,28 @@ function applyOpenAIBaseUrlDefault(provider: string, baseUrl?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearUnsupportedOpenAIShimSettings(routeId: string): void {
|
||||
if (!routeSupportsApiFormatSelection(routeId)) {
|
||||
delete process.env.OPENAI_API_FORMAT
|
||||
}
|
||||
if (!routeSupportsAuthHeaders(routeId)) {
|
||||
delete process.env.OPENAI_AUTH_HEADER
|
||||
delete process.env.OPENAI_AUTH_SCHEME
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
function usableProviderModelEnvValue(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = trimmed.toLowerCase()
|
||||
return normalized === 'undefined' || normalized === 'null'
|
||||
? undefined
|
||||
: trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply --model (without --provider) to process.env for the current process only.
|
||||
*
|
||||
@@ -316,6 +351,9 @@ export function applyProviderFlag(
|
||||
process.env.OPENAI_API_KEY === process.env.ATLAS_CLOUD_API_KEY
|
||||
? 'atlas-cloud'
|
||||
: process.env.OPENAI_API_KEY !== undefined &&
|
||||
process.env.OPENAI_API_KEY === process.env.APISMART_API_KEY
|
||||
? 'apismart'
|
||||
: process.env.OPENAI_API_KEY !== undefined &&
|
||||
process.env.OPENAI_API_KEY === process.env.NEARAI_API_KEY
|
||||
? 'nearai'
|
||||
: process.env.OPENAI_API_KEY !== undefined &&
|
||||
@@ -369,6 +407,11 @@ export function applyProviderFlag(
|
||||
if (hadCustomAnthropicEndpoint) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
}
|
||||
// `--provider anthropic` is an explicit selection even though the
|
||||
// default provider has no positive mode flag. Do not let a dedicated
|
||||
// OpenAI-compatible env-only route override it later in startup.
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.APISMART_MODEL
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
break
|
||||
@@ -411,6 +454,10 @@ export function applyProviderFlag(
|
||||
|
||||
case 'openai':
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
// An explicit generic OpenAI selection must not be reclassified as a
|
||||
// dedicated env-only gateway during client startup.
|
||||
delete process.env.APISMART_API_KEY
|
||||
delete process.env.APISMART_MODEL
|
||||
if (model) process.env.OPENAI_MODEL = model
|
||||
break
|
||||
|
||||
@@ -580,6 +627,49 @@ export function applyProviderFlag(
|
||||
}
|
||||
break
|
||||
|
||||
case 'apismart':
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
// Keep provider-flag selection on the same descriptor-declared wire
|
||||
// contract as env-only setup and saved profiles. ApiSmart does not
|
||||
// support alternate API formats or custom auth headers.
|
||||
clearUnsupportedOpenAIShimSettings('apismart')
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
applyOpenAIBaseUrlDefault(
|
||||
provider,
|
||||
defaultBaseUrl ?? 'https://gw.apismart.ai/v1',
|
||||
)
|
||||
{
|
||||
const apismartModel = usableProviderModelEnvValue(
|
||||
process.env.APISMART_MODEL,
|
||||
)
|
||||
if (apismartModel) {
|
||||
process.env.OPENAI_MODEL = apismartModel
|
||||
} else {
|
||||
process.env.OPENAI_MODEL ??=
|
||||
usableProviderModelEnvValue(process.env.OPENAI_MODEL) ||
|
||||
defaultModel ||
|
||||
'DEEPSEEK_V4_FLASH'
|
||||
}
|
||||
}
|
||||
if (model) {
|
||||
process.env.OPENAI_MODEL = model
|
||||
process.env.APISMART_MODEL = model
|
||||
}
|
||||
// DedicatedCredentialsOnly: only APISMART_API_KEY authenticates this
|
||||
// route. Mirror it into OPENAI_API_KEY for the shared shim transport,
|
||||
// and clear any stale generic key so another provider's credential is
|
||||
// never forwarded to ApiSmart. Only the documented `/v1` inference URL
|
||||
// is eligible for mirroring (AIMLAPI canonical-host parity).
|
||||
if (
|
||||
hasUsableOpenAICredential(process.env.APISMART_API_KEY) &&
|
||||
isCanonicalApismartInferenceBaseUrl(getConfiguredOpenAIBaseUrl())
|
||||
) {
|
||||
process.env.OPENAI_API_KEY = process.env.APISMART_API_KEY
|
||||
} else {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
}
|
||||
break
|
||||
|
||||
case 'fireworks':
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
applyOpenAIBaseUrlDefault(provider, defaultBaseUrl)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
applyStartupEnvFromProfile,
|
||||
buildStartupEnvFromProfile,
|
||||
buildAtomicChatProfileEnv,
|
||||
buildApismartProfileEnv,
|
||||
buildCompatibilityProcessEnv,
|
||||
buildCodexProfileEnv,
|
||||
buildGeminiProfileEnv,
|
||||
@@ -257,6 +258,204 @@ test('openai launch preserves persisted dedicated vendor credentials across rest
|
||||
assert.equal(env.ATLAS_CLOUD_API_KEY, 'atlas-secret-key')
|
||||
})
|
||||
|
||||
test('openai launch preserves persisted ApiSmart dedicated credentials across restart', 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: {},
|
||||
})
|
||||
|
||||
assert.equal(env.OPENAI_BASE_URL, 'https://gw.apismart.ai/v1')
|
||||
assert.equal(env.OPENAI_MODEL, 'DEEPSEEK_V4_FLASH')
|
||||
assert.equal(env.OPENAI_API_KEY, 'apismart-secret-key')
|
||||
assert.equal(env.APISMART_API_KEY, 'apismart-secret-key')
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart')
|
||||
})
|
||||
|
||||
test('openai launch prefers dedicated ApiSmart credentials over a legacy generic mirror', 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: 'legacy-generic-key',
|
||||
APISMART_API_KEY: 'dedicated-key',
|
||||
}),
|
||||
goal: 'balanced',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.equal(env.APISMART_API_KEY, 'dedicated-key')
|
||||
})
|
||||
|
||||
test('openai launch backfills APISMART_API_KEY from a legacy OpenAI-shaped ApiSmart profile', async () => {
|
||||
// Pre-dedicated-key persisted envs only stored OPENAI_API_KEY. ApiSmart is
|
||||
// dedicatedCredentialsOnly, so relaunch must recover APISMART_API_KEY from
|
||||
// that mirrored value or the shim authenticates with nothing.
|
||||
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',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart')
|
||||
assert.equal(env.OPENAI_API_KEY, 'apismart-secret-key')
|
||||
assert.equal(env.APISMART_API_KEY, 'apismart-secret-key')
|
||||
assert.equal(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
processEnv: env,
|
||||
baseUrl: env.OPENAI_BASE_URL,
|
||||
}),
|
||||
'apismart-secret-key',
|
||||
)
|
||||
})
|
||||
|
||||
test('openai launch never promotes an ambient generic key to an ApiSmart credential', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_API_KEY: 'generic-openai-key',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.APISMART_API_KEY, undefined)
|
||||
assert.equal(
|
||||
resolveRouteCredentialValue({
|
||||
routeId: 'apismart',
|
||||
processEnv: env,
|
||||
baseUrl: env.OPENAI_BASE_URL,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildApismartProfileEnv prefers APISMART_MODEL over OPENAI_MODEL', () => {
|
||||
const env = buildApismartProfileEnv({
|
||||
apiKey: 'apismart-secret-key',
|
||||
processEnv: {
|
||||
APISMART_MODEL: 'KIMI_K3',
|
||||
OPENAI_MODEL: 'GLM_5.2',
|
||||
},
|
||||
})
|
||||
|
||||
assert.ok(env)
|
||||
assert.equal(env?.OPENAI_MODEL, 'KIMI_K3')
|
||||
assert.equal(env?.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart')
|
||||
})
|
||||
|
||||
test('buildApismartProfileEnv refuses to copy the dedicated credential to a custom endpoint', () => {
|
||||
const env = buildApismartProfileEnv({
|
||||
apiKey: 'apismart-secret-key',
|
||||
baseUrl: 'https://proxy.example/v1',
|
||||
})
|
||||
|
||||
assert.equal(env, null)
|
||||
})
|
||||
|
||||
test('buildApismartProfileEnv refuses non-canonical ApiSmart paths', () => {
|
||||
assert.equal(
|
||||
buildApismartProfileEnv({
|
||||
apiKey: 'apismart-secret-key',
|
||||
baseUrl: 'https://gw.apismart.ai/staging/v1',
|
||||
}),
|
||||
null,
|
||||
)
|
||||
assert.equal(
|
||||
buildApismartProfileEnv({
|
||||
apiKey: 'apismart-secret-key',
|
||||
baseUrl: 'https://gw.apismart.ai',
|
||||
}),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('openai launch withholds ambient ApiSmart credentials from a keyless proxy profile on restart', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_API_KEY: 'ambient-apismart-key',
|
||||
APISMART_API_KEY: 'ambient-apismart-key',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'apismart')
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.APISMART_API_KEY, undefined)
|
||||
|
||||
const canonical = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_API_KEY: 'ambient-apismart-key',
|
||||
APISMART_API_KEY: 'ambient-apismart-key',
|
||||
},
|
||||
})
|
||||
assert.equal(canonical.OPENAI_API_KEY, 'ambient-apismart-key')
|
||||
assert.equal(canonical.APISMART_API_KEY, 'ambient-apismart-key')
|
||||
})
|
||||
|
||||
test('openai launch carries APISMART_API_KEY only when the route resolves to apismart', async () => {
|
||||
const offRoute = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_KEY: 'sk-openai',
|
||||
APISMART_API_KEY: 'apismart-persisted',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'apismart-ambient',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(offRoute.APISMART_API_KEY, undefined)
|
||||
|
||||
const onRoute = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://gw.apismart.ai/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
OPENAI_API_KEY: 'apismart-key',
|
||||
APISMART_API_KEY: 'apismart-key',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.equal(onRoute.APISMART_API_KEY, 'apismart-key')
|
||||
})
|
||||
|
||||
test('openai launch prefers a live dedicated vendor key over the persisted one', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
@@ -939,6 +1138,25 @@ test('buildStartupEnvFromProfile preserves concrete env-only NIM setup over stal
|
||||
assert.equal(resolveActiveRouteIdFromEnv(env), 'nvidia-nim')
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile preserves ApiSmart env-only setup over a saved profile', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
APISMART_API_KEY: 'apismart-live',
|
||||
}
|
||||
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('openai', {
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'stale-openai-key',
|
||||
}),
|
||||
processEnv,
|
||||
})
|
||||
|
||||
assert.equal(env.APISMART_API_KEY, 'apismart-live')
|
||||
assert.equal(resolveActiveRouteIdFromEnv(env), 'apismart')
|
||||
assert.equal(env.OPENAI_BASE_URL, undefined)
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile does not activate non-NIM env-only OpenAI-compatible setup', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: null,
|
||||
|
||||
+131
-24
@@ -25,6 +25,7 @@ import { getErrnoCode } from './errors.js'
|
||||
import {
|
||||
getRouteDefaultBaseUrl,
|
||||
getRouteDefaultModel,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
normalizeXiaomiMimoBaseUrl,
|
||||
resolveRouteCredentialValue,
|
||||
@@ -111,6 +112,8 @@ const PROFILE_ENV_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'NEARAI_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'LONGCAT_API_KEY',
|
||||
@@ -196,6 +199,7 @@ export type ProfileEnv = {
|
||||
VENICE_API_KEY?: string
|
||||
MIMO_API_KEY?: string
|
||||
ATLAS_CLOUD_API_KEY?: string
|
||||
APISMART_API_KEY?: string
|
||||
CLINE_API_KEY?: string
|
||||
NEARAI_API_KEY?: string
|
||||
FIREWORKS_API_KEY?: string
|
||||
@@ -632,6 +636,59 @@ export function buildAtlasCloudProfileEnv(options: {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApismartProfileEnv(options: {
|
||||
model?: string | null
|
||||
baseUrl?: string | null
|
||||
apiKey?: string | null
|
||||
processEnv?: NodeJS.ProcessEnv
|
||||
}): ProfileEnv | null {
|
||||
const processEnv = options.processEnv ?? process.env
|
||||
const key = sanitizeApiKey(options.apiKey ?? processEnv.APISMART_API_KEY)
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const defaultBaseUrl = getRouteDefaultBaseUrl('apismart')
|
||||
const defaultModel = getRouteDefaultModel('apismart')
|
||||
if (!defaultBaseUrl || !defaultModel) {
|
||||
throw new Error('ApiSmart route defaults are missing from integration metadata.')
|
||||
}
|
||||
const secretSource: SecretValueSource = {
|
||||
OPENAI_API_KEY: key,
|
||||
APISMART_API_KEY: key,
|
||||
}
|
||||
const configuredBaseUrl =
|
||||
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource)
|
||||
// Only the documented `/v1` inference URL may carry the dedicated key.
|
||||
// Host-only or path-suffixed ApiSmart URLs fall through to the generic
|
||||
// OpenAI path (same canonical gate AIMLAPI uses for ambient forwarding).
|
||||
if (
|
||||
configuredBaseUrl &&
|
||||
!isCanonicalApismartInferenceBaseUrl(configuredBaseUrl)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
OPENAI_BASE_URL: configuredBaseUrl || defaultBaseUrl,
|
||||
OPENAI_MODEL:
|
||||
normalizeProfileModel(
|
||||
sanitizeProviderConfigValue(options.model, secretSource),
|
||||
) ||
|
||||
normalizeProfileModel(
|
||||
sanitizeProviderConfigValue(processEnv.APISMART_MODEL, secretSource),
|
||||
) ||
|
||||
normalizeProfileModel(
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource),
|
||||
) ||
|
||||
defaultModel,
|
||||
OPENAI_API_KEY: key,
|
||||
APISMART_API_KEY: key,
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGeminiProfileEnv(options: {
|
||||
model?: string | null
|
||||
baseUrl?: string | null
|
||||
@@ -1346,8 +1403,12 @@ function hasConcreteProviderSelection(
|
||||
return true
|
||||
}
|
||||
|
||||
// Env-only provider setups — no CLAUDE_CODE_USE_* flag needed
|
||||
// Env-only provider setups — no CLAUDE_CODE_USE_* flag needed. These must
|
||||
// survive startup-profile selection so client routing can apply their
|
||||
// descriptor defaults; otherwise a saved/default profile clears the key
|
||||
// before the env-only resolver has a chance to see it.
|
||||
return (
|
||||
sanitizeApiKey(processEnv.APISMART_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined ||
|
||||
sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined
|
||||
@@ -2001,12 +2062,12 @@ export async function buildLaunchEnv(options: {
|
||||
} else {
|
||||
delete env.CLAUDE_CODE_PROVIDER_ROUTE_ID
|
||||
}
|
||||
// A keyless retained aimlapi profile on a non-canonical (proxy) base URL must
|
||||
// not receive the ambient canonical credential via the generic OPENAI_API_KEY
|
||||
// /OPENAI_API_KEYS alias either (the generic selection above prefers the live
|
||||
// shell value). Re-source the generic credential from the profile's OWN
|
||||
// persisted env and drop a purely ambient one.
|
||||
// Scoped to a launch that actually carries the aimlapi identity. A profile
|
||||
// A keyless retained aimlapi/apismart profile on a non-canonical (proxy) base
|
||||
// URL must not receive the ambient canonical credential via the generic
|
||||
// OPENAI_API_KEY / OPENAI_API_KEYS alias either (the generic selection above
|
||||
// prefers the live shell value). Re-source the generic credential from the
|
||||
// profile's OWN persisted env and drop a purely ambient one.
|
||||
// Scoped to a launch that actually carries the route identity. A profile
|
||||
// retargeted to an endpoint it was not saved for keeps no identity, so it is
|
||||
// handled by the route-agnostic precedence above rather than here: forcing the
|
||||
// profile's own credential in would both hand a key to an endpoint it was not
|
||||
@@ -2015,7 +2076,13 @@ export async function buildLaunchEnv(options: {
|
||||
effectiveOpenAIRouteId === 'aimlapi' &&
|
||||
!!env.OPENAI_BASE_URL?.trim() &&
|
||||
!isCanonicalAimlapiInferenceBaseUrl(env.OPENAI_BASE_URL)
|
||||
if (isNoncanonicalAimlapiLaunch) {
|
||||
const isNoncanonicalApismartLaunch =
|
||||
effectiveOpenAIRouteId === 'apismart' &&
|
||||
!!env.OPENAI_BASE_URL?.trim() &&
|
||||
!isCanonicalApismartInferenceBaseUrl(env.OPENAI_BASE_URL)
|
||||
const isNoncanonicalDedicatedOpenAILaunch =
|
||||
isNoncanonicalAimlapiLaunch || isNoncanonicalApismartLaunch
|
||||
if (isNoncanonicalDedicatedOpenAILaunch) {
|
||||
delete env.OPENAI_API_KEY
|
||||
delete env.OPENAI_API_KEYS
|
||||
const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv)
|
||||
@@ -2047,6 +2114,7 @@ export async function buildLaunchEnv(options: {
|
||||
}
|
||||
for (const dedicatedKey of [
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'NEARAI_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'LONGCAT_API_KEY',
|
||||
@@ -2062,6 +2130,9 @@ export async function buildLaunchEnv(options: {
|
||||
if (dedicatedKey === 'AIMLAPI_API_KEY' && effectiveOpenAIRouteId !== 'aimlapi') {
|
||||
continue
|
||||
}
|
||||
if (dedicatedKey === 'APISMART_API_KEY' && effectiveOpenAIRouteId !== 'apismart') {
|
||||
continue
|
||||
}
|
||||
if (dedicatedKey === 'NVIDIA_API_KEY' && effectiveOpenAIRouteId !== 'nvidia-nim') {
|
||||
continue
|
||||
}
|
||||
@@ -2071,22 +2142,49 @@ export async function buildLaunchEnv(options: {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// On a non-canonical (proxy) aimlapi base URL, never source AIMLAPI_API_KEY
|
||||
// from ambient/session credentials — that would leak the canonical AIMLAPI
|
||||
// key to a user-controlled proxy on restart. The profile's OWN persisted key
|
||||
// is still applied, since the user configured that key for that proxy.
|
||||
const aimlapiBaseUrl = env.OPENAI_BASE_URL?.trim()
|
||||
// On a non-canonical (proxy) aimlapi/apismart base URL, never source the
|
||||
// dedicated key from ambient/session credentials — that would leak the
|
||||
// canonical provider key to a user-controlled proxy on restart. The
|
||||
// profile's OWN persisted key is still applied, since the user configured
|
||||
// that key for that proxy.
|
||||
const dedicatedBaseUrl = env.OPENAI_BASE_URL?.trim()
|
||||
const withholdAmbientAimlapiKey =
|
||||
dedicatedKey === 'AIMLAPI_API_KEY' &&
|
||||
!!aimlapiBaseUrl &&
|
||||
!isCanonicalAimlapiInferenceBaseUrl(aimlapiBaseUrl)
|
||||
const dedicatedValue = withholdAmbientAimlapiKey
|
||||
!!dedicatedBaseUrl &&
|
||||
!isCanonicalAimlapiInferenceBaseUrl(dedicatedBaseUrl)
|
||||
const withholdAmbientApismartKey =
|
||||
dedicatedKey === 'APISMART_API_KEY' &&
|
||||
!!dedicatedBaseUrl &&
|
||||
!isCanonicalApismartInferenceBaseUrl(dedicatedBaseUrl)
|
||||
const withholdAmbientDedicatedKey =
|
||||
withholdAmbientAimlapiKey || withholdAmbientApismartKey
|
||||
// AIMLAPI accepts generic OpenAI credentials, but ApiSmart is
|
||||
// dedicatedCredentialsOnly. Never promote a shell OPENAI_API_KEY into the
|
||||
// dedicated credential on relaunch.
|
||||
const backfillDedicatedFromOpenAI =
|
||||
dedicatedKey === 'AIMLAPI_API_KEY' &&
|
||||
openAICredential?.kind === 'usable'
|
||||
? sanitizeApiKey(openAICredential.value)
|
||||
: undefined
|
||||
// Older ApiSmart profiles predate APISMART_API_KEY and persisted their
|
||||
// *profile-owned* credential only as OPENAI_API_KEY. Migrate that stored
|
||||
// value for the canonical ApiSmart endpoint, but never use the live shell
|
||||
// credential: the latter may belong to an unrelated OpenAI provider.
|
||||
const persistedOpenAICredential = resolveOpenAICredentialEnvSelection(persistedEnv)
|
||||
const backfillLegacyApismartProfileKey =
|
||||
dedicatedKey === 'APISMART_API_KEY' &&
|
||||
effectiveOpenAIRouteId === 'apismart' &&
|
||||
!!dedicatedBaseUrl &&
|
||||
isCanonicalApismartInferenceBaseUrl(dedicatedBaseUrl) &&
|
||||
persistedOpenAICredential?.kind === 'usable'
|
||||
? sanitizeApiKey(persistedOpenAICredential.value)
|
||||
: undefined
|
||||
const dedicatedValue = withholdAmbientDedicatedKey
|
||||
? sanitizeApiKey(persistedEnv[dedicatedKey])
|
||||
: (dedicatedKey === 'AIMLAPI_API_KEY' && openAICredential?.kind === 'usable'
|
||||
? sanitizeApiKey(openAICredential.value)
|
||||
: undefined) ||
|
||||
: backfillDedicatedFromOpenAI ||
|
||||
sanitizeApiKey(processEnv[dedicatedKey]) ||
|
||||
sanitizeApiKey(persistedEnv[dedicatedKey])
|
||||
sanitizeApiKey(persistedEnv[dedicatedKey]) ||
|
||||
backfillLegacyApismartProfileKey
|
||||
if (dedicatedValue) {
|
||||
env[dedicatedKey] = dedicatedValue
|
||||
}
|
||||
@@ -2102,9 +2200,9 @@ export async function buildLaunchEnv(options: {
|
||||
// client, and its own filter only drops `authorization`, `x-api-key` and
|
||||
// `api-key` — a custom-named header such as `X-Proxy-Auth: <secret>` survives
|
||||
// and is sent on every request. So an ambient value must be withheld from a
|
||||
// non-canonical aimlapi launch exactly like the API key and the custom-auth
|
||||
// trio; only headers the profile itself persisted are restored.
|
||||
const customHeaders = isNoncanonicalAimlapiLaunch
|
||||
// non-canonical aimlapi/apismart launch exactly like the API key and the
|
||||
// custom-auth trio; only headers the profile itself persisted are restored.
|
||||
const customHeaders = isNoncanonicalDedicatedOpenAILaunch
|
||||
? persistedCustomHeaders
|
||||
: shellCustomHeaders || persistedCustomHeaders
|
||||
if (customHeaders) {
|
||||
@@ -2178,7 +2276,16 @@ export async function buildStartupEnvFromProfile(options?: {
|
||||
// If startup already has a concrete provider selection, keep trusting it.
|
||||
// This prevents legacy profiles or the fresh-install default from becoming
|
||||
// a silent third precedence layer over explicit env/flags.
|
||||
if (hasConcreteProviderSelection(processEnv)) {
|
||||
// A retained ApiSmart proxy profile carries route identity specifically to
|
||||
// withhold ambient dedicated credentials from its noncanonical endpoint.
|
||||
// Do not let an env-only key skip that guard; the persisted profile must be
|
||||
// applied first so buildLaunchEnv can preserve the proxy boundary.
|
||||
const persistedApismartProxy =
|
||||
persisted?.profile === 'openai' &&
|
||||
persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'apismart' &&
|
||||
!!persisted.env.OPENAI_BASE_URL?.trim() &&
|
||||
!isCanonicalApismartInferenceBaseUrl(persisted.env.OPENAI_BASE_URL)
|
||||
if (hasConcreteProviderSelection(processEnv) && !persistedApismartProxy) {
|
||||
return processEnv
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ const RESTORED_KEYS = [
|
||||
'VENICE_API_KEY',
|
||||
'MIMO_API_KEY',
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'APISMART_MODEL',
|
||||
'CLINE_API_KEY',
|
||||
'HICAP_API_KEY',
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
@@ -265,6 +267,17 @@ function buildAtlasCloudProfile(overrides: Partial<ProviderProfile> = {}): Provi
|
||||
})
|
||||
}
|
||||
|
||||
function buildApismartProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
|
||||
return buildProfile({
|
||||
provider: 'apismart',
|
||||
name: 'ApiSmart',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
model: 'DEEPSEEK_V4_FLASH',
|
||||
apiKey: 'apismart-test-key',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function buildClinePassProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
|
||||
return buildProfile({
|
||||
provider: 'clinepass',
|
||||
@@ -799,6 +812,171 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test('apismart profile applies OpenAI-compatible env with APISMART_API_KEY mirror', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
|
||||
applyProviderProfileToProcessEnv(buildApismartProfile())
|
||||
const { getAPIProvider: getFreshAPIProvider } =
|
||||
await importFreshProvidersModule()
|
||||
|
||||
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
||||
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-test-key')
|
||||
expect(process.env.APISMART_API_KEY).toBe('apismart-test-key')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
})
|
||||
|
||||
test('apismart profile clears a stale route-specific model before applying its saved model', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.APISMART_MODEL = 'KIMI_K3'
|
||||
|
||||
applyProviderProfileToProcessEnv(buildApismartProfile())
|
||||
|
||||
expect(process.env.APISMART_MODEL).toBeUndefined()
|
||||
expect(process.env.OPENAI_MODEL).toBe('DEEPSEEK_V4_FLASH')
|
||||
})
|
||||
|
||||
test('apismart profile without a base URL retains its dedicated credential for the default route', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(buildApismartProfile({ baseUrl: undefined }))
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('apismart-test-key')
|
||||
expect(process.env.APISMART_API_KEY).toBe('apismart-test-key')
|
||||
})
|
||||
|
||||
test('retargeted ApiSmart profile withholds its dedicated credential', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({ baseUrl: 'https://proxy.example/v1' }),
|
||||
)
|
||||
|
||||
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.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
})
|
||||
|
||||
test('keyless ApiSmart profile resolves APISMART_API_KEY without persisting it', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.APISMART_API_KEY = 'ambient-apismart-key'
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({
|
||||
apiKey: undefined,
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBe('ambient-apismart-key')
|
||||
expect(process.env.APISMART_API_KEY).toBe('ambient-apismart-key')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless ApiSmart profile without a base URL resolves the ambient key as canonical', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.APISMART_API_KEY = 'ambient-apismart-key'
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('ambient-apismart-key')
|
||||
expect(process.env.APISMART_API_KEY).toBe('ambient-apismart-key')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless canonical ApiSmart profile never promotes a generic OpenAI key', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.OPENAI_API_KEY = 'generic-openai-key'
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({
|
||||
apiKey: undefined,
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless custom ApiSmart profile preserves route identity without forwarding the ambient key', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.APISMART_API_KEY = 'ambient-apismart-key'
|
||||
process.env.OPENAI_API_KEY = 'ambient-apismart-key'
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({
|
||||
apiKey: undefined,
|
||||
baseUrl: 'https://proxy.example.com/v1',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example.com/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
}, 20_000)
|
||||
|
||||
test('non-canonical ApiSmart host path withholds the dedicated credential', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildApismartProfile({ baseUrl: 'https://gw.apismart.ai/staging/v1' }),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://gw.apismart.ai/staging/v1')
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.APISMART_API_KEY).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])(
|
||||
'addProviderProfile drops placeholder ApiSmart credential %s',
|
||||
async placeholder => {
|
||||
const { addProviderProfile, getProviderProfiles } =
|
||||
await importFreshProviderProfileModules()
|
||||
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [],
|
||||
activeProviderProfileId: undefined,
|
||||
}))
|
||||
|
||||
const saved = addProviderProfile({
|
||||
provider: 'apismart',
|
||||
name: 'ApiSmart',
|
||||
baseUrl: 'https://gw.apismart.ai/v1',
|
||||
model: 'DEEPSEEK_V4_FLASH',
|
||||
apiKey: placeholder,
|
||||
})
|
||||
|
||||
expect(saved?.apiKey).toBeUndefined()
|
||||
expect(getProviderProfiles()[0]?.apiKey).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
test('cloudflare profile applies OpenAI-compatible env with CLOUDFLARE_API_TOKEN mirror', async () => {
|
||||
// Account-scoped URL: a real user has substituted `<ACCOUNT_ID>` for their
|
||||
// Cloudflare account id. The env-build path should mirror the api key into
|
||||
@@ -3123,6 +3301,59 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('retargeted ApiSmart profiles keep route identity but persist without their dedicated 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 apismartProfile = buildApismartProfile({
|
||||
id: 'apismart_proxy',
|
||||
baseUrl: 'https://proxy.example/v1',
|
||||
})
|
||||
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [apismartProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('apismart_proxy', { configDir })
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
|
||||
expect(result?.id).toBe('apismart_proxy')
|
||||
expect(persisted.profile).toBe('openai')
|
||||
expect(persisted.env).toEqual({
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'apismart',
|
||||
OPENAI_BASE_URL: 'https://proxy.example/v1',
|
||||
OPENAI_MODEL: 'DEEPSEEK_V4_FLASH',
|
||||
})
|
||||
|
||||
const { buildStartupEnvFromProfile } = await import(
|
||||
`./providerProfile.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const startupEnv = await buildStartupEnvFromProfile({
|
||||
persisted,
|
||||
processEnv: {
|
||||
APISMART_API_KEY: 'ambient-apismart-key',
|
||||
OPENAI_API_KEY: 'ambient-apismart-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('apismart')
|
||||
expect(startupEnv.APISMART_API_KEY).toBeUndefined()
|
||||
expect(startupEnv.OPENAI_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-'))
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
buildXaiOAuthProfileEnv,
|
||||
buildXiaomiMimoProfileEnv,
|
||||
buildAtlasCloudProfileEnv,
|
||||
buildApismartProfileEnv,
|
||||
buildVertexProfileEnv,
|
||||
clearManagedProfileEnv,
|
||||
deleteProfileFile,
|
||||
@@ -50,8 +51,10 @@ import {
|
||||
type ProviderPreset,
|
||||
} from '../integrations/index.js'
|
||||
import {
|
||||
getRouteDefaultBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isClinePassBaseUrl,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
isFireworksBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
isNearaiBaseUrl,
|
||||
@@ -64,6 +67,7 @@ import {
|
||||
sanitizeProfileCustomHeaders,
|
||||
serializeProfileCustomHeaders,
|
||||
} from './providerCustomHeaders.js'
|
||||
import { sanitizeApiKey } from './providerSecrets.js'
|
||||
import { getSettings_DEPRECATED } from './settings/settings.js'
|
||||
|
||||
export type { ProviderPreset } from '../integrations/index.js'
|
||||
@@ -149,6 +153,14 @@ function isClinePassProfile(profile: ProviderProfile): boolean {
|
||||
return route.routeId === 'clinepass' || isClinePassBaseUrl(profile.baseUrl)
|
||||
}
|
||||
|
||||
function isApismartProfile(profile: ProviderProfile): boolean {
|
||||
const baseUrl = profile.baseUrl?.trim()
|
||||
// Missing base URL resolves to the ApiSmart default, which is canonical.
|
||||
// Only the documented `/v1` inference URL may carry the dedicated key —
|
||||
// host-only or path-suffixed ApiSmart URLs are treated as retargeted.
|
||||
return !baseUrl || isCanonicalApismartInferenceBaseUrl(baseUrl)
|
||||
}
|
||||
|
||||
function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined {
|
||||
if (!baseUrl?.trim()) return undefined
|
||||
try {
|
||||
@@ -353,7 +365,8 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
||||
provider,
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey: trimOrUndefined(profile.apiKey),
|
||||
// Drop template/dotenv sentinels so placeholders never persist as keys.
|
||||
apiKey: sanitizeApiKey(trimOrUndefined(profile.apiKey)),
|
||||
}
|
||||
if (supportsApiFormat && apiFormat) {
|
||||
sanitized.apiFormat = apiFormat
|
||||
@@ -797,6 +810,10 @@ function isProcessEnvAlignedWithProfile(
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.ATLAS_CLOUD_API_KEY, profile.apiKey)
|
||||
: true) &&
|
||||
(isApismartProfile(profile)
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.APISMART_API_KEY, profile.apiKey)
|
||||
: true) &&
|
||||
(isClinePassProfile(profile)
|
||||
? !includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.CLINE_API_KEY, profile.apiKey)
|
||||
@@ -953,6 +970,8 @@ export function applyProviderProfileToProcessEnv(
|
||||
const normalizedProfileBaseUrl =
|
||||
route.routeId === 'xiaomi-mimo' || route.routeId === 'xiaomi-mimo-token'
|
||||
? normalizeXiaomiMimoBaseUrl(profile.baseUrl) ?? profile.baseUrl
|
||||
: route.routeId === 'apismart' && !profile.baseUrl?.trim()
|
||||
? getRouteDefaultBaseUrl('apismart') ?? profile.baseUrl
|
||||
: profile.baseUrl
|
||||
const openAIProfileEnv: ProfileEnv = {
|
||||
OPENAI_BASE_URL: normalizedProfileBaseUrl,
|
||||
@@ -980,19 +999,21 @@ export function applyProviderProfileToProcessEnv(
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.apiKey) {
|
||||
const withholdRetargetedApismartCredential =
|
||||
route.routeId === 'apismart' && !isApismartProfile(profile)
|
||||
if (profile.apiKey && !withholdRetargetedApismartCredential) {
|
||||
openAIProfileEnv.OPENAI_API_KEY = profile.apiKey
|
||||
if (route.vendorId === 'minimax' || profile.baseUrl.toLowerCase().includes('minimax')) {
|
||||
if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) {
|
||||
openAIProfileEnv.MINIMAX_API_KEY = profile.apiKey
|
||||
}
|
||||
if (
|
||||
route.gatewayId === 'nvidia-nim' ||
|
||||
profile.baseUrl.toLowerCase().includes('nvidia') ||
|
||||
profile.baseUrl.toLowerCase().includes('integrate.api.nvidia')
|
||||
normalizedProfileBaseUrl.toLowerCase().includes('nvidia') ||
|
||||
normalizedProfileBaseUrl.toLowerCase().includes('integrate.api.nvidia')
|
||||
) {
|
||||
openAIProfileEnv.NVIDIA_API_KEY = profile.apiKey
|
||||
}
|
||||
if (route.routeId === 'bankr' || profile.baseUrl.toLowerCase().includes('bankr')) {
|
||||
if (route.routeId === 'bankr' || normalizedProfileBaseUrl.toLowerCase().includes('bankr')) {
|
||||
openAIProfileEnv.BNKR_API_KEY = profile.apiKey
|
||||
}
|
||||
if (route.routeId === 'xai' || isXaiBaseUrl(profile.baseUrl)) {
|
||||
@@ -1001,7 +1022,7 @@ export function applyProviderProfileToProcessEnv(
|
||||
if (isAimlapiProfile) {
|
||||
openAIProfileEnv.AIMLAPI_API_KEY = profile.apiKey
|
||||
}
|
||||
if (route.routeId === 'venice' || profile.baseUrl.toLowerCase().includes('api.venice.ai')) {
|
||||
if (route.routeId === 'venice' || normalizedProfileBaseUrl.toLowerCase().includes('api.venice.ai')) {
|
||||
openAIProfileEnv.VENICE_API_KEY = profile.apiKey
|
||||
}
|
||||
if (
|
||||
@@ -1011,9 +1032,12 @@ export function applyProviderProfileToProcessEnv(
|
||||
) {
|
||||
openAIProfileEnv.MIMO_API_KEY = profile.apiKey
|
||||
}
|
||||
if (route.routeId === 'atlas-cloud' || profile.baseUrl.toLowerCase().includes('atlascloud')) {
|
||||
if (route.routeId === 'atlas-cloud' || normalizedProfileBaseUrl.toLowerCase().includes('atlascloud')) {
|
||||
openAIProfileEnv.ATLAS_CLOUD_API_KEY = profile.apiKey
|
||||
}
|
||||
if (isApismartProfile(profile)) {
|
||||
openAIProfileEnv.APISMART_API_KEY = profile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(profile)) {
|
||||
openAIProfileEnv.CLINE_API_KEY = profile.apiKey
|
||||
}
|
||||
@@ -1059,6 +1083,23 @@ export function applyProviderProfileToProcessEnv(
|
||||
openAIProfileEnv.AIMLAPI_API_KEY ?? ambientAimlapiKey
|
||||
}
|
||||
}
|
||||
// Keep ApiSmart route identity even when the profile is retargeted to a
|
||||
// proxy. Dedicated credentials stay withheld above; the route id is what
|
||||
// lets buildLaunchEnv refuse ambient APISMART_API_KEY / mirrored
|
||||
// OPENAI_API_KEY on relaunch (AIMLAPI parity).
|
||||
if (route.routeId === 'apismart') {
|
||||
openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'apismart'
|
||||
// Keyless canonical ApiSmart profiles resolve ambient dedicated
|
||||
// credentials the same way AIMLAPI does. Proxy / non-canonical hosts
|
||||
// must not receive the ambient key.
|
||||
if (isApismartProfile(profile)) {
|
||||
const ambientApismartKey = sanitizeApiKey(process.env.APISMART_API_KEY)
|
||||
openAIProfileEnv.OPENAI_API_KEY =
|
||||
openAIProfileEnv.OPENAI_API_KEY ?? ambientApismartKey
|
||||
openAIProfileEnv.APISMART_API_KEY =
|
||||
openAIProfileEnv.APISMART_API_KEY ?? ambientApismartKey
|
||||
}
|
||||
}
|
||||
if (route.gatewayId === 'nvidia-nim') {
|
||||
openAIProfileEnv.NVIDIA_NIM = '1'
|
||||
}
|
||||
@@ -1340,11 +1381,14 @@ function buildOpenAICompatibleStartupEnv(
|
||||
if (isCodexBaseUrl(activeProfile.baseUrl)) {
|
||||
return null
|
||||
}
|
||||
const withholdRetargetedApismartCredential =
|
||||
resolveProfileRoute(activeProfile.provider).routeId === 'apismart' &&
|
||||
!isApismartProfile(activeProfile)
|
||||
const isAimlapiProfile =
|
||||
activeProfile.provider === 'aimlapi' ||
|
||||
resolveRouteIdFromBaseUrl(activeProfile.baseUrl) === 'aimlapi'
|
||||
|
||||
if (activeProfile.apiKey) {
|
||||
if (activeProfile.apiKey && !withholdRetargetedApismartCredential) {
|
||||
const strictEnv = buildOpenAIProfileEnv({
|
||||
goal: 'balanced',
|
||||
model: activeProfile.model,
|
||||
@@ -1369,6 +1413,9 @@ function buildOpenAICompatibleStartupEnv(
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
|
||||
strictEnv.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isApismartProfile(activeProfile)) {
|
||||
strictEnv.APISMART_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(activeProfile)) {
|
||||
strictEnv.CLINE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
@@ -1415,7 +1462,13 @@ function buildOpenAICompatibleStartupEnv(
|
||||
if (isAimlapiProfile) {
|
||||
env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
|
||||
}
|
||||
if (activeProfile.apiKey) {
|
||||
// Preserve ApiSmart identity on retargeted/proxy startup envs so relaunch
|
||||
// withholding can refuse ambient dedicated credentials. Canonical profiles
|
||||
// already stamp this via buildApismartProfileEnv.
|
||||
if (resolveProfileRoute(activeProfile.provider).routeId === 'apismart') {
|
||||
env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'apismart'
|
||||
}
|
||||
if (activeProfile.apiKey && !withholdRetargetedApismartCredential) {
|
||||
env.OPENAI_API_KEY = activeProfile.apiKey
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
|
||||
env.BNKR_API_KEY = activeProfile.apiKey
|
||||
@@ -1438,6 +1491,9 @@ function buildOpenAICompatibleStartupEnv(
|
||||
if (activeProfile.baseUrl?.toLowerCase().includes('atlascloud')) {
|
||||
env.ATLAS_CLOUD_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isApismartProfile(activeProfile)) {
|
||||
env.APISMART_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
if (isClinePassProfile(activeProfile)) {
|
||||
env.CLINE_API_KEY = activeProfile.apiKey
|
||||
}
|
||||
@@ -1627,6 +1683,19 @@ 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
|
||||
@@ -1663,6 +1732,9 @@ function triggerStartupDiscoveryRefreshForProfile(
|
||||
if (route.routeId === 'unknown-fallback') {
|
||||
return
|
||||
}
|
||||
if (route.routeId === 'apismart' && !isApismartProfile(profile)) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshStartupDiscoveryForRoute(route.routeId, {
|
||||
baseUrl: profile.baseUrl,
|
||||
|
||||
@@ -162,10 +162,14 @@ describe('getKnownProviderSecretEnvKeys', () => {
|
||||
})
|
||||
|
||||
describe('sanitizeApiKey', () => {
|
||||
test('drops empty and the Portuguese placeholder', () => {
|
||||
test('drops empty and credential placeholders', () => {
|
||||
expect(sanitizeApiKey(undefined)).toBeUndefined()
|
||||
expect(sanitizeApiKey('')).toBeUndefined()
|
||||
expect(sanitizeApiKey('SUA_CHAVE')).toBeUndefined()
|
||||
expect(sanitizeApiKey('sua_chave')).toBeUndefined()
|
||||
expect(sanitizeApiKey('null')).toBeUndefined()
|
||||
expect(sanitizeApiKey('undefined')).toBeUndefined()
|
||||
expect(sanitizeApiKey(' NULL ')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns real keys unchanged', () => {
|
||||
|
||||
@@ -87,7 +87,19 @@ export type SecretValueSource = Partial<Record<string, string | undefined>>
|
||||
export function sanitizeApiKey(
|
||||
key: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!key || key === 'SUA_CHAVE') return undefined
|
||||
if (!key) return undefined
|
||||
const trimmed = key.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = trimmed.toLowerCase()
|
||||
// Keep profile/env sanitization aligned with credentialPool placeholders so
|
||||
// template values like SUA_CHAVE / null / undefined never persist as keys.
|
||||
if (
|
||||
normalized === 'sua_chave' ||
|
||||
normalized === 'null' ||
|
||||
normalized === 'undefined'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const ENV_KEYS = [
|
||||
'MISTRAL_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'LONGCAT_API_KEY',
|
||||
'APISMART_API_KEY',
|
||||
'NVIDIA_API_KEY',
|
||||
'NVIDIA_NIM',
|
||||
'BNKR_API_KEY',
|
||||
@@ -185,6 +186,47 @@ test('non-OpenAI LongCat path falls back to generic OpenAI validation', async ()
|
||||
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('non-canonical ApiSmart host falls back to generic OpenAI validation', async () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai:8443/v1'
|
||||
process.env.OPENAI_MODEL = 'custom-model'
|
||||
process.env.OPENAI_API_KEY = 'generic-key'
|
||||
|
||||
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('noncanonical ApiSmart paths do not validate a dedicated credential', async () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v2'
|
||||
process.env.OPENAI_MODEL = 'DEEPSEEK_V4_FLASH'
|
||||
process.env.APISMART_API_KEY = 'apismart-key'
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_API_KEYS
|
||||
|
||||
const message = await getProviderValidationError(process.env)
|
||||
expect(message).not.toBeNull()
|
||||
expect(message).not.toContain('ApiSmart auth is required')
|
||||
expect(message).toContain(
|
||||
'OPENAI_API_KEYS or OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1',
|
||||
)
|
||||
})
|
||||
|
||||
test.each(['SUA_CHAVE', 'sua_chave', 'null', 'undefined', ' NULL '])(
|
||||
'ApiSmart validation rejects placeholder APISMART_API_KEY %s',
|
||||
async placeholder => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://gw.apismart.ai/v1'
|
||||
process.env.OPENAI_MODEL = 'DEEPSEEK_V4_FLASH'
|
||||
process.env.APISMART_API_KEY = placeholder
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_API_KEYS
|
||||
|
||||
await expect(getProviderValidationError(process.env)).resolves.toBe(
|
||||
'ApiSmart auth is required. Set APISMART_API_KEY.',
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
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,7 @@ import {
|
||||
getRouteCredentialValue,
|
||||
getRouteDescriptor,
|
||||
getRouteDefaultModel,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
isCloudflareBaseUrl,
|
||||
isLongcatBaseUrl,
|
||||
matchHostnameAgainstRouteHosts,
|
||||
@@ -134,7 +135,12 @@ function hasUsableCredentialEnvValue(
|
||||
return false
|
||||
}
|
||||
|
||||
if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') {
|
||||
if (
|
||||
envVar === 'OPENAI_API_KEYS' ||
|
||||
envVar === 'OPENAI_API_KEY' ||
|
||||
envVar === 'AIMLAPI_API_KEY' ||
|
||||
envVar === 'APISMART_API_KEY'
|
||||
) {
|
||||
return hasUsableOpenAICredential(value)
|
||||
}
|
||||
|
||||
@@ -271,17 +277,16 @@ function getRuntimeValidationTarget(
|
||||
return false
|
||||
}
|
||||
|
||||
// The Cloudflare Workers AI route is path-scoped, not just host-scoped:
|
||||
// `api.cloudflare.com` also serves the REST management API. A host-only
|
||||
// match on a non-Workers path (e.g. `.../client/v4/user/tokens/verify`)
|
||||
// would pick the Cloudflare validation target and demand its Workers-AI
|
||||
// auth instead of falling back to generic OpenAI validation. Mirror the
|
||||
// runtime route resolver's boundary here.
|
||||
// Some routes have stricter endpoint boundaries than a host match. Keep
|
||||
// validation aligned with the runtime resolver so a custom endpoint on a
|
||||
// shared host is not forced through a dedicated-credential contract.
|
||||
if (
|
||||
((target.descriptor.id === 'cloudflare' &&
|
||||
!isCloudflareBaseUrl(request.baseUrl)) ||
|
||||
(target.descriptor.id === 'longcat' &&
|
||||
!isLongcatBaseUrl(request.baseUrl)))
|
||||
!isLongcatBaseUrl(request.baseUrl)) ||
|
||||
(target.descriptor.id === 'apismart' &&
|
||||
!isCanonicalApismartInferenceBaseUrl(request.baseUrl)))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -188,6 +188,14 @@ export const providers: Provider[] = [
|
||||
envVars: ['ATLAS_CLOUD_API_KEY'],
|
||||
notes: 'OpenAI-compatible hosted open models with reasoning support at https://api.atlascloud.ai/v1.',
|
||||
},
|
||||
{
|
||||
id: 'apismart',
|
||||
name: 'ApiSmart',
|
||||
group: 'gateways',
|
||||
setup: '/provider or env vars',
|
||||
envVars: ['APISMART_API_KEY', 'APISMART_MODEL'],
|
||||
notes: 'Unified OpenAI-compatible gateway at https://gw.apismart.ai/v1; defaults to DEEPSEEK_V4_FLASH with hybrid /v1/models discovery.',
|
||||
},
|
||||
{
|
||||
id: 'cloudflare',
|
||||
name: 'Cloudflare Workers AI',
|
||||
|
||||
Reference in New Issue
Block a user