diff --git a/README.md b/README.md index 40e3d6c56..2a54243ae 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 562979420..a6b55c086 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -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', diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 9a63d7e38..4b135d571 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -19,6 +19,7 @@ import type { ProviderPreset } from '../utils/providerProfiles.js' const EXPECTED_PRESETS = [ 'anthropic', 'atlas-cloud', + 'apismart', 'aimlapi', 'openai', 'ollama', diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index bb56f6c9d..65c641783 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -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() diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 2cd957b74..326d5c84b 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -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, }), ) diff --git a/src/integrations/gateways/apismart.test.ts b/src/integrations/gateways/apismart.test.ts new file mode 100644 index 000000000..2086ddd17 --- /dev/null +++ b/src/integrations/gateways/apismart.test.ts @@ -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() + }) +}) diff --git a/src/integrations/gateways/apismart.ts b/src/integrations/gateways/apismart.ts new file mode 100644 index 000000000..2bf06b497 --- /dev/null +++ b/src/integrations/gateways/apismart.ts @@ -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 { + return typeof value === 'object' && value !== null +} + +function getTrimmedString( + record: Record, + 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 }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 178a1aa9d..87a002174 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -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[])[] diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index b8af4ab41..2ad565a0f 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -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", diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index bce9124b0..9b09c7d29 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -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({ diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index 6b2d80bd9..a60a6c570 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -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 } diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index f1297e0e9..f1b8b1fdd 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -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 diff --git a/src/services/api/client.ts b/src/services/api/client.ts index b7b1bfa12..3b6b92f03 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -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) || diff --git a/src/services/api/credentialPool.test.ts b/src/services/api/credentialPool.test.ts index 67debb755..ac970c391 100644 --- a/src/services/api/credentialPool.test.ts +++ b/src/services/api/credentialPool.test.ts @@ -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']) diff --git a/src/services/api/credentialPool.ts b/src/services/api/credentialPool.ts index d83c96705..c05ce0939 100644 --- a/src/services/api/credentialPool.ts +++ b/src/services/api/credentialPool.ts @@ -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)) ) } diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index 14cb81ec0..1f9d06801 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -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([]) }) diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index 33fde1adb..08aa05d72 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -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, diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts index 558927780..ade244531 100644 --- a/src/services/api/providerConfig.test.ts +++ b/src/services/api/providerConfig.test.ts @@ -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') diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 0ccd2e28c..c783c790f 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -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, diff --git a/src/utils/envFile.test.ts b/src/utils/envFile.test.ts index 31ed02d22..97847cf94 100644 --- a/src/utils/envFile.test.ts +++ b/src/utils/envFile.test.ts @@ -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', diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index 0763ea2c7..a7093b48d 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -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', diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 0adc6ad60..b0545046f 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -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 diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index aa5f27952..1cdabcbfe 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -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) diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 0e162bcad..ac1c29696 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -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, diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 64cbaf191..ca82b52d4 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -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: ` 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 } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index d08f6e918..2b07a4609 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -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 = {}): Provi }) } +function buildApismartProfile(overrides: Partial = {}): 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 { 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 `` 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-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index f9810d672..4cc1aa0f0 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -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, diff --git a/src/utils/providerSecrets.test.ts b/src/utils/providerSecrets.test.ts index 4bf4e01e6..b10fae82a 100644 --- a/src/utils/providerSecrets.test.ts +++ b/src/utils/providerSecrets.test.ts @@ -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', () => { diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index 8dc247620..31d9049ab 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -87,7 +87,19 @@ export type SecretValueSource = Partial> 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 } diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 92440ccac..e603ffe36 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -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' diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 0434aec1f..69c6697f8 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -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 } diff --git a/web/src/data/configuration.ts b/web/src/data/configuration.ts index 673664307..bbe6a9d10 100644 --- a/web/src/data/configuration.ts +++ b/web/src/data/configuration.ts @@ -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.' }, diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 6b51dd6be..089c3d8b2 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -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',