diff --git a/.env.example b/.env.example index a6db87049..4eded9a5a 100644 --- a/.env.example +++ b/.env.example @@ -211,6 +211,13 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # OPENAI_BASE_URL=https://api.aimlapi.com/v1 # OPENAI_MODEL=gpt-4o +# For LLMTR, prefer its dedicated key. Raw env setup must also set +# OPENAI_BASE_URL and OPENAI_MODEL. OPENAI_API_KEY remains supported: +# LLMTR_API_KEY=your-llmtr-key-here +# OPENAI_API_KEY=your-llmtr-key-here # supported fallback +# OPENAI_BASE_URL=https://llmtr.com/v1 +# OPENAI_MODEL=deepseek/deepseek-v4-flash + # Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com) # OPENAI_BASE_URL=https://api.openai.com/v1 # Choose the OpenAI-compatible API surface (optional). diff --git a/README.md b/README.md index c8954f64c..bf5fc976b 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ Advanced and source-build guides: | 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 | | Concentrate | `/provider` or `CONCENTRATE_API_KEY` | Unified OpenAI-compatible gateway at `https://api.concentrate.ai/v1`; defaults to `deepseek-v4-flash` and auto-discovers the chat model catalog | +| LLMTR | `/provider` or OpenAI-compatible env vars | Multi-model gateway at `https://llmtr.com/v1`; `/provider` and `--provider llmtr` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://llmtr.com/v1` and `OPENAI_MODEL`; accepts `LLMTR_API_KEY` or `OPENAI_API_KEY` after the route is selected and discovers tool-capable Chat Completions models from the public 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` | diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index f316add8e..ad84b6d98 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -136,6 +136,7 @@ const PRESET_ORDER = [ 'Anthropic', 'Alibaba Coding Plan (China)', 'Alibaba Coding Plan', + 'ApiSmart', 'Atlas Cloud', 'Azure OpenAI', 'Bankr', @@ -149,6 +150,7 @@ const PRESET_ORDER = [ 'Google AI / Gemini', 'Groq', 'Hicap', + 'LLMTR', 'LM Studio', 'Atomic Chat', 'Ollama', @@ -308,6 +310,17 @@ function mockProviderProfilesModule(options?: { } } + if (preset === 'llmtr') { + return { + provider: 'llmtr', + name: 'LLMTR', + baseUrl: 'https://llmtr.com/v1', + model: 'deepseek/deepseek-v4-flash', + apiKey: '', + requiresApiKey: true, + } + } + return { provider: 'openai', name: 'Mock provider', @@ -1126,6 +1139,130 @@ test('ProviderManager asks for model and API key when adding OpenAI preset', asy } }) +test('ProviderManager adds LLMTR with only model selection and API key', async () => { + const addProviderProfile = mock((payload: any) => ({ + id: 'llmtr_profile', + ...payload, + })) + + mockProviderManagerDependencies(() => undefined, async () => undefined, { + addProviderProfile, + }) + + const nonce = `${Date.now()}-${Math.random()}` + const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) + const mounted = await mountProviderManager(ProviderManager) + + try { + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Provider manager'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Choose provider preset'), + ) + + await navigateToPreset(mounted.stdin, 'LLMTR') + mounted.stdin.write('\r') + const modelOutput = await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Create provider profile') && + frame.includes('Step 1 of 2: Default model'), + ) + + expect(modelOutput).toContain('LLMTR') + expect(modelOutput).toContain('deepseek/deepseek-v4-flash') + expect(modelOutput).not.toContain('Provider name') + expect(modelOutput).not.toContain('Base URL') + expect(modelOutput).not.toContain('API mode') + expect(modelOutput).not.toContain('Custom headers') + + mounted.stdin.write('\r') + const keyOutput = await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Step 2 of 2: API key'), + ) + expect(keyOutput).not.toContain('Provider name') + expect(keyOutput).not.toContain('Base URL') + expect(keyOutput).not.toContain('API mode') + expect(keyOutput).not.toContain('Custom headers') + + mounted.stdin.write('llmtr-test-key') + await Bun.sleep(25) + mounted.stdin.write('\r') + + await waitForCondition(() => addProviderProfile.mock.calls.length > 0) + expect(addProviderProfile).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'llmtr', + name: 'LLMTR', + baseUrl: 'https://llmtr.com/v1', + model: 'deepseek/deepseek-v4-flash', + apiKey: 'llmtr-test-key', + apiFormat: 'chat_completions', + }), + expect.objectContaining({ makeActive: true }), + ) + } finally { + await mounted.dispose() + } +}) + +test('ProviderManager edits query-bearing LLMTR endpoints with generic proxy controls', async () => { + const llmtrProxyProfile = { + id: 'provider_llmtr_proxy', + provider: 'llmtr', + name: 'LLMTR query proxy', + baseUrl: 'https://llmtr.com/v1?tenant=proxy', + model: 'proxy-model', + apiKey: undefined, + apiFormat: 'responses', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-auth-value', + customHeaders: { 'X-Proxy-Trace': 'enabled' }, + } + + mockProviderManagerDependencies( + () => undefined, + async () => undefined, + { + getProviderProfiles: () => [llmtrProxyProfile], + getActiveProviderProfile: () => llmtrProxyProfile, + }, + ) + + const nonce = `${Date.now()}-${Math.random()}` + const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) + const mounted = await mountProviderManager(ProviderManager) + + try { + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Provider manager') && frame.includes('Edit provider'), + ) + + mounted.stdin.write('j') + await Bun.sleep(25) + mounted.stdin.write('j') + await Bun.sleep(25) + mounted.stdin.write('\r') + + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Edit provider') && + frame.includes('LLMTR query proxy') && + !frame.includes('Provider manager'), + ) + + mounted.stdin.write('\r') + const editOutput = await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Edit provider profile') && frame.includes('Step 1 of 8'), + ) + + expect(editOutput).toContain('Advanced: this provider supports custom request headers') + } finally { + await mounted.dispose() + } +}) + test('ProviderManager saves OpenAI preset GPT-5 models with Responses API', async () => { const addProviderProfile = mock((payload: any) => ({ id: 'openai_profile', diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 009b04252..c382eaba4 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -90,6 +90,7 @@ import { getActiveProviderProfile, getProviderPresetDefaults, getProviderProfiles, + resolveProfileCapabilityRouteId, setActiveProviderProfile, type ProviderPreset, type ProviderProfileInput, @@ -368,12 +369,7 @@ function resolveProviderEditorRouteId( provider: ProviderProfile['provider'], baseUrl?: string, ): string { - const route = resolveProfileRoute(provider).routeId - if (route !== 'openai') { - return route - } - - return resolveRouteIdFromBaseUrl(baseUrl) ?? route + return resolveProfileCapabilityRouteId(provider, baseUrl) } function routeSupportsResponsesModel(routeId: string, model: string): boolean { @@ -2539,6 +2535,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { } function renderForm(): React.ReactNode { + const editorRouteId = resolveProviderEditorRouteId( + draftProvider, + draft.baseUrl, + ) return ( @@ -2547,9 +2547,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { {displayStep.helpText} Provider type:{' '} - {getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)} + {getRouteProviderTypeLabel(editorRouteId)} - {routeSupportsCustomHeaders(resolveProfileRoute(draftProvider).routeId) ? ( + {routeSupportsCustomHeaders(editorRouteId) ? ( Advanced: this provider supports custom request headers when you need them. diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 4b135d571..934b7503f 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -49,9 +49,11 @@ const EXPECTED_PRESETS = [ 'atomic-chat', 'cloudflare', 'gitlawb-opengateway', + 'concentrate', 'nearai', 'fireworks', 'longcat', + 'llmtr', 'opencode', 'opencode-go', 'clinepass', diff --git a/src/integrations/gateways/llmtr.models.ts b/src/integrations/gateways/llmtr.models.ts new file mode 100644 index 000000000..cc20a8f3f --- /dev/null +++ b/src/integrations/gateways/llmtr.models.ts @@ -0,0 +1,129 @@ +import { defineCatalog } from '../define.js' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function hasStringValue(value: unknown, expected: string): boolean { + return ( + Array.isArray(value) && + value.some(item => typeof item === 'string' && item === expected) + ) +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isInteger(value) && + value > 0 + ? value + : undefined +} + +export function mapLlmtrModel(raw: unknown) { + if (!isRecord(raw)) { + return null + } + + const id = typeof raw.id === 'string' ? raw.id.trim() : '' + if ( + !id || + !hasStringValue(raw.supported_parameters, 'tools') || + !hasStringValue(raw.supported_operations, 'CHAT_COMPLETIONS') || + !hasStringValue(raw.supported_endpoints, '/v1/chat/completions') + ) { + return null + } + + const architecture = isRecord(raw.architecture) ? raw.architecture : null + const topProvider = isRecord(raw.top_provider) ? raw.top_provider : null + const contextWindow = + positiveInteger(raw.context_length) ?? + positiveInteger(topProvider?.context_length) + const maxOutputTokens = positiveInteger( + topProvider?.max_completion_tokens, + ) + + return { + id, + apiName: id, + label: + typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : id, + capabilities: { + supportsFunctionCalling: true, + supportsVision: hasStringValue(architecture?.input_modalities, 'image'), + supportsReasoning: + isRecord(raw.reasoning) || + hasStringValue(raw.supported_parameters, 'reasoning') || + hasStringValue(raw.supported_parameters, 'reasoning_effort'), + }, + ...(contextWindow ? { contextWindow } : {}), + ...(maxOutputTokens ? { maxOutputTokens } : {}), + } +} + +export default defineCatalog({ + source: 'hybrid', + discovery: { + kind: 'openai-compatible', + requiresAuth: false, + mapModel: mapLlmtrModel, + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [ + { + id: 'llmtr-deepseek-v4-flash', + apiName: 'deepseek/deepseek-v4-flash', + aliases: ['deepseek-v4-flash'], + label: 'DeepSeek V4 Flash', + modelDescriptorId: 'deepseek-v4-flash', + contextWindow: 1_000_000, + maxOutputTokens: 393_216, + }, + { + id: 'llmtr-claude-sonnet-4.6', + apiName: 'anthropic/claude-sonnet-4.6', + aliases: ['claude-sonnet-4-6'], + label: 'Claude Sonnet 4.6', + modelDescriptorId: 'claude-sonnet-4-6', + contextWindow: 1_000_000, + maxOutputTokens: 128_000, + }, + { + id: 'llmtr-gpt-5.4', + apiName: 'openai/gpt-5.4', + aliases: ['gpt-5.4'], + label: 'GPT-5.4', + modelDescriptorId: 'gpt-5.4', + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + id: 'llmtr-gemini-3.1-pro-preview', + apiName: 'google/gemini-3.1-pro-preview', + label: 'Gemini 3.1 Pro Preview', + modelDescriptorId: 'google/gemini-3.1-pro-preview', + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }, + { + id: 'llmtr-deepseek-v4-pro', + apiName: 'deepseek/deepseek-v4-pro', + aliases: ['deepseek-v4-pro'], + label: 'DeepSeek V4 Pro', + modelDescriptorId: 'deepseek-v4-pro', + contextWindow: 1_000_000, + maxOutputTokens: 393_216, + }, + { + id: 'llmtr-glm-5.2', + apiName: 'zai/glm-5.2', + aliases: ['glm-5.2'], + label: 'GLM-5.2', + modelDescriptorId: 'glm-5.2', + contextWindow: 1_000_000, + maxOutputTokens: 131_072, + }, + ], +}) diff --git a/src/integrations/gateways/llmtr.test.ts b/src/integrations/gateways/llmtr.test.ts new file mode 100644 index 000000000..d41fb11b1 --- /dev/null +++ b/src/integrations/gateways/llmtr.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from 'bun:test' + +import { routeForPreset } from '../compatibility.js' +import { getProviderPresetUiMetadata } from '../providerUiMetadata.js' +import { + resolveActiveRouteIdFromEnv, + resolveRouteCredentialValue, + resolveRouteIdFromBaseUrl, +} from '../routeMetadata.js' +import catalog, { mapLlmtrModel } from './llmtr.models.js' +import gateway from './llmtr.js' + +test('LLMTR uses the standard OpenAI-compatible gateway contract', () => { + expect(gateway.defaultBaseUrl).toBe('https://llmtr.com/v1') + expect(gateway.defaultModel).toBe('deepseek/deepseek-v4-flash') + expect(gateway.setup.credentialEnvVars).toEqual([ + 'LLMTR_API_KEY', + 'OPENAI_API_KEY', + ]) + expect(gateway.setup.dedicatedCredentialsOnly).not.toBe(true) + expect(gateway.preset?.apiKeyEnvVars).toEqual([ + 'LLMTR_API_KEY', + 'OPENAI_API_KEY', + ]) + expect(gateway.validation).toMatchObject({ + kind: 'credential-env', + routing: { matchDefaultBaseUrl: true }, + credentialEnvVars: [ + 'LLMTR_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + }) + expect(gateway.validation?.routing?.matchBaseUrlHosts).toBeUndefined() + expect(gateway.transportConfig).toEqual({ + kind: 'openai-compatible', + openaiShim: { + requiredApiFormat: 'chat_completions', + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + }) + expect(catalog.source).toBe('hybrid') + expect(catalog.discovery?.requiresAuth).toBe(false) +}) + +test('LLMTR preset uses the existing generic profile path', () => { + expect(routeForPreset('llmtr')).toEqual({ + vendorId: 'openai', + gatewayId: 'llmtr', + routeId: 'llmtr', + }) + expect( + getProviderPresetUiMetadata('llmtr', { + LLMTR_API_KEY: 'llmtr-key', + OPENAI_API_KEY: 'fallback-key', + }), + ).toMatchObject({ + apiKey: 'llmtr-key', + baseUrl: 'https://llmtr.com/v1', + model: 'deepseek/deepseek-v4-flash', + provider: 'llmtr', + routeId: 'llmtr', + }) + + expect( + getProviderPresetUiMetadata('llmtr', { + OPENAI_API_KEY: 'fallback-key', + }).apiKey, + ).toBe('fallback-key') + + expect( + getProviderPresetUiMetadata('llmtr', { + LLMTR_API_KEY: 'SUA_CHAVE', + }).apiKey, + ).toBe('') + expect( + getProviderPresetUiMetadata('llmtr', { + LLMTR_API_KEY: 'SUA_CHAVE', + OPENAI_API_KEY: 'fallback-key', + }).apiKey, + ).toBe('fallback-key') +}) + +test('LLMTR dedicated credentials require an already selected LLMTR route', () => { + const processEnv = { LLMTR_API_KEY: 'llmtr-key' } + + expect(resolveRouteIdFromBaseUrl('https://llmtr.com/v1')).toBe('llmtr') + expect(resolveRouteIdFromBaseUrl('https://llmtr.com/proxy/v1')).toBeNull() + expect( + resolveRouteIdFromBaseUrl('https://llmtr.com/v1?tenant=proxy'), + ).toBeNull() + expect(resolveRouteIdFromBaseUrl('https://llmtr.com/v1#proxy')).toBeNull() + expect( + resolveRouteCredentialValue({ + routeId: 'llmtr', + baseUrl: 'https://llmtr.com/v1', + processEnv, + }), + ).toBe('llmtr-key') + expect( + resolveRouteCredentialValue({ + routeId: 'custom', + baseUrl: 'https://proxy.example/v1', + processEnv, + }), + ).toBeUndefined() + expect( + resolveRouteCredentialValue({ + routeId: 'llmtr', + baseUrl: 'https://proxy.example/v1', + processEnv, + }), + ).toBeUndefined() + expect( + resolveActiveRouteIdFromEnv( + { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://proxy.example/v1', + }, + { + activeProfileProvider: 'llmtr', + activeProfileBaseUrl: 'https://proxy.example/v1', + }, + ), + ).toBe('custom') + expect( + resolveActiveRouteIdFromEnv(processEnv), + ).not.toBe('llmtr') +}) + +test('LLMTR discovery keeps tool-capable Chat Completions models', () => { + expect( + mapLlmtrModel({ + id: 'example/chat-model', + name: 'Chat Model', + context_length: 131_072, + top_provider: { + max_completion_tokens: 32_768, + }, + architecture: { + input_modalities: ['text', 'image'], + }, + reasoning: {}, + supported_parameters: ['tools', 'reasoning'], + supported_operations: ['CHAT_COMPLETIONS'], + supported_endpoints: ['/v1/chat/completions'], + }), + ).toEqual({ + id: 'example/chat-model', + apiName: 'example/chat-model', + label: 'Chat Model', + capabilities: { + supportsFunctionCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }) +}) + +test.each([ + ['Responses-only', ['RESPONSES'], ['/v1/responses'], ['tools']], + ['no tools', ['CHAT_COMPLETIONS'], ['/v1/chat/completions'], []], + ['wrong endpoint', ['CHAT_COMPLETIONS'], ['/v1/responses'], ['tools']], +])('LLMTR discovery rejects %s models', (_label, operations, endpoints, parameters) => { + expect( + mapLlmtrModel({ + id: 'example/incompatible', + supported_operations: operations, + supported_endpoints: endpoints, + supported_parameters: parameters, + }), + ).toBeNull() +}) + +test('LLMTR curated models are a small tool-capable fallback catalog', () => { + expect(catalog.models?.map(model => model.apiName)).toEqual([ + 'deepseek/deepseek-v4-flash', + 'anthropic/claude-sonnet-4.6', + 'openai/gpt-5.4', + 'google/gemini-3.1-pro-preview', + 'deepseek/deepseek-v4-pro', + 'zai/glm-5.2', + ]) +}) diff --git a/src/integrations/gateways/llmtr.ts b/src/integrations/gateways/llmtr.ts new file mode 100644 index 000000000..ba5e94fe8 --- /dev/null +++ b/src/integrations/gateways/llmtr.ts @@ -0,0 +1,49 @@ +import { defineGateway } from '../define.js' +import catalog from './llmtr.models.js' + +export default defineGateway({ + id: 'llmtr', + label: 'LLMTR', + category: 'aggregating', + defaultBaseUrl: 'https://llmtr.com/v1', + defaultModel: 'deepseek/deepseek-v4-flash', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'], + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + requiredApiFormat: 'chat_completions', + supportsAuthHeaders: false, + maxTokensField: 'max_tokens', + }, + }, + preset: { + id: 'llmtr', + description: 'LLMTR OpenAI-compatible multi-model gateway', + vendorId: 'openai', + apiKeyEnvVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + }, + credentialEnvVars: [ + 'LLMTR_API_KEY', + 'OPENAI_API_KEYS', + 'OPENAI_API_KEY', + ], + missingCredentialMessage: + 'LLMTR auth is required. Set LLMTR_API_KEY or OPENAI_API_KEY.', + }, + catalog, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index b4416e27d..261857aeb 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -37,6 +37,7 @@ import gatewayGitlawbOpengateway from '../gateways/gitlawb-opengateway.js' import gatewayGroq from '../gateways/groq.js' import gatewayHicap from '../gateways/hicap.js' import gatewayKimiCode from '../gateways/kimi-code.js' +import gatewayLlmtr from '../gateways/llmtr.js' import gatewayLmstudio from '../gateways/lmstudio.js' import gatewayMistral from '../gateways/mistral.js' import gatewayNvidiaNim from '../gateways/nvidia-nim.js' @@ -91,7 +92,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, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayConcentrate, 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, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, 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 818330267..82fb29592 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -252,6 +252,21 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENAI_MODEL" ] }, + { + "preset": "llmtr", + "routeKind": "gateway", + "routeId": "llmtr", + "vendorId": "openai", + "gatewayId": "llmtr", + "description": "LLMTR OpenAI-compatible multi-model gateway", + "apiKeyEnvVars": [ + "LLMTR_API_KEY", + "OPENAI_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "lmstudio", "routeKind": "gateway", @@ -572,6 +587,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "gemini", "groq", "hicap", + "llmtr", "lmstudio", "atomic-chat", "ollama", diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index e2954745c..5f127fe6b 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -41,7 +41,11 @@ function hasUsableEnvValue(envVar: string, value: string | undefined): boolean { return false } - if (envVar === 'OPENAI_API_KEYS' || envVar === 'OPENAI_API_KEY') { + if ( + envVar === 'OPENAI_API_KEYS' || + envVar === 'OPENAI_API_KEY' || + envVar === 'LLMTR_API_KEY' + ) { return hasUsableOpenAICredential(value) } diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index bba8f8dab..55d5d33f1 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -231,7 +231,8 @@ function hasUsableEnvCredentialValue( envVar === 'OPENAI_API_KEY' || envVar === 'AIMLAPI_API_KEY' || envVar === 'APISMART_API_KEY' || - envVar === 'CONCENTRATE_API_KEY' + envVar === 'CONCENTRATE_API_KEY' || + envVar === 'LLMTR_API_KEY' ) { return hasUsableOpenAICredential(value) } @@ -467,6 +468,34 @@ export function isCanonicalConcentrateInferenceBaseUrl( } } +const LLMTR_CANONICAL_INFERENCE_BASE_URL = 'https://llmtr.com/v1' + +export function isCanonicalLlmtrInferenceBaseUrl( + value: string | undefined, +): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + + try { + const canonical = new URL(LLMTR_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 + } +} + export function getConcentrateBaseUrlOverride( processEnv: NodeJS.ProcessEnv = process.env, ): string | undefined { @@ -1117,6 +1146,13 @@ export function resolveRouteCredentialValue( ) { return undefined } + if ( + routeId === 'llmtr' && + options?.baseUrl !== undefined && + !isCanonicalLlmtrInferenceBaseUrl(options.baseUrl) + ) { + return undefined + } return getRouteCredentialValue(routeId, processEnv) } @@ -1225,6 +1261,16 @@ export function resolveRouteIdFromBaseUrl( normalizedBaseUrl && normalizedDefaultBaseUrl === normalizedBaseUrl ) { + // LLMTR's dedicated credential and fixed transport contract are valid + // only for the exact inference URL. The generic comparable-URL helper + // intentionally ignores query/hash components for other providers, but a + // query-bearing LLMTR URL is a retargeted/custom endpoint. + if ( + route.id === 'llmtr' && + !isCanonicalLlmtrInferenceBaseUrl(baseUrl) + ) { + continue + } return route.id } } @@ -1241,7 +1287,9 @@ export function resolveRouteIdFromBaseUrl( (route.id === 'longcat' && !isLongcatBaseUrl(baseUrl)) || (route.id === 'apismart' && !isApismartBaseUrl(baseUrl)) || (route.id === 'concentrate' && - !isCanonicalConcentrateInferenceBaseUrl(baseUrl)) + !isCanonicalConcentrateInferenceBaseUrl(baseUrl)) || + (route.id === 'llmtr' && + !isCanonicalLlmtrInferenceBaseUrl(baseUrl)) ) { continue } @@ -1281,6 +1329,9 @@ function profileRouteHonorsBaseUrlBoundary( if (routeId === 'apismart') { return isApismartBaseUrl(baseUrl) } + if (routeId === 'llmtr') { + return isCanonicalLlmtrInferenceBaseUrl(baseUrl) + } return true } diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 83695888c..d22279eec 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -293,6 +293,24 @@ describe('resolveModelRuntimeLimits', () => { }) }) +describe('LLMTR runtime attribution', () => { + it('keeps query-bearing endpoints on the generic custom transport', () => { + const result = resolveOpenAIShimRuntimeContext({ + activeProfileProvider: 'llmtr', + baseUrl: 'https://llmtr.com/v1?tenant=proxy', + model: 'proxy-model', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_API_FORMAT: 'responses', + }, + }) + + expect(result.routeId).not.toBe('llmtr') + expect(result.openaiShimConfig.requiredApiFormat).toBeUndefined() + expect(result.openaiShimConfig.maxTokensField).toBeUndefined() + }) +}) + describe('AIMLAPI runtime attribution', () => { it('sends the fixed partner id on the canonical endpoint only', () => { const previous = process.env.AIMLAPI_PARTNER_ID diff --git a/src/services/api/openaiShim/requestExecutor.test.ts b/src/services/api/openaiShim/requestExecutor.test.ts index f41cb6d10..c16aec4f9 100644 --- a/src/services/api/openaiShim/requestExecutor.test.ts +++ b/src/services/api/openaiShim/requestExecutor.test.ts @@ -20,6 +20,7 @@ const originalEnv = { OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, + LLMTR_API_KEY: process.env.LLMTR_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE, @@ -409,19 +410,25 @@ function makeChatCompletionResponse(model: string): Response { async function captureChatCompletionRequest( model = 'mimo-v2.5-pro', -): Promise<{ authorization: string | null; url: string | null }> { + defaultHeaders: Record = {}, +): Promise<{ + authorization: string | null + headers: Record + url: string | null +}> { let authorization: string | null = null + let headers: Record = {} let url: string | null = null globalThis.fetch = (async (input, init) => { url = String(input) - const headers = init?.headers as Record | undefined - authorization = headers?.Authorization ?? headers?.authorization ?? null + headers = (init?.headers as Record | undefined) ?? {} + authorization = headers.Authorization ?? headers.authorization ?? null return makeChatCompletionResponse(model) }) as unknown as FetchType - const client = createOpenAIShimClient({}) as OpenAIShimClient + const client = createOpenAIShimClient({ defaultHeaders }) as OpenAIShimClient await client.beta.messages.create({ model, @@ -430,7 +437,7 @@ async function captureChatCompletionRequest( stream: false, }) - return { authorization, url } + return { authorization, headers, url } } function makeCodexSseResponse(responseData: Record): Response { @@ -444,6 +451,7 @@ beforeEach(async () => { delete process.env.OPENAI_API_BASE process.env.OPENAI_API_KEY = 'test-key' delete process.env.OPENAI_API_KEYS + delete process.env.LLMTR_API_KEY delete process.env.OPENAI_MODEL delete process.env.OPENAI_API_FORMAT delete process.env.OPENAI_AZURE_STYLE @@ -493,6 +501,7 @@ afterEach(() => { restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS) + restoreEnv('LLMTR_API_KEY', originalEnv.LLMTR_API_KEY) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT) restoreEnv('OPENAI_AZURE_STYLE', originalEnv.OPENAI_AZURE_STYLE) @@ -570,6 +579,86 @@ test('Concentrate selection prefers its dedicated key over a generic OPENAI_API_ expect(captured.authorization).toBe('Bearer concentrate-key') }) +test('selected LLMTR route sends LLMTR_API_KEY through the generic route credential resolver', async () => { + process.env.LLMTR_API_KEY = 'llmtr-key' + delete process.env.OPENAI_API_KEYS + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL + + const result = applyProviderFlag('llmtr', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest( + 'deepseek/deepseek-v4-flash', + ) + + expect(captured.url).toBe('https://llmtr.com/v1/chat/completions') + expect(captured.authorization).toBe('Bearer llmtr-key') +}) + +test('selected LLMTR route prefers its dedicated key over a generic OPENAI_API_KEYS pool', async () => { + process.env.LLMTR_API_KEY = 'llmtr-key' + process.env.OPENAI_API_KEYS = 'generic-openai-key-a,generic-openai-key-b' + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL + + const result = applyProviderFlag('llmtr', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest( + 'deepseek/deepseek-v4-flash', + ) + + expect(captured.authorization).toBe('Bearer llmtr-key') +}) + +test('raw-env LLMTR ignores unsupported custom auth and custom headers', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.OPENAI_MODEL = 'deepseek/deepseek-v4-flash' + process.env.LLMTR_API_KEY = 'llmtr-key' + process.env.OPENAI_AUTH_HEADER = 'X-Proxy-Key' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'proxy-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant-Secret: tenant-secret' + delete process.env.OPENAI_API_KEYS + delete process.env.OPENAI_API_KEY + + const captured = await captureChatCompletionRequest( + 'deepseek/deepseek-v4-flash', + { 'X-Tenant-Secret': 'tenant-secret' }, + ) + + expect(captured.url).toBe('https://llmtr.com/v1/chat/completions') + expect(captured.authorization).toBe('Bearer llmtr-key') + expect(captured.headers['X-Proxy-Key']).toBeUndefined() + expect(captured.headers['X-Tenant-Secret']).toBeUndefined() +}) + +test('custom endpoints preserve configured auth and custom headers', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.OPENAI_MODEL = 'proxy-model' + process.env.LLMTR_API_KEY = 'llmtr-key' + process.env.OPENAI_AUTH_HEADER = 'X-Proxy-Key' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'proxy-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant-Secret: tenant-secret' + delete process.env.OPENAI_API_KEYS + delete process.env.OPENAI_API_KEY + + const captured = await captureChatCompletionRequest('proxy-model', { + 'X-Tenant-Secret': 'tenant-secret', + }) + + expect(captured.url).toBe('https://proxy.example/v1/chat/completions') + expect(captured.authorization).toBeNull() + expect(captured.headers['X-Proxy-Key']).toBe('proxy-secret') + expect(captured.headers['X-Tenant-Secret']).toBe('tenant-secret') +}) + test('gitlawb opengateway provider flag uses generic OPENAI_API_KEYS pool before generic OPENAI_API_KEY fallback', async () => { process.env.OPENGATEWAY_BASE_URL = 'http://localhost:8181/v1' process.env.OPENAI_API_KEYS = 'fake-openai-pool-a,fake-openai-pool-b' diff --git a/src/services/api/openaiShim/requestExecutor.ts b/src/services/api/openaiShim/requestExecutor.ts index a1ca2e819..eb4154a04 100644 --- a/src/services/api/openaiShim/requestExecutor.ts +++ b/src/services/api/openaiShim/requestExecutor.ts @@ -5,6 +5,7 @@ import { redactEncodedSecretSubstringsForDisplay, redactSecretSubstringsForDisplay, } from '../../../utils/providerSecrets.js' +import { parseCustomHeadersEnv } from '../../../utils/providerCustomHeaders.js' export function formatRetryAfterHint(response: Response): string { const retryAfter = response.headers.get('retry-after') @@ -240,11 +241,35 @@ export async function executeOpenAIRequest( isGithubCopilot, isGithubModels, } = context + // Existing routes historically accept process-level custom auth even when + // their profile UI hides those controls. LLMTR is the new fixed-contract + // route: enforce its explicit capability without changing that compatibility + // behavior for unrelated providers or generic custom endpoints. + const supportsConfiguredAuthHeaders = + runtimeShimContext.routeId !== 'llmtr' || + runtimeShimContext.openaiShimConfig.supportsAuthHeaders === true + const unsupportedCustomHeaderNames = supportsConfiguredAuthHeaders + ? null + : new Set( + Object.keys( + parseCustomHeadersEnv( + requestProcessEnv.ANTHROPIC_CUSTOM_HEADERS, + ) ?? {}, + ).map(name => name.toLowerCase()), + ) + const filterUnsupportedCustomHeaders = ( + headers: Record | undefined, + ): Record => + Object.fromEntries( + Object.entries(headers ?? {}).filter( + ([name]) => !unsupportedCustomHeaderNames?.has(name.toLowerCase()), + ), + ) const baseHeaders: Record = { 'Content-Type': 'application/json', ...filterAnthropicHeaders(shimConfig.headers), - ...defaultHeaders, - ...filterAnthropicHeaders(options?.headers), + ...filterUnsupportedCustomHeaders(defaultHeaders), + ...filterUnsupportedCustomHeaders(filterAnthropicHeaders(options?.headers)), } const isGemini = isGeminiMode() @@ -292,6 +317,7 @@ export async function executeOpenAIRequest( requestProcessEnv.NEARAI_API_KEY, requestProcessEnv.FIREWORKS_API_KEY, requestProcessEnv.LONGCAT_API_KEY, + requestProcessEnv.LLMTR_API_KEY, ].some((value) => value?.trim() === openAIApiKeyRawUsable), ) const routeCredentialIsCopiedProviderKey = Boolean( @@ -330,7 +356,8 @@ export async function executeOpenAIRequest( const catalogAuthHeader = runtimeShimContext.catalogEntry?.transportOverrides?.openaiShim ?.defaultAuthHeader - const configuredAuthHeaderValue = catalogAuthHeader + const configuredAuthHeaderValue = + catalogAuthHeader || !supportsConfiguredAuthHeaders ? undefined : requestProcessEnv.OPENAI_AUTH_HEADER_VALUE?.trim() if (configuredAuthHeaderValue && /[\r\n]/.test(configuredAuthHeaderValue)) { @@ -338,7 +365,8 @@ export async function executeOpenAIRequest( 'OPENAI_AUTH_HEADER_VALUE must not contain CR/LF characters', ) } - const customAuthHeader = catalogAuthHeader + const customAuthHeader = + catalogAuthHeader || !supportsConfiguredAuthHeaders ? undefined : requestProcessEnv.OPENAI_AUTH_HEADER?.trim() const hasCustomAuthHeader = Boolean( diff --git a/src/utils/envFile.test.ts b/src/utils/envFile.test.ts index b257ae3fb..5d3e44cc1 100644 --- a/src/utils/envFile.test.ts +++ b/src/utils/envFile.test.ts @@ -18,6 +18,7 @@ const TEST_ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', 'CODEX_AUTH_JSON_PATH', 'CODEX_HOME', + 'LLMTR_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', 'OPENAI_API_KEYS', @@ -292,6 +293,17 @@ describe('loadEnvFile', () => { }) }) + it('loads the dedicated LLMTR credential without selecting a route', () => { + const filePath = writeTempEnvFile('LLMTR_API_KEY=llmtr-key') + + const loaded = loadEnvFile(filePath) + + expect(process.env.LLMTR_API_KEY).toBe('llmtr-key') + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBeUndefined() + expect(loaded).toEqual({ LLMTR_API_KEY: 'llmtr-key' }) + }) + it('loads documented Concentrate env-only provider setup values', () => { const filePath = writeTempEnvFile([ 'CONCENTRATE_API_KEY=concentrate-key', diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index f20456b74..029f0eb18 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -77,6 +77,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'JINA_API_KEY', 'KIMI_API_KEY', 'LINKUP_API_KEY', + 'LLMTR_API_KEY', 'LONGCAT_API_KEY', 'MINIMAX_API_KEY', 'MINIMAX_BASE_URL', diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index b94a995c1..c03cbc6b9 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -32,6 +32,7 @@ const ENV_KEYS = [ 'OPENAI_AUTH_HEADER', 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', + 'LLMTR_API_KEY', 'GEMINI_MODEL', 'NVIDIA_API_KEY', 'NVIDIA_NIM', @@ -574,6 +575,71 @@ describe('applyProviderFlag - descriptor-backed openai-compatible routes', () => expect(process.env.OPENAI_BASE_URL).toBe('http://proxy.local:8080/v1') }) + test('LLMTR clears stale custom auth only on its canonical endpoint', () => { + process.env.LLMTR_API_KEY = 'llmtr-key' + process.env.OPENAI_API_FORMAT = 'responses' + process.env.OPENAI_AUTH_HEADER = 'X-Proxy-Key' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'proxy-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant-Secret: tenant-secret' + + const result = applyProviderFlag('llmtr', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://llmtr.com/v1') + 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() + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined() + }) + + test('LLMTR preserves custom endpoint auth settings when the endpoint is explicit', () => { + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.OPENAI_API_FORMAT = 'responses' + process.env.OPENAI_AUTH_HEADER = 'X-Proxy-Key' + process.env.OPENAI_AUTH_SCHEME = 'raw' + process.env.OPENAI_AUTH_HEADER_VALUE = 'proxy-secret' + process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant-Secret: tenant-secret' + + const result = applyProviderFlag('llmtr', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example/v1') + expect(process.env.OPENAI_API_FORMAT).toBe('responses') + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-Proxy-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('proxy-secret') + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( + 'X-Tenant-Secret: tenant-secret', + ) + }) + + test('clears an LLMTR key copied into OPENAI_API_KEY when switching routes', () => { + process.env.LLMTR_API_KEY = 'llmtr-secret' + process.env.OPENAI_API_KEY = 'llmtr-secret' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + + const result = applyProviderFlag('openrouter', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.LLMTR_API_KEY).toBe('llmtr-secret') + }) + + test('preserves an independent generic OPENAI_API_KEY when switching from LLMTR', () => { + process.env.LLMTR_API_KEY = 'llmtr-secret' + process.env.OPENAI_API_KEY = 'generic-openai-secret' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + + const result = applyProviderFlag('openrouter', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') + expect(process.env.OPENAI_API_KEY).toBe('generic-openai-secret') + }) + test('descriptor-backed provider selection preserves custom OPENAI_API_BASE alias', () => { process.env.OPENAI_API_BASE = 'http://proxy.local:8080/v1' process.env.OPENGATEWAY_API_KEY = 'fake-ogw-key' diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index e7ab51bad..665ca52b6 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -32,6 +32,7 @@ import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js' import { isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, + isCanonicalLlmtrInferenceBaseUrl, } from '../integrations/routeMetadata.js' import { hasUsableOpenAICredential } from '../services/api/credentialPool.js' import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js' @@ -364,6 +365,9 @@ export function applyProviderFlag( : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.CONCENTRATE_API_KEY ? 'concentrate' + : process.env.OPENAI_API_KEY !== undefined && + process.env.OPENAI_API_KEY === process.env.LLMTR_API_KEY + ? 'llmtr' : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.NEARAI_API_KEY ? 'nearai' @@ -837,6 +841,13 @@ export function applyProviderFlag( default: process.env.CLAUDE_CODE_USE_OPENAI = '1' applyOpenAIBaseUrlDefault(provider, defaultBaseUrl) + if ( + provider === 'llmtr' && + isCanonicalLlmtrInferenceBaseUrl(getConfiguredOpenAIBaseUrl()) + ) { + clearUnsupportedOpenAIShimSettings('llmtr') + delete process.env.ANTHROPIC_CUSTOM_HEADERS + } if (defaultModel) { process.env.OPENAI_MODEL ??= defaultModel } diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 2f252bc72..0b0665409 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -3148,6 +3148,44 @@ test('openai launch withholds ambient Concentrate credentials from a keyless pro assert.equal(canonical.CONCENTRATE_API_KEY, 'ambient-concentrate-key') }) +test('openai launch withholds ambient LLMTR credentials from a keyless proxy profile on restart', async () => { + const env = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'llmtr', + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_MODEL: 'proxy-model', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://proxy.example.com/v1', + OPENAI_API_KEY: 'ambient-llmtr-key', + LLMTR_API_KEY: 'ambient-llmtr-key', + }, + }) + + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'llmtr') + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.LLMTR_API_KEY, undefined) + + const canonical = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'llmtr', + OPENAI_BASE_URL: 'https://llmtr.com/v1', + OPENAI_MODEL: 'deepseek/deepseek-v4-flash', + }), + goal: 'coding', + processEnv: { + OPENAI_BASE_URL: 'https://llmtr.com/v1', + LLMTR_API_KEY: 'ambient-llmtr-key', + }, + }) + + assert.equal(canonical.OPENAI_API_KEY, undefined) + assert.equal(canonical.LLMTR_API_KEY, 'ambient-llmtr-key') +}) + test('openai launch removes a legacy persisted Concentrate key from a noncanonical URL', async () => { const env = await buildLaunchEnv({ profile: 'openai', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index d4d6ec7f2..6f868f2d9 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -27,6 +27,7 @@ import { getRouteDefaultModel, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, + isCanonicalLlmtrInferenceBaseUrl, isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, resolveRouteCredentialValue, @@ -118,6 +119,7 @@ const PROFILE_ENV_KEYS = [ 'NEARAI_API_KEY', 'FIREWORKS_API_KEY', 'LONGCAT_API_KEY', + 'LLMTR_API_KEY', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', 'CONCENTRATE_MODEL', @@ -208,6 +210,7 @@ export type ProfileEnv = { NEARAI_API_KEY?: string FIREWORKS_API_KEY?: string LONGCAT_API_KEY?: string + LLMTR_API_KEY?: string CONCENTRATE_API_KEY?: string CONCENTRATE_BASE_URL?: string CONCENTRATE_MODEL?: string @@ -2147,8 +2150,15 @@ export async function buildLaunchEnv(options: { effectiveOpenAIRouteId === 'concentrate' && !!env.OPENAI_BASE_URL?.trim() && !isCanonicalConcentrateInferenceBaseUrl(env.OPENAI_BASE_URL) + const isNoncanonicalLlmtrLaunch = + effectiveOpenAIRouteId === 'llmtr' && + !!env.OPENAI_BASE_URL?.trim() && + !isCanonicalLlmtrInferenceBaseUrl(env.OPENAI_BASE_URL) const isNoncanonicalDedicatedOpenAILaunch = - isNoncanonicalAimlapiLaunch || isNoncanonicalApismartLaunch || isNoncanonicalConcentrateLaunch + isNoncanonicalAimlapiLaunch || + isNoncanonicalApismartLaunch || + isNoncanonicalConcentrateLaunch || + isNoncanonicalLlmtrLaunch if (isNoncanonicalDedicatedOpenAILaunch) { delete env.OPENAI_API_KEY delete env.OPENAI_API_KEYS @@ -2157,7 +2167,7 @@ export async function buildLaunchEnv(options: { // dedicated credential is never valid off the canonical endpoint, and // older profiles could have stored that same secret under either generic // alias. Do not resurrect it for a noncanonical Concentrate launch. - if (!isNoncanonicalConcentrateLaunch) { + if (!isNoncanonicalConcentrateLaunch && !isNoncanonicalLlmtrLaunch) { const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv) if (persistedCredential) { env[persistedCredential.envVar] = persistedCredential.value @@ -2190,6 +2200,7 @@ export async function buildLaunchEnv(options: { 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'CONCENTRATE_API_KEY', + 'LLMTR_API_KEY', 'NEARAI_API_KEY', 'FIREWORKS_API_KEY', 'LONGCAT_API_KEY', @@ -2211,6 +2222,9 @@ export async function buildLaunchEnv(options: { if (dedicatedKey === 'CONCENTRATE_API_KEY' && effectiveOpenAIRouteId !== 'concentrate') { continue } + if (dedicatedKey === 'LLMTR_API_KEY' && effectiveOpenAIRouteId !== 'llmtr') { + continue + } if (dedicatedKey === 'NVIDIA_API_KEY' && effectiveOpenAIRouteId !== 'nvidia-nim') { continue } @@ -2238,14 +2252,21 @@ export async function buildLaunchEnv(options: { dedicatedKey === 'CONCENTRATE_API_KEY' && !!dedicatedBaseUrl && !isCanonicalConcentrateInferenceBaseUrl(dedicatedBaseUrl) + const withholdAmbientLlmtrKey = + dedicatedKey === 'LLMTR_API_KEY' && + !!dedicatedBaseUrl && + !isCanonicalLlmtrInferenceBaseUrl(dedicatedBaseUrl) const withholdAmbientDedicatedKey = - withholdAmbientAimlapiKey || withholdAmbientApismartKey || withholdAmbientConcentrateKey + withholdAmbientAimlapiKey || + withholdAmbientApismartKey || + withholdAmbientConcentrateKey || + withholdAmbientLlmtrKey // Unlike the generic proxy-compatible routes above, Concentrate's // dedicated key is never valid outside its canonical inference endpoint. // Do not preserve a legacy persisted key for a retargeted Concentrate // profile: older versions could have serialized one before this boundary // was enforced. - if (withholdAmbientConcentrateKey) { + if (withholdAmbientConcentrateKey || withholdAmbientLlmtrKey) { continue } // AIMLAPI accepts generic OpenAI credentials, but ApiSmart is @@ -2278,9 +2299,23 @@ export async function buildLaunchEnv(options: { persistedOpenAICredential?.kind === 'usable' ? sanitizeApiKey(persistedOpenAICredential.value) : undefined + // A selected canonical LLMTR profile owns its saved credential. Prefer it + // over an unrelated ambient LLMTR_API_KEY on restart, and migrate startup + // files written before LLMTR_API_KEY was persisted explicitly. + const persistedLlmtrProfileKey = + dedicatedKey === 'LLMTR_API_KEY' && + effectiveOpenAIRouteId === 'llmtr' && + !!dedicatedBaseUrl && + isCanonicalLlmtrInferenceBaseUrl(dedicatedBaseUrl) + ? sanitizeApiKey(persistedEnv.LLMTR_API_KEY) || + (persistedOpenAICredential?.kind === 'usable' + ? sanitizeApiKey(persistedOpenAICredential.value) + : undefined) + : undefined const dedicatedValue = withholdAmbientDedicatedKey ? sanitizeApiKey(persistedEnv[dedicatedKey]) : backfillDedicatedFromOpenAI || + persistedLlmtrProfileKey || sanitizeApiKey(processEnv[dedicatedKey]) || sanitizeApiKey(persistedEnv[dedicatedKey]) || backfillLegacyApismartProfileKey || @@ -2390,10 +2425,16 @@ export async function buildStartupEnvFromProfile(options?: { persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'concentrate' && !!persisted.env.OPENAI_BASE_URL?.trim() && !isCanonicalConcentrateInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) + const persistedLlmtrProxy = + persisted?.profile === 'openai' && + persisted.env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'llmtr' && + !!persisted.env.OPENAI_BASE_URL?.trim() && + !isCanonicalLlmtrInferenceBaseUrl(persisted.env.OPENAI_BASE_URL) if ( hasConcreteProviderSelection(processEnv) && !persistedApismartProxy && - !persistedConcentrateProxy + !persistedConcentrateProxy && + !persistedLlmtrProxy ) { return processEnv } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index d2d6f43cf..a1f682f21 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' +import { resolveRouteCredentialValue } from '../integrations/routeMetadata.js' import type { ProviderProfile } from './config.js' async function importFreshProvidersModule() { @@ -72,6 +73,7 @@ const RESTORED_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'APISMART_API_KEY', 'APISMART_MODEL', + 'LLMTR_API_KEY', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', 'CONCENTRATE_MODEL', @@ -292,6 +294,17 @@ function buildConcentrateProfile(overrides: Partial = {}): Prov }) } +function buildLlmtrProfile(overrides: Partial = {}): ProviderProfile { + return buildProfile({ + provider: 'llmtr', + name: 'LLMTR', + baseUrl: 'https://llmtr.com/v1', + model: 'deepseek/deepseek-v4-flash', + apiKey: 'llmtr-test-key', + ...overrides, + }) +} + function buildClinePassProfile(overrides: Partial = {}): ProviderProfile { return buildProfile({ provider: 'clinepass', @@ -317,6 +330,50 @@ function buildCloudflareProfile(overrides: Partial = {}): Provi } describe('applyProviderProfileToProcessEnv', () => { + test('LLMTR profile clears an ambient dedicated key so its saved key wins', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.LLMTR_API_KEY = 'ambient-old' + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'llmtr', + name: 'LLMTR', + baseUrl: 'https://llmtr.com/v1', + model: 'deepseek/deepseek-v4-flash', + apiKey: 'selected-new', + }), + ) + + expect(process.env.LLMTR_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBe('selected-new') + expect( + resolveRouteCredentialValue({ + routeId: 'llmtr', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('selected-new') + }, 20_000) + + test('keyless canonical LLMTR profile adopts its ambient dedicated key', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.LLMTR_API_KEY = 'ambient-llmtr-key' + + applyProviderProfileToProcessEnv(buildLlmtrProfile({ apiKey: undefined })) + + expect(process.env.LLMTR_API_KEY).toBe('ambient-llmtr-key') + expect(process.env.OPENAI_API_KEY).toBe('ambient-llmtr-key') + expect( + resolveRouteCredentialValue({ + routeId: 'llmtr', + baseUrl: process.env.OPENAI_BASE_URL, + processEnv: process.env, + }), + ).toBe('ambient-llmtr-key') + }, 20_000) + test('applies Azure-style routing from a saved OpenAI-compatible profile', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -1802,6 +1859,87 @@ describe('applyProviderProfileToProcessEnv', () => { expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6') expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBeUndefined() }) + + test('retargeted LLMTR profile withholds its dedicated credential', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildLlmtrProfile({ 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.LLMTR_API_KEY).toBeUndefined() + }) + + test('query-bearing LLMTR profiles retain generic proxy capabilities', async () => { + const { addProviderProfile, applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + process.env.LLMTR_API_KEY = 'ambient-llmtr-key' + + const saved = addProviderProfile({ + provider: 'llmtr', + name: 'LLMTR query proxy', + baseUrl: 'https://llmtr.com/v1?tenant=proxy', + model: 'proxy-model', + apiKey: 'saved-llmtr-key', + apiFormat: 'responses', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-auth-value', + customHeaders: { 'X-Proxy-Trace': 'enabled' }, + }) + + applyProviderProfileToProcessEnv(saved!) + + expect(process.env.OPENAI_API_FORMAT).toBe('responses') + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-Proxy-Key') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('proxy-auth-value') + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( + 'X-Proxy-Trace: enabled', + ) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.LLMTR_API_KEY).toBeUndefined() + }) + + test('retargeted LLMTR profiles retain generic proxy capabilities', async () => { + const { addProviderProfile, applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + const saved = addProviderProfile({ + provider: 'llmtr', + name: 'LLMTR proxy', + baseUrl: 'https://proxy.example/v1', + model: 'proxy-model', + apiKey: 'llmtr-test-key', + apiFormat: 'responses', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-auth-value', + customHeaders: { 'X-Proxy-Trace': 'enabled' }, + }) + + expect(saved).toMatchObject({ + apiFormat: 'responses', + authHeader: 'X-Proxy-Key', + authScheme: 'raw', + authHeaderValue: 'proxy-auth-value', + customHeaders: { 'X-Proxy-Trace': 'enabled' }, + }) + + applyProviderProfileToProcessEnv(saved!) + + expect(process.env.OPENAI_API_FORMAT).toBe('responses') + expect(process.env.OPENAI_AUTH_HEADER).toBe('X-Proxy-Key') + expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw') + expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('proxy-auth-value') + expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBe( + 'X-Proxy-Trace: enabled', + ) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.LLMTR_API_KEY).toBeUndefined() + }) }) describe('getProviderProfiles', () => { @@ -1918,6 +2056,7 @@ describe('clearActiveProviderProfile', () => { expect(process.env.OPENAI_BASE_URL).toBeUndefined() expect(process.env.OPENAI_API_KEY).toBeUndefined() }) + }) describe('Anthropic sentinel survives profile management (#1426)', () => { @@ -3615,6 +3754,66 @@ describe('setActiveProviderProfile', () => { } }) + test('keyed canonical LLMTR profiles persist their saved dedicated credential across restart', 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 llmtrProfile = buildLlmtrProfile({ id: 'llmtr_profile' }) + + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [llmtrProfile], + })) + + const result = setActiveProviderProfile('llmtr_profile', { configDir }) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + + expect(result?.id).toBe('llmtr_profile') + expect(persisted.profile).toBe('openai') + expect(persisted.env).toMatchObject({ + OPENAI_BASE_URL: 'https://llmtr.com/v1', + OPENAI_MODEL: 'deepseek/deepseek-v4-flash', + OPENAI_API_KEY: 'llmtr-test-key', + LLMTR_API_KEY: 'llmtr-test-key', + }) + + const { buildStartupEnvFromProfile } = await import( + `./providerProfile.js?ts=${Date.now()}-${Math.random()}` + ) + const startupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: { + LLMTR_API_KEY: 'ambient-unrelated-key', + }, + }) + + expect(startupEnv.OPENAI_API_KEY).toBe('llmtr-test-key') + expect(startupEnv.LLMTR_API_KEY).toBe('llmtr-test-key') + + delete persisted.env.LLMTR_API_KEY + const migratedStartupEnv = await buildStartupEnvFromProfile({ + persisted, + processEnv: { + LLMTR_API_KEY: 'ambient-unrelated-key', + }, + }) + + expect(migratedStartupEnv.OPENAI_API_KEY).toBe('llmtr-test-key') + expect(migratedStartupEnv.LLMTR_API_KEY).toBe('llmtr-test-key') + } 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 52a73eacc..4fe1a6693 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -57,6 +57,7 @@ import { isClinePassBaseUrl, isCanonicalApismartInferenceBaseUrl, isCanonicalConcentrateInferenceBaseUrl, + isCanonicalLlmtrInferenceBaseUrl, isFireworksBaseUrl, isLongcatBaseUrl, isNearaiBaseUrl, @@ -175,6 +176,15 @@ function isConcentrateProfile(profile: ProviderProfile): boolean { return !baseUrl || isCanonicalConcentrateInferenceBaseUrl(baseUrl) } +function isLlmtrProfile(profile: ProviderProfile): boolean { + const { route } = resolveProfileCompatibility(profile.provider) + if (route.routeId !== 'llmtr') { + return false + } + const baseUrl = profile.baseUrl?.trim() + return !baseUrl || isCanonicalLlmtrInferenceBaseUrl(baseUrl) +} + function deriveGithubEnterpriseUrl(baseUrl: string | undefined): string | undefined { if (!baseUrl?.trim()) return undefined try { @@ -239,7 +249,7 @@ function normalizeBaseUrl(value: string): string { return trimValue(value).replace(/\/+$/, '') } -function resolveProfileCapabilityRouteId( +export function resolveProfileCapabilityRouteId( provider: string, baseUrl?: string, ): string { @@ -259,13 +269,16 @@ function resolveProfileCapabilityRouteId( if ( (providerRouteId === 'cloudflare' || providerRouteId === 'longcat' || - providerRouteId === 'concentrate') && + providerRouteId === 'concentrate' || + providerRouteId === 'llmtr') && baseUrl && !(providerRouteId === 'cloudflare' ? isCloudflareBaseUrl(baseUrl) : providerRouteId === 'longcat' ? isLongcatBaseUrl(baseUrl) - : isCanonicalConcentrateInferenceBaseUrl(baseUrl)) + : providerRouteId === 'concentrate' + ? isCanonicalConcentrateInferenceBaseUrl(baseUrl) + : isCanonicalLlmtrInferenceBaseUrl(baseUrl)) ) { return 'custom' } @@ -1023,10 +1036,13 @@ export function applyProviderProfileToProcessEnv( route.routeId === 'apismart' && !isApismartProfile(profile) const withholdRetargetedConcentrateCredential = route.routeId === 'concentrate' && !isConcentrateProfile(profile) + const withholdRetargetedLlmtrCredential = + route.routeId === 'llmtr' && !isLlmtrProfile(profile) if ( profile.apiKey && !withholdRetargetedApismartCredential && - !withholdRetargetedConcentrateCredential + !withholdRetargetedConcentrateCredential && + !withholdRetargetedLlmtrCredential ) { openAIProfileEnv.OPENAI_API_KEY = profile.apiKey if (route.vendorId === 'minimax' || normalizedProfileBaseUrl.toLowerCase().includes('minimax')) { @@ -1148,6 +1164,22 @@ export function applyProviderProfileToProcessEnv( } } } + // A keyless canonical LLMTR profile may use the dedicated credential from + // the ambient environment. Profile application clears all managed provider + // variables before installing this object, so adopt it here while the + // canonical endpoint boundary is still known. Never carry it to a + // retargeted profile. + if (route.routeId === 'llmtr') { + openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'llmtr' + if (isLlmtrProfile(profile) && !profile.apiKey) { + const ambientLlmtrKey = sanitizeApiKey(process.env.LLMTR_API_KEY) + if (ambientLlmtrKey) { + openAIProfileEnv.OPENAI_API_KEY = + openAIProfileEnv.OPENAI_API_KEY ?? ambientLlmtrKey + openAIProfileEnv.LLMTR_API_KEY = ambientLlmtrKey + } + } + } if (route.gatewayId === 'nvidia-nim') { openAIProfileEnv.NVIDIA_NIM = '1' } @@ -1436,15 +1468,19 @@ function buildOpenAICompatibleStartupEnv( const withholdRetargetedConcentrateCredential = activeProfileRouteId === 'concentrate' && !isConcentrateProfile(activeProfile) + const withholdRetargetedLlmtrCredential = + activeProfileRouteId === 'llmtr' && !isLlmtrProfile(activeProfile) const isAimlapiProfile = activeProfile.provider === 'aimlapi' || resolveRouteIdFromBaseUrl(activeProfile.baseUrl) === 'aimlapi' const isConcentrateProfileFlag = isConcentrateProfile(activeProfile) + const isLlmtrProfileFlag = isLlmtrProfile(activeProfile) if ( activeProfile.apiKey && !withholdRetargetedApismartCredential && - !withholdRetargetedConcentrateCredential + !withholdRetargetedConcentrateCredential && + !withholdRetargetedLlmtrCredential ) { const strictEnv = buildOpenAIProfileEnv({ goal: 'balanced', @@ -1476,6 +1512,9 @@ function buildOpenAICompatibleStartupEnv( if (isConcentrateProfileFlag) { strictEnv.CONCENTRATE_API_KEY = activeProfile.apiKey } + if (isLlmtrProfileFlag) { + strictEnv.LLMTR_API_KEY = activeProfile.apiKey + } if (isClinePassProfile(activeProfile)) { strictEnv.CLINE_API_KEY = activeProfile.apiKey } @@ -1533,10 +1572,14 @@ function buildOpenAICompatibleStartupEnv( if (activeProfileRouteId === 'concentrate') { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'concentrate' } + if (activeProfileRouteId === 'llmtr') { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'llmtr' + } if ( activeProfile.apiKey && !withholdRetargetedApismartCredential && - !withholdRetargetedConcentrateCredential + !withholdRetargetedConcentrateCredential && + !withholdRetargetedLlmtrCredential ) { env.OPENAI_API_KEY = activeProfile.apiKey if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) { @@ -1566,6 +1609,9 @@ function buildOpenAICompatibleStartupEnv( if (isConcentrateProfileFlag) { env.CONCENTRATE_API_KEY = activeProfile.apiKey } + if (isLlmtrProfileFlag) { + env.LLMTR_API_KEY = activeProfile.apiKey + } if (isClinePassProfile(activeProfile)) { env.CLINE_API_KEY = activeProfile.apiKey } @@ -1820,6 +1866,9 @@ function triggerStartupDiscoveryRefreshForProfile( if (route.routeId === 'apismart' && !isApismartProfile(profile)) { return } + if (route.routeId === 'llmtr' && !isLlmtrProfile(profile)) { + return + } void refreshStartupDiscoveryForRoute(route.routeId, { baseUrl: profile.baseUrl, diff --git a/src/utils/providerStartupOverrides.test.ts b/src/utils/providerStartupOverrides.test.ts index 18fd38613..fd0d8c45e 100644 --- a/src/utils/providerStartupOverrides.test.ts +++ b/src/utils/providerStartupOverrides.test.ts @@ -20,6 +20,7 @@ describe('clearStartupProviderOverrides', () => { OPENAI_MODEL: 'minimax-m2.7', OPENAI_API_KEYS: 'pool-a,pool-b', OPENAI_API_KEY: 'single-key', + LLMTR_API_KEY: 'llmtr-key', MINIMAX_API_KEY: 'sk-minimax', VENICE_API_KEY: 'sk-venice', LONGCAT_API_KEY: 'sk-longcat', @@ -44,6 +45,7 @@ describe('clearStartupProviderOverrides', () => { OPENAI_MODEL: undefined, OPENAI_API_KEYS: undefined, OPENAI_API_KEY: undefined, + LLMTR_API_KEY: undefined, MINIMAX_API_KEY: undefined, VENICE_API_KEY: undefined, LONGCAT_API_KEY: undefined, diff --git a/src/utils/providerStartupOverrides.ts b/src/utils/providerStartupOverrides.ts index 06409dad9..c8e1fc787 100644 --- a/src/utils/providerStartupOverrides.ts +++ b/src/utils/providerStartupOverrides.ts @@ -14,6 +14,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [ 'OPENAI_MODEL', 'OPENAI_API_KEYS', 'OPENAI_API_KEY', + 'LLMTR_API_KEY', 'OPENAI_ORG', 'OPENAI_PROJECT', 'OPENAI_ORGANIZATION', diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 68337820e..2d663c48f 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', + 'LLMTR_API_KEY', 'APISMART_API_KEY', 'CONCENTRATE_API_KEY', 'CONCENTRATE_BASE_URL', @@ -145,6 +146,73 @@ test('openai missing key error includes recovery guidance and config locations', expect(message!).toContain('Saved startup settings can come from') }) +test('LLMTR validation accepts its dedicated credential on the selected route', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.OPENAI_MODEL = 'deepseek/deepseek-v4-flash' + process.env.LLMTR_API_KEY = 'llmtr-key' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('LLMTR validation rejects placeholder dedicated credentials', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.LLMTR_API_KEY = 'SUA_CHAVE' + + await expect(getProviderValidationError(process.env)).resolves.toBe( + 'LLMTR auth is required. Set LLMTR_API_KEY or OPENAI_API_KEY.', + ) +}) + +test('LLMTR validation rejects an invalid generic fallback credential', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.OPENAI_API_KEY = 'SUA_CHAVE' + + await expect(getProviderValidationError(process.env)).resolves.toBe( + 'LLMTR auth is required. Set LLMTR_API_KEY or OPENAI_API_KEY.', + ) +}) + +test('LLMTR validation accepts a dedicated credential despite an invalid generic fallback', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.LLMTR_API_KEY = 'llmtr-key' + process.env.OPENAI_API_KEY = 'SUA_CHAVE' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('LLMTR validation falls back from a placeholder dedicated key to OPENAI_API_KEY', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.OPENAI_MODEL = 'deepseek/deepseek-v4-flash' + process.env.LLMTR_API_KEY = 'SUA_CHAVE' + process.env.OPENAI_API_KEY = 'llmtr-fallback-key' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('LLMTR validation falls back from a placeholder dedicated key to OPENAI_API_KEYS', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://llmtr.com/v1' + process.env.OPENAI_MODEL = 'deepseek/deepseek-v4-flash' + process.env.LLMTR_API_KEY = 'SUA_CHAVE' + process.env.OPENAI_API_KEYS = 'llmtr-pool-key-a,llmtr-pool-key-b' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('LLMTR_API_KEY does not authenticate an unrelated OpenAI-compatible route', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://proxy.example/v1' + process.env.LLMTR_API_KEY = 'llmtr-key' + + const message = await getProviderValidationError(process.env) + expect(message).toContain('OPENAI_API_KEYS or OPENAI_API_KEY is required') +}) + test('cloudflare Workers AI URL selects the Cloudflare validation target', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 963287bbd..df1928ecc 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -142,7 +142,8 @@ function hasUsableCredentialEnvValue( envVar === 'OPENAI_API_KEY' || envVar === 'AIMLAPI_API_KEY' || envVar === 'APISMART_API_KEY' || - envVar === 'CONCENTRATE_API_KEY' + envVar === 'CONCENTRATE_API_KEY' || + envVar === 'LLMTR_API_KEY' ) { return hasUsableOpenAICredential(value) } @@ -344,11 +345,21 @@ function getCredentialEnvValidationError( if (usesOpenAIFallback) { const openAIState = resolveOpenAICredentialEnvState(env) if (openAIState.invalid) { - return ( - validation.invalidCredentialValues?.find( - invalidValue => invalidValue.envVar === openAIState.envVar, - )?.message ?? null + const hasUsableDedicatedCredential = credentialEnvVars.some( + envVar => + envVar !== 'OPENAI_API_KEYS' && + envVar !== 'OPENAI_API_KEY' && + hasUsableCredentialEnvValue(env, envVar), ) + if (!hasUsableDedicatedCredential) { + return ( + validation.invalidCredentialValues?.find( + invalidValue => invalidValue.envVar === openAIState.envVar, + )?.message ?? + validation.missingCredentialMessage ?? + null + ) + } } } diff --git a/src/utils/swarm/spawnUtils.test.ts b/src/utils/swarm/spawnUtils.test.ts index 1a48b6077..6a05448e7 100644 --- a/src/utils/swarm/spawnUtils.test.ts +++ b/src/utils/swarm/spawnUtils.test.ts @@ -42,6 +42,16 @@ test('buildInheritedEnvVars forwards pooled OpenAI credentials', () => { expect(envVars).toContain('OPENAI_API_KEYS=key-a\\,key-b') }) +test('buildInheritedEnvVars forwards an LLMTR credential without inventing route state', () => { + process.env.LLMTR_API_KEY = 'llmtr-key' + + const envVars = buildInheritedEnvVars() + + expect(envVars).toContain('LLMTR_API_KEY=llmtr-key') + expect(envVars).not.toContain('CLAUDE_CODE_USE_OPENAI=1') + expect(envVars).not.toContain('OPENAI_BASE_URL=') +}) + test('buildInheritedEnvVars forwards PATH for source-built teammate tool lookups', () => { process.env.PATH = '/custom/bin:/usr/bin' diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index fed53bdb9..43e831ec7 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -109,6 +109,7 @@ const TEAMMATE_ENV_VARS = [ 'GH_TOKEN', 'OPENAI_API_KEYS', 'OPENAI_API_KEY', + 'LLMTR_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'GEMINI_API_KEY', diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 93f7f64a3..70c373763 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -140,6 +140,14 @@ export const providers: Provider[] = [ envVars: ['OPENROUTER_API_KEY'], notes: 'OpenAI-compatible aggregation across hundreds of hosted models.', }, + { + id: 'llmtr', + name: 'LLMTR', + group: 'gateways', + setup: '/provider or OpenAI-compatible env vars', + envVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'], + notes: '/provider and --provider llmtr default to deepseek/deepseek-v4-flash; raw env setup must set OPENAI_BASE_URL=https://llmtr.com/v1 and OPENAI_MODEL. Uses public discovery of tool-capable chat models.', + }, { id: 'near-ai', name: 'NEAR AI',