diff --git a/.env.example b/.env.example index 236aee8d4c..5d5aad5970 100644 --- a/.env.example +++ b/.env.example @@ -380,7 +380,23 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # ----------------------------------------------------------------------------- -# Option 13: ClinePass +# Option 13: LongCat +# ----------------------------------------------------------------------------- +# LongCat is Meituan's OpenAI-compatible API for LongCat-2.0. +# Get your API key from https://longcat.chat/platform/api_keys +# Docs: https://longcat.chat/platform/docs/ +# +# CLAUDE_CODE_USE_OPENAI=1 +# LONGCAT_API_KEY=your-longcat-key-here +# OPENAI_BASE_URL=https://api.longcat.chat/openai/v1 +# OPENAI_MODEL=LongCat-2.0 +# +# Or run: openclaude --provider longcat +# Or configure interactively with /provider + + +# ----------------------------------------------------------------------------- +# Option 14: ClinePass # ----------------------------------------------------------------------------- # ClinePass is an AI model gateway with usage limits (5hr, weekly, monthly). # Get your API key from https://app.cline.bot/dashboard/subscription?personal=true diff --git a/README.md b/README.md index 98e5211d23..ac74993adf 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ Advanced and source-build guides: | 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 | | 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` | | ClinePass | `/provider` or env vars | AI model gateway with usage limits (5hr, weekly, monthly); uses `CLINE_API_KEY` at `https://api.cline.bot/api/v1` | | Gemini | `/provider` or env vars | Supports API key only | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials | diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index e487ea56ed..6d1fd96b75 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -142,6 +142,7 @@ const PRESET_ORDER = [ 'LM Studio', 'Atomic Chat', 'Ollama', + 'LongCat', 'MiniMax', 'Mistral AI', 'Moonshot AI - API', diff --git a/src/integrations/brands/longcat.ts b/src/integrations/brands/longcat.ts new file mode 100644 index 0000000000..2951c035c3 --- /dev/null +++ b/src/integrations/brands/longcat.ts @@ -0,0 +1,15 @@ +import { defineBrand } from '../define.js' + +export default defineBrand({ + id: 'longcat', + label: 'LongCat', + canonicalVendorId: 'longcat', + defaultCapabilities: { + supportsStreaming: true, + supportsFunctionCalling: false, + supportsJsonMode: false, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + modelIds: ['LongCat-2.0'], +}) diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 5431802f37..9a63d7e38b 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -50,6 +50,7 @@ const EXPECTED_PRESETS = [ 'gitlawb-opengateway', 'nearai', 'fireworks', + 'longcat', 'opencode', 'opencode-go', 'clinepass', diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index 5583069aa4..29b1e39824 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -43,6 +43,8 @@ export interface OpenAIShimTransportConfig { enableToolStreaming?: boolean maxTokensField?: OpenAIShimTokenField removeBodyFields?: string[] + /** Whether the endpoint accepts image content parts. Defaults to true. */ + supportsImageInputs?: boolean /** Override the endpoint path for this model (e.g., '/responses', '/messages'). */ endpointPath?: string } diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 3446351ec5..3f8931b9f5 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -10,6 +10,7 @@ import vendorBankr from '../vendors/bankr.js' import vendorDeepseek from '../vendors/deepseek.js' import vendorFireworks from '../vendors/fireworks.js' import vendorGemini from '../vendors/gemini.js' +import vendorLongcat from '../vendors/longcat.js' import vendorMinimax from '../vendors/minimax.js' import vendorMoonshot from '../vendors/moonshot.js' import vendorNearai from '../vendors/nearai.js' @@ -53,6 +54,7 @@ import brandGlm from '../brands/glm.js' import brandGpt from '../brands/gpt.js' import brandKimi from '../brands/kimi.js' import brandLlama from '../brands/llama.js' +import brandLongcat from '../brands/longcat.js' import brandMinimax from '../brands/minimax.js' import brandMistral from '../brands/mistral.js' import brandNearai from '../brands/nearai.js' @@ -70,6 +72,7 @@ import modelGlm from '../models/glm.js' import modelGpt from '../models/gpt.js' import modelKimi from '../models/kimi.js' import modelLlama from '../models/llama.js' +import modelLongcat from '../models/longcat.js' import modelMinimax from '../models/minimax.js' import modelMistral from '../models/mistral.js' import modelNearai from '../models/nearai.js' @@ -81,11 +84,11 @@ import modelTencent from '../models/tencent.js' import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' -export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] +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 ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[] -export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, 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, modelLlama, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] +export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandLongcat, 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, modelLlama, modelLongcat, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[] export { PROVIDER_PRESET_MANIFEST, ORDERED_PROVIDER_PRESETS } from './integrationManifest.generated.js' diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index 7118f3217b..b8af4ab412 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": "longcat", + "routeKind": "vendor", + "routeId": "longcat", + "vendorId": "longcat", + "description": "LongCat OpenAI-compatible API (Meituan)", + "label": "LongCat", + "name": "LongCat", + "apiKeyEnvVars": [ + "LONGCAT_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "minimax", "routeKind": "vendor", @@ -525,6 +540,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "lmstudio", "atomic-chat", "ollama", + "longcat", "minimax", "mistral", "moonshotai", diff --git a/src/integrations/index.ts b/src/integrations/index.ts index fb863f4077..bf0618c99f 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -139,6 +139,7 @@ export { getRouteProviderTypeLabel, getTransportKindForRoute, isCloudflareBaseUrl, + isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, diff --git a/src/integrations/models/longcat.ts b/src/integrations/models/longcat.ts new file mode 100644 index 0000000000..e75d3842e9 --- /dev/null +++ b/src/integrations/models/longcat.ts @@ -0,0 +1,21 @@ +import { defineModel } from '../define.js' + +export default [ + defineModel({ + id: 'LongCat-2.0', + label: 'LongCat-2.0', + brandId: 'longcat', + vendorId: 'longcat', + classification: ['chat', 'reasoning', 'coding'], + defaultModel: 'LongCat-2.0', + capabilities: { + supportsStreaming: true, + supportsFunctionCalling: false, + supportsJsonMode: false, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 131_072, + }), +] diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 75517d45f6..bce9124b04 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -7,6 +7,7 @@ import { getRouteDefaultModel, getRouteProviderTypeLabel, isCloudflareBaseUrl, + isLongcatBaseUrl, resolveActiveRouteIdFromEnv, resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, @@ -67,6 +68,28 @@ test('isCloudflareBaseUrl matches Workers AI host but not the shared AI Gateway' ).toBe(false) }) +test('isLongcatBaseUrl requires the documented HTTPS OpenAI API path', () => { + expect(isLongcatBaseUrl('https://api.longcat.chat/openai')).toBe(true) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/')).toBe(true) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/v1')).toBe(true) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/v1/chat/completions')).toBe(true) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/chat/completions')).toBe(true) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/other')).toBe(false) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/v1?query=value')).toBe(false) + expect(isLongcatBaseUrl('https://api.longcat.chat/openai/v1#fragment')).toBe(false) + expect(isLongcatBaseUrl('https://api.longcat.chat:8443/openai/v1')).toBe(false) + expect(isLongcatBaseUrl('http://api.longcat.chat/openai/v1')).toBe(false) + expect(isLongcatBaseUrl('https://api.longcat.chat/v1')).toBe(false) + expect(isLongcatBaseUrl('https://api.longcat.chat.evil.test/openai/v1')).toBe(false) +}) + +test('resolveActiveRouteIdFromEnv keeps generic OpenAI credentials ahead of env-only LongCat', () => { + expect(resolveActiveRouteIdFromEnv({ + OPENAI_API_KEY: 'generic-key', + LONGCAT_API_KEY: 'longcat-key', + })).not.toBe('longcat') +}) + test('getRouteProviderTypeLabel uses descriptor transport kinds for provider labels', () => { expect(getRouteProviderTypeLabel('anthropic')).toBe('Anthropic native API') expect(getRouteProviderTypeLabel('gemini')).toBe('Gemini API') diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index 6baeae08fc..6b2d80bd9e 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -368,6 +368,27 @@ export function isFireworksBaseUrl(value: string | undefined): boolean { } } +export function isLongcatBaseUrl(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) { + return false + } + + try { + const url = new URL(trimmed) + return ( + url.protocol === 'https:' && + url.hostname.toLowerCase() === 'api.longcat.chat' && + !url.port && + !url.search && + !url.hash && + /^\/openai(?:\/v1)?(?:\/chat\/completions)?\/?$/.test(url.pathname) + ) + } catch { + return false + } +} + export function isClinePassBaseUrl(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) { @@ -486,6 +507,23 @@ export function getFireworksBaseUrlOverride( return undefined } + +export function getLongcatBaseUrlOverride( + processEnv: NodeJS.ProcessEnv = process.env, +): string | undefined { + const openAIBaseUrl = processEnv.OPENAI_BASE_URL?.trim() + if (isLongcatBaseUrl(openAIBaseUrl)) { + return openAIBaseUrl + } + + const openAIApiBase = processEnv.OPENAI_API_BASE?.trim() + if (isLongcatBaseUrl(openAIApiBase)) { + return openAIApiBase + } + + return undefined +} + export function getMiniMaxBaseUrlOverride( processEnv: NodeJS.ProcessEnv = process.env, ): string | undefined { @@ -664,6 +702,7 @@ export function hasNearaiEnvOnlyProviderIntent( !hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) && !hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) && !hasNonEmptyEnvValue(processEnv.FIREWORKS_API_KEY) && + !hasNonEmptyEnvValue(processEnv.LONGCAT_API_KEY) && !hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) && !hasConflictingOpenAIBaseUrlForRoute(processEnv, isNearaiBaseUrl) && hasNoExplicitNonOpenAICompatibleProvider(processEnv) @@ -685,12 +724,31 @@ export function hasFireworksEnvOnlyProviderIntent( !hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) && !hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) && !hasNonEmptyEnvValue(processEnv.NEARAI_API_KEY) && + !hasNonEmptyEnvValue(processEnv.LONGCAT_API_KEY) && !hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) && !hasConflictingOpenAIBaseUrlForRoute(processEnv, isFireworksBaseUrl) && hasNoExplicitNonOpenAICompatibleProvider(processEnv) ) } +export function hasLongcatEnvOnlyProviderIntent( + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + hasNonEmptyEnvValue(processEnv.LONGCAT_API_KEY) && + !hasAnyUsableOpenAICredential(processEnv) && + !hasNonEmptyEnvValue(processEnv.XAI_API_KEY) && + !hasNonEmptyEnvValue(processEnv.MINIMAX_API_KEY) && + !hasNonEmptyEnvValue(processEnv.VENICE_API_KEY) && + !hasNonEmptyEnvValue(processEnv.MIMO_API_KEY) && + !hasNonEmptyEnvValue(processEnv.NEARAI_API_KEY) && + !hasNonEmptyEnvValue(processEnv.FIREWORKS_API_KEY) && + !hasNonEmptyEnvValue(processEnv.CLINE_API_KEY) && + !hasConflictingOpenAIBaseUrlForRoute(processEnv, isLongcatBaseUrl) && + hasNoExplicitNonOpenAICompatibleProvider(processEnv) + ) +} + /** * Detects whether the process environment is configured to route traffic * exclusively through ClinePass based on the presence of CLINE_API_KEY @@ -716,6 +774,7 @@ export function resolveEnvOnlyProviderRouteId( | 'xiaomi-mimo' | 'nearai' | 'fireworks' + | 'longcat' | 'clinepass' | null { if ( @@ -753,6 +812,10 @@ export function resolveEnvOnlyProviderRouteId( return 'fireworks' } + if (hasLongcatEnvOnlyProviderIntent(processEnv)) { + return 'longcat' + } + if (hasClinePassEnvOnlyProviderIntent(processEnv)) { return 'clinepass' } @@ -939,7 +1002,10 @@ export function resolveRouteIdFromBaseUrl( // bare hostname match isn't enough for the Workers AI route — require // the Workers AI path (/client/v4/accounts//ai/v1). Otherwise an // unrelated Cloudflare API URL would inherit Workers-AI routing. - if (route.id === 'cloudflare' && !isCloudflareBaseUrl(baseUrl)) { + if ( + (route.id === 'cloudflare' && !isCloudflareBaseUrl(baseUrl)) || + (route.id === 'longcat' && !isLongcatBaseUrl(baseUrl)) + ) { continue } return route.id @@ -972,6 +1038,9 @@ function profileRouteHonorsBaseUrlBoundary( if (routeId === 'cloudflare') { return isCloudflareBaseUrl(baseUrl) } + if (routeId === 'longcat') { + return isLongcatBaseUrl(baseUrl) + } return true } diff --git a/src/integrations/vendors/longcat.test.ts b/src/integrations/vendors/longcat.test.ts new file mode 100644 index 0000000000..eee2b6fdc7 --- /dev/null +++ b/src/integrations/vendors/longcat.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' + +import { + ensureIntegrationsLoaded, + getCatalogEntriesForRoute, + getModel, + getProviderPresetUiMetadata, + getRouteDefaultBaseUrl, + getRouteDefaultModel, + getVendor, + resolveRouteIdFromBaseUrl, + validateIntegrationRegistry, +} from '../index.js' +import { resolveModelReasoningControl } from '../../utils/effort.js' + +describe('longcat vendor', () => { + test('registers route defaults, catalog, and preset metadata', () => { + ensureIntegrationsLoaded() + + const vendor = getVendor('longcat') + expect(vendor).toBeDefined() + expect(vendor?.defaultBaseUrl).toBe('https://api.longcat.chat/openai/v1') + expect(vendor?.defaultModel).toBe('LongCat-2.0') + expect(vendor?.setup.credentialEnvVars).toEqual(['LONGCAT_API_KEY']) + expect(vendor?.transportConfig.openaiShim?.thinkingRequestFormat).toBe( + 'zai-compatible', + ) + expect(vendor?.transportConfig.openaiShim?.removeBodyFields).toContain( + 'reasoning_effort', + ) + expect(vendor?.transportConfig.openaiShim?.removeBodyFields).toContain( + 'stream_options', + ) + + expect(getRouteDefaultBaseUrl('longcat')).toBe( + 'https://api.longcat.chat/openai/v1', + ) + expect(getRouteDefaultModel('longcat')).toBe('LongCat-2.0') + expect(resolveRouteIdFromBaseUrl('https://api.longcat.chat/openai')).toBe( + 'longcat', + ) + expect( + resolveRouteIdFromBaseUrl('https://api.longcat.chat/openai/v1'), + ).toBe('longcat') + + const catalog = getCatalogEntriesForRoute('longcat') + expect(catalog).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'LongCat-2.0', + apiName: 'LongCat-2.0', + modelDescriptorId: 'LongCat-2.0', + }), + ]), + ) + + const model = getModel('LongCat-2.0') + expect(model).toMatchObject({ + id: 'LongCat-2.0', + vendorId: 'longcat', + contextWindow: 1_048_576, + maxOutputTokens: 131_072, + capabilities: expect.objectContaining({ supportsFunctionCalling: false }), + }) + + const preset = getProviderPresetUiMetadata('longcat') + expect(preset.routeId).toBe('longcat') + expect(preset.credentialEnvVars).toContain('LONGCAT_API_KEY') + expect(preset.baseUrl).toBe('https://api.longcat.chat/openai/v1') + expect(preset.model).toBe('LongCat-2.0') + + const validation = validateIntegrationRegistry() + expect(validation.valid).toBe(true) + }) + + test('exposes LongCat reasoning without unsupported effort levels', () => { + ensureIntegrationsLoaded() + + expect( + resolveModelReasoningControl('LongCat-2.0', { routeId: 'longcat' }), + ).toMatchObject({ + supportsReasoning: true, + controllable: false, + source: 'capability', + levels: [], + }) + }) +}) diff --git a/src/integrations/vendors/longcat.ts b/src/integrations/vendors/longcat.ts new file mode 100644 index 0000000000..1105c82f6f --- /dev/null +++ b/src/integrations/vendors/longcat.ts @@ -0,0 +1,67 @@ +import { defineVendor } from '../define.js' + +export default defineVendor({ + id: 'longcat', + label: 'LongCat', + classification: 'openai-compatible', + defaultBaseUrl: 'https://api.longcat.chat/openai/v1', + defaultModel: 'LongCat-2.0', + requiredEnvVars: ['LONGCAT_API_KEY'], + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['LONGCAT_API_KEY'], + dedicatedCredentialsOnly: true, + setupPrompt: 'Paste your LongCat API key from https://longcat.chat/platform/api_keys', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + thinkingRequestFormat: 'zai-compatible', + maxTokensField: 'max_tokens', + // The documented Chat Completions request accepts text input only. + supportsImageInputs: false, + // LongCat documents thinking:{type} but not reasoning_effort. + removeBodyFields: ['store', 'reasoning_effort', 'stream_options', 'tools'], + requiredApiFormat: 'chat_completions', + supportsApiFormatSelection: false, + supportsAuthHeaders: false, + }, + }, + preset: { + id: 'longcat', + description: 'LongCat OpenAI-compatible API (Meituan)', + label: 'LongCat', + name: 'LongCat', + apiKeyEnvVars: ['LONGCAT_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + matchBaseUrlHosts: ['api.longcat.chat'], + }, + credentialEnvVars: ['LONGCAT_API_KEY'], + missingCredentialMessage: 'LONGCAT_API_KEY is required.', + }, + catalog: { + source: 'static', + models: [ + { + id: 'LongCat-2.0', + apiName: 'LongCat-2.0', + label: 'LongCat-2.0', + modelDescriptorId: 'LongCat-2.0', + capabilities: { + supportsStreaming: true, + supportsFunctionCalling: false, + supportsReasoning: true, + }, + contextWindow: 1_048_576, + maxOutputTokens: 131_072, + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/services/api/claude.lifecycle.test.ts b/src/services/api/claude.lifecycle.test.ts index 1eccfb7608..8f63f50d73 100644 --- a/src/services/api/claude.lifecycle.test.ts +++ b/src/services/api/claude.lifecycle.test.ts @@ -42,6 +42,7 @@ const envKeys = [ 'CLAUDE_FEATURE_FLAGS_FILE', 'CLAUDE_STREAM_IDLE_TIMEOUT_MS', 'GEMINI_API_KEY', + 'LONGCAT_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 8eb8fa2086..503e6bb3d1 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -66,6 +66,7 @@ const originalEnv = { MIMO_API_KEY: process.env.MIMO_API_KEY, VENICE_API_KEY: process.env.VENICE_API_KEY, FIREWORKS_API_KEY: process.env.FIREWORKS_API_KEY, + LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, AIMLAPI_API_KEY: process.env.AIMLAPI_API_KEY, NVIDIA_NIM: process.env.NVIDIA_NIM, NVIDIA_API_KEY: process.env.NVIDIA_API_KEY, @@ -118,6 +119,7 @@ function clearEnvForMiniMaxOnlyTest(): void { delete process.env.MIMO_API_KEY delete process.env.VENICE_API_KEY delete process.env.FIREWORKS_API_KEY + delete process.env.LONGCAT_API_KEY delete process.env.AIMLAPI_API_KEY delete process.env.NVIDIA_NIM delete process.env.NVIDIA_API_KEY @@ -158,6 +160,7 @@ beforeEach(async () => { delete process.env.MIMO_API_KEY delete process.env.VENICE_API_KEY delete process.env.FIREWORKS_API_KEY + delete process.env.LONGCAT_API_KEY delete process.env.AIMLAPI_API_KEY delete process.env.OPENAI_AUTH_HEADER delete process.env.OPENAI_AUTH_SCHEME @@ -206,6 +209,7 @@ afterEach(() => { restoreEnv('MIMO_API_KEY', originalEnv.MIMO_API_KEY) restoreEnv('VENICE_API_KEY', originalEnv.VENICE_API_KEY) 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('NVIDIA_NIM', originalEnv.NVIDIA_NIM) restoreEnv('NVIDIA_API_KEY', originalEnv.NVIDIA_API_KEY) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index d20a1a5132..a306722d59 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -39,6 +39,7 @@ import { } from '../../utils/envUtils.js' import { getFireworksBaseUrlOverride, + getLongcatBaseUrlOverride, getMiniMaxBaseUrlOverride, getNearaiBaseUrlOverride, getRouteDefaultBaseUrl, @@ -327,6 +328,32 @@ function applyFireworksEnvOnlyDefaults(): void { delete process.env.OPENAI_AUTH_HEADER_VALUE } +function isLongcatModelName(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return Boolean(normalized && normalized.startsWith('longcat')) +} + +function applyLongcatEnvOnlyDefaults(): void { + const baseUrlOverride = getLongcatBaseUrlOverride() + const hasLongcatBaseOverride = baseUrlOverride !== undefined + const modelOverride = process.env.OPENAI_MODEL?.trim() || undefined + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = + baseUrlOverride ?? getRouteDefaultBaseUrl('longcat') + process.env.OPENAI_MODEL = + (hasLongcatBaseOverride || isLongcatModelName(modelOverride) + ? modelOverride + : undefined) ?? + getRouteDefaultModel('longcat') + process.env.OPENAI_API_KEY = process.env.LONGCAT_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 +} + function applyAimlapiEnvOnlyDefaults(): void { const baseUrlOverride = process.env.OPENAI_BASE_URL?.trim() || @@ -461,6 +488,8 @@ export async function getAnthropicClient({ envOnlyProviderRouteId === 'nearai' && !useMiniMaxEnvOnlyProvider const useFireworksEnvOnlyProvider = envOnlyProviderRouteId === 'fireworks' && !useMiniMaxEnvOnlyProvider + const useLongcatEnvOnlyProvider = + envOnlyProviderRouteId === 'longcat' && !useMiniMaxEnvOnlyProvider const useAimlapiEnvOnlyProvider = envOnlyProviderRouteId === 'aimlapi' && !useMiniMaxEnvOnlyProvider if (useMiniMaxEnvOnlyProvider) { @@ -478,6 +507,9 @@ export async function getAnthropicClient({ if (useFireworksEnvOnlyProvider) { applyFireworksEnvOnlyDefaults() } + if (useLongcatEnvOnlyProvider) { + applyLongcatEnvOnlyDefaults() + } if (useAimlapiEnvOnlyProvider) { applyAimlapiEnvOnlyDefaults() } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 446fdaf223..84ffa711fc 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -55,6 +55,8 @@ const originalEnv = { OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, MIMO_API_KEY: process.env.MIMO_API_KEY, + LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, + CLINE_API_KEY: process.env.CLINE_API_KEY, OPENGATEWAY_API_KEY: process.env.OPENGATEWAY_API_KEY, OPENGATEWAY_BASE_URL: process.env.OPENGATEWAY_BASE_URL, OPENCODE_API_KEY: process.env.OPENCODE_API_KEY, @@ -516,6 +518,8 @@ beforeEach(async () => { delete process.env.OPENROUTER_API_KEY delete process.env.DEEPSEEK_API_KEY delete process.env.MIMO_API_KEY + delete process.env.LONGCAT_API_KEY + delete process.env.CLINE_API_KEY delete process.env.OPENGATEWAY_API_KEY delete process.env.OPENGATEWAY_BASE_URL delete process.env.OPENCODE_API_KEY @@ -561,6 +565,8 @@ afterEach(() => { restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) restoreEnv('DEEPSEEK_API_KEY', originalEnv.DEEPSEEK_API_KEY) restoreEnv('MIMO_API_KEY', originalEnv.MIMO_API_KEY) + restoreEnv('LONGCAT_API_KEY', originalEnv.LONGCAT_API_KEY) + restoreEnv('CLINE_API_KEY', originalEnv.CLINE_API_KEY) restoreEnv('OPENGATEWAY_API_KEY', originalEnv.OPENGATEWAY_API_KEY) restoreEnv('OPENGATEWAY_BASE_URL', originalEnv.OPENGATEWAY_BASE_URL) restoreEnv('OPENCODE_API_KEY', originalEnv.OPENCODE_API_KEY) @@ -3271,6 +3277,7 @@ test('preserves Gemini tool call extra_content in follow-up requests', async () { role: 'assistant', content: [ + { type: 'thinking', thinking: 'I should inspect the working tree first.' }, { type: 'tool_use', id: 'call_1', @@ -4668,6 +4675,197 @@ test('gitlawb opengateway provider flag prefers OPENGATEWAY_API_KEY over generic expect(captured.authorization).toBe('Bearer fake-ogw-key') }) +test('longcat provider flag prefers LONGCAT_API_KEY over generic OPENAI_API_KEYS pool', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_API_KEYS = 'fake-openai-pool-a,fake-openai-pool-b' + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_KEY + + const result = applyProviderFlag('longcat', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('https://api.longcat.chat/openai/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-longcat-key') +}) + +test('longcat provider flag strips unsupported tool definitions', async () => { + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record + return makeChatCompletionResponse('LongCat-2.0') + }) as unknown as FetchType + + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'LongCat-2.0', + messages: [ + { role: 'user', content: 'List files' }, + ], + tools: [{ + name: 'Bash', + description: 'Run a shell command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }], + max_tokens: 32, + stream: false, + }) + + expect(requestBody?.tools).toBeUndefined() +}) + +test('longcat rejects image input before dispatch', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect(client.beta.messages.create({ + model: 'LongCat-2.0', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'Describe this image.' }, + { type: 'image', source: { type: 'url', url: 'https://example.com/image.png' } }, + ], + }], + max_tokens: 32, + stream: false, + })).rejects.toThrow('does not support image inputs') +}) + +test('longcat rejects image tool results before dispatch', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_KEY + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect(client.beta.messages.create({ + model: 'LongCat-2.0', + messages: [ + { role: 'user', content: 'Inspect the screenshot' }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'call_screenshot', name: 'Screenshot', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_screenshot', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } }] }] }, + ], + tools: [{ name: 'Screenshot', description: 'Capture a screenshot', input_schema: { type: 'object', properties: {} } }], + max_tokens: 32, + stream: false, + })).rejects.toThrow('does not support image inputs') +}) + +test('longcat accepts the documented bare OpenAI SDK base URL', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai' + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('https://api.longcat.chat/openai/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-longcat-key') +}) + +test('longcat accepts the documented bare OpenAI SDK base URL with a trailing slash', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai/' + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('https://api.longcat.chat/openai/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-longcat-key') +}) + +test('longcat does not append chat completions to a configured endpoint URL', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai/v1/chat/completions' + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.url).toBe('https://api.longcat.chat/openai/v1/chat/completions') + expect(captured.authorization).toBe('Bearer fake-longcat-key') +}) + +test('longcat normalizes a configured endpoint URL with a trailing slash', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai/v1/chat/completions/' + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + const captured = await captureChatCompletionRequest() + expect(captured.url).toBe('https://api.longcat.chat/openai/v1/chat/completions') +}) + +test('longcat accepts the documented CodeBuddy endpoint URL', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai/chat/completions' + delete process.env.OPENAI_API_KEY + + expect(applyProviderFlag('longcat', []).error).toBeUndefined() + const captured = await captureChatCompletionRequest() + expect(captured.url).toBe('https://api.longcat.chat/openai/chat/completions') +}) + +test('longcat prefers its dedicated credential over a copied key from another provider', async () => { + process.env.LONGCAT_API_KEY = 'fake-longcat-key' + process.env.MIMO_API_KEY = 'fake-mimo-key' + process.env.OPENAI_API_KEY = 'fake-mimo-key' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/openai/v1' + + const captured = await captureChatCompletionRequest('LongCat-2.0') + + expect(captured.authorization).toBe('Bearer fake-longcat-key') +}) + +test('longcat provider flag never falls back to an OPENAI_API_KEYS pool', async () => { + process.env.OPENAI_API_KEYS = 'other-provider-secret' + delete process.env.LONGCAT_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_KEY + + const result = applyProviderFlag('longcat', []) + expect(result.error).toBeUndefined() + + const captured = await captureChatCompletionRequest() + + expect(captured.authorization).not.toBe('Bearer other-provider-secret') +}) + +test('dedicated-only ClinePass route never falls back to generic OpenAI credentials', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.cline.bot/api/v1' + process.env.OPENAI_MODEL = 'cline-pass/deepseek-v4-flash' + process.env.OPENAI_API_KEY = 'generic-openai-key' + process.env.OPENAI_API_KEYS = 'generic-openai-pool-a,generic-openai-pool-b' + delete process.env.CLINE_API_KEY + + const captured = await captureChatCompletionRequest( + 'cline-pass/deepseek-v4-flash', + ) + + expect(captured.authorization).toBeNull() +}) + 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.ts b/src/services/api/openaiShim.ts index 1e93e22856..8864db539c 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -56,6 +56,8 @@ import { import { resolveXaiAccessToken } from '../../utils/xaiCredentials.js' import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js' import { + getRouteDescriptor, + isLongcatBaseUrl, isXaiBaseUrl, resolveRouteCredentialValue, } from '../../integrations/routeMetadata.js' @@ -1343,6 +1345,7 @@ function joinTextContentParts(parts: OpenAIContentPart[]): string { function convertToolResultContent( content: unknown, isError?: boolean, + options?: { supportsImageInputs?: boolean }, ): string | OpenAIContentPart[] { if (typeof content === 'string') { return isError ? `Error: ${content}` : content @@ -1371,6 +1374,11 @@ function convertToolResultContent( } if (block?.type === 'image') { + if (options?.supportsImageInputs === false) { + throw new Error( + 'The active provider accepts text-only messages and does not support image inputs.', + ) + } const source = block.source if (source?.type === 'url' && source.url) { parts.push({ type: 'image_url', image_url: { url: source.url } }) @@ -1420,6 +1428,7 @@ function convertToolResultContent( function convertContentBlocks( content: unknown, + options?: { supportsImageInputs?: boolean }, ): string | OpenAIContentPart[] { if (typeof content === 'string') return content if (!Array.isArray(content)) return String(content ?? '') @@ -1431,6 +1440,11 @@ function convertContentBlocks( parts.push({ type: 'text', text: block.text ?? '' }) break case 'image': { + if (options?.supportsImageInputs === false) { + throw new Error( + 'The active provider accepts text-only messages and does not support image inputs.', + ) + } const src = block.source if (src?.type === 'base64') { parts.push({ @@ -1544,11 +1558,13 @@ function convertMessages( preserveReasoningContent?: boolean reasoningContentFallback?: '' | 'omit' preserveGeminiThoughtSignature?: boolean + supportsImageInputs?: boolean }, ): OpenAIMessage[] { const preserveReasoningContent = options?.preserveReasoningContent === true const reasoningContentFallback = options?.reasoningContentFallback const preserveGeminiThoughtSignature = options?.preserveGeminiThoughtSignature === true + const supportsImageInputs = options?.supportsImageInputs const result: OpenAIMessage[] = [] const knownToolCallIds = new Set() @@ -1606,7 +1622,7 @@ function convertMessages( result.push({ role: 'tool', tool_call_id: id, - content: convertToolResultContent(tr.content, tr.is_error), + content: convertToolResultContent(tr.content, tr.is_error, { supportsImageInputs }), }) } else { logForDebugging( @@ -1623,13 +1639,13 @@ function convertMessages( if (otherContent && otherContent.length > 0) { result.push({ role: 'user', - content: convertContentBlocks(otherContent), + content: convertContentBlocks(otherContent, { supportsImageInputs }), }) } } else { result.push({ role: 'user', - content: convertContentBlocks(content), + content: convertContentBlocks(content, { supportsImageInputs }), }) } } else if (role === 'assistant') { @@ -1679,7 +1695,7 @@ function convertMessages( const assistantMsg: OpenAIMessage = { role: 'assistant', content: (() => { - const c = convertContentBlocks(textContent ?? []) + const c = convertContentBlocks(textContent ?? [], { supportsImageInputs }) return typeof c === 'string' ? c : Array.isArray(c) @@ -1780,7 +1796,7 @@ function convertMessages( const assistantMsg: OpenAIMessage = { role: 'assistant', content: (() => { - const c = convertContentBlocks(content) + const c = convertContentBlocks(content, { supportsImageInputs }) return typeof c === 'string' ? c : Array.isArray(c) @@ -4345,6 +4361,7 @@ class OpenAIShimMessages { request.resolvedModel, request.baseUrl, ), + supportsImageInputs: shimConfig.supportsImageInputs, }), ) @@ -4476,7 +4493,11 @@ class OpenAIShimMessages { delete body[field] } - if (params.tools && params.tools.length > 0) { + if ( + !(shimConfig.removeBodyFields ?? []).includes('tools') && + params.tools && + params.tools.length > 0 + ) { const converted = convertTools( params.tools as Array<{ name: string @@ -4793,7 +4814,12 @@ class OpenAIShimMessages { // sent as a Bearer to api.x.ai/v1 — same surface as an API key. const isXaiRoute = runtimeShimContext.routeId === 'xai' || isXaiBaseUrl(request.baseUrl) + const routeAcceptsGenericOpenAICredentials = + runtimeShimContext.routeId === null || + getRouteDescriptor(runtimeShimContext.routeId)?.setup + .dedicatedCredentialsOnly !== true const openAIApiKeysPoolRaw = + routeAcceptsGenericOpenAICredentials && parseCredentialList(process.env.OPENAI_API_KEYS).length > 0 ? process.env.OPENAI_API_KEYS : undefined @@ -4824,6 +4850,7 @@ class OpenAIShimMessages { process.env.ATLAS_CLOUD_API_KEY, process.env.NEARAI_API_KEY, process.env.FIREWORKS_API_KEY, + process.env.LONGCAT_API_KEY, ].some(value => value?.trim() === openAIApiKeyRawUsable), ) const routeCredentialIsCopiedProviderKey = @@ -4847,13 +4874,20 @@ class OpenAIShimMessages { openAIApiKeyRawUsable && routeCredential === openAIApiKeyRawUsable, ) + const copiedProviderCredential = + openAIApiKeyIsCopiedProviderKey && + (routeAcceptsGenericOpenAICredentials || routeCredentialIsCopiedProviderKey) + ? openAIApiKeyRawUsable + : undefined const apiKeyRaw = this.providerOverride?.apiKey ?? - (openAIApiKeyIsCopiedProviderKey ? openAIApiKeyRawUsable : undefined) ?? + copiedProviderCredential ?? (routeCredentialIsGenericOpenAIFallback ? undefined : routeCredential) ?? openAIApiKeysPoolRaw ?? routeCredential ?? - (openAIApiKeyRawUsable || xaiOAuthToken || '') + (routeAcceptsGenericOpenAICredentials + ? openAIApiKeyRawUsable || xaiOAuthToken || '' + : '') // A catalog-level auth header is part of the selected model's transport // contract. Ignore global custom auth left behind by another route so it // cannot replace that model-specific header or credential. @@ -4993,7 +5027,26 @@ class OpenAIShimMessages { return `${normalizedBase}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}` } - return `${baseUrl}/chat/completions` + const normalizedBase = baseUrl.replace(/\/+$/, '') + // LongCat documents both `/openai/v1/chat/completions` and the + // CodeBuddy-specific `/openai/chat/completions` endpoint forms. + if ( + runtimeShimContext.routeId === 'longcat' && + isLongcatBaseUrl(normalizedBase) && + /^\/openai\/?$/.test(new URL(normalizedBase).pathname) + ) { + return `${normalizedBase}/v1/chat/completions` + } + if ( + runtimeShimContext.routeId === 'longcat' && + isLongcatBaseUrl(normalizedBase) && + /^\/openai(?:\/v1)?\/chat\/completions$/.test( + new URL(normalizedBase).pathname, + ) + ) { + return normalizedBase + } + return `${normalizedBase}/chat/completions` } // Azure serves the Responses API only on the v1 surface diff --git a/src/services/api/providerConfig.test.ts b/src/services/api/providerConfig.test.ts index b733900b3d..d24d9706c7 100644 --- a/src/services/api/providerConfig.test.ts +++ b/src/services/api/providerConfig.test.ts @@ -25,6 +25,20 @@ test('resolveProviderRequest exposes model-query thinking defaults', () => { expect(request.thinking).toEqual({ type: 'disabled' }) }) +test('resolveProviderRequest forces LongCat onto chat completions', () => { + const request = resolveProviderRequest({ + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + LONGCAT_API_KEY: 'longcat-key', + OPENAI_BASE_URL: 'https://api.longcat.chat/openai/v1', + OPENAI_MODEL: 'LongCat-2.0', + OPENAI_API_FORMAT: 'responses', + }, + }) + + expect(request.transport).toBe('chat_completions') +}) + test('resolveProviderRequest maps explicit route catalog aliases to API model ids', () => { const request = resolveProviderRequest({ model: 'glm-5.2?reasoning=high', diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 27c29f0677..b51e75db4f 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -1079,10 +1079,9 @@ export function resolveProviderRequest(options?: { isGithubMode ? undefined : parseOpenAICompatibleApiFormat(runtimeShimContext?.openaiShimConfig.requiredApiFormat) - // Precedence: explicit env/profile apiFormat (incl. chat_completions, the - // escape hatch) > catalog requiredApiFormat > this model+base predicate > - // shim default. The predicate fires only when nothing above resolved it, so - // an explicit format always wins over it. + // An explicitly required chat-completions route cannot be switched to a + // different endpoint. Other catalog-required formats retain their existing + // precedence over an absent or chat-completions selection. const autoResponsesApiFormat = !isGithubMode && explicitApiFormat === undefined && @@ -1092,12 +1091,14 @@ export function resolveProviderRequest(options?: { ? ('responses' as const) : undefined const requestedApiFormat = - requiredApiFormat && - (explicitApiFormat === undefined || explicitApiFormat === 'chat_completions') + requiredApiFormat === 'chat_completions' ? requiredApiFormat - : explicitApiFormat ?? - autoResponsesApiFormat ?? - parseOpenAICompatibleApiFormat(runtimeShimContext?.openaiShimConfig.defaultApiFormat) + : requiredApiFormat && + (explicitApiFormat === undefined || explicitApiFormat === 'chat_completions') + ? requiredApiFormat + : explicitApiFormat ?? + autoResponsesApiFormat ?? + parseOpenAICompatibleApiFormat(runtimeShimContext?.openaiShimConfig.defaultApiFormat) const supportsRequestedApiFormat = (requestedApiFormat !== 'responses' && requestedApiFormat !== 'responses_compat') || openAIShimSupportsApiFormatForModel( diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 5b77d4e8cd..8e1d31891f 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -293,6 +293,7 @@ const PROVIDER_ENV_KEYS = [ // key is interpreted as 'firstParty' by getAPIProvider because the // 'fireworks' route has no switch case in that function. 'FIREWORKS_API_KEY', + 'LONGCAT_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', diff --git a/src/utils/attachments.ultracode.test.ts b/src/utils/attachments.ultracode.test.ts index 9c33e518b3..298265072b 100644 --- a/src/utils/attachments.ultracode.test.ts +++ b/src/utils/attachments.ultracode.test.ts @@ -21,6 +21,7 @@ const ROUTING_ENV_VARS = [ 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', 'NVIDIA_NIM', + 'LONGCAT_API_KEY', 'CLAUDE_CODE_EFFORT_LEVEL', ] diff --git a/src/utils/betas.test.ts b/src/utils/betas.test.ts index 7f0f90d28a..4c3812aa7f 100644 --- a/src/utils/betas.test.ts +++ b/src/utils/betas.test.ts @@ -43,6 +43,7 @@ const PROVIDER_ENV_KEYS = [ // the getAPIProvider switch, so a leaked key is interpreted as the // default anthropic provider. 'FIREWORKS_API_KEY', + 'LONGCAT_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 91fc60e6fc..1782a09fa1 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -29,6 +29,7 @@ const originalEnv = { process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, MINIMAX_API_KEY: process.env.MINIMAX_API_KEY, XAI_API_KEY: process.env.XAI_API_KEY, + LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, CLAUDE_CODE_MAX_CONTEXT_TOKENS: process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, USER_TYPE: process.env.USER_TYPE, } @@ -49,6 +50,7 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID delete process.env.MINIMAX_API_KEY delete process.env.XAI_API_KEY + delete process.env.LONGCAT_API_KEY delete process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS delete process.env.USER_TYPE }) @@ -125,6 +127,11 @@ afterEach(() => { } else { process.env.XAI_API_KEY = originalEnv.XAI_API_KEY } + if (originalEnv.LONGCAT_API_KEY === undefined) { + delete process.env.LONGCAT_API_KEY + } else { + process.env.LONGCAT_API_KEY = originalEnv.LONGCAT_API_KEY + } if (originalEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS === undefined) { delete process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS } else { diff --git a/src/utils/effort.ultracode-display.test.ts b/src/utils/effort.ultracode-display.test.ts index 19779d607e..c834a1cf77 100644 --- a/src/utils/effort.ultracode-display.test.ts +++ b/src/utils/effort.ultracode-display.test.ts @@ -33,6 +33,7 @@ const ENV_VARS = [ 'OPENAI_API_KEYS', 'GEMINI_API_KEY', 'MISTRAL_API_KEY', + 'LONGCAT_API_KEY', 'MINIMAX_API_KEY', 'NVIDIA_NIM', 'NVIDIA_API_KEY', diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index 869a0826e9..0763ea2c7a 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -72,6 +72,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'JINA_API_KEY', 'KIMI_API_KEY', 'LINKUP_API_KEY', + 'LONGCAT_API_KEY', 'MINIMAX_API_KEY', 'MINIMAX_BASE_URL', 'MINIMAX_MODEL', diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index c43fc5d967..6c56c03d61 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -14,6 +14,7 @@ const originalEnv = { CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, NVIDIA_NIM: process.env.NVIDIA_NIM, MINIMAX_API_KEY: process.env.MINIMAX_API_KEY, + LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, @@ -62,6 +63,7 @@ function clearProviderEnv(): void { delete process.env.CLAUDE_CODE_USE_FOUNDRY delete process.env.NVIDIA_NIM delete process.env.MINIMAX_API_KEY + delete process.env.LONGCAT_API_KEY delete process.env.ANTHROPIC_BASE_URL delete process.env.ANTHROPIC_API_KEY delete process.env.ANTHROPIC_AUTH_TOKEN diff --git a/src/utils/providerAutoDetect.test.ts b/src/utils/providerAutoDetect.test.ts index 6663b073b5..401d77f974 100644 --- a/src/utils/providerAutoDetect.test.ts +++ b/src/utils/providerAutoDetect.test.ts @@ -148,6 +148,13 @@ describe('detectProviderFromEnv — priority order', () => { }) }) + test('LONGCAT_API_KEY detected', () => { + expect(scan({ LONGCAT_API_KEY: 'lc-key' })).toEqual({ + kind: 'longcat', + source: 'LONGCAT_API_KEY set', + }) + }) + test('empty-string values are ignored', () => { expect( scan({ diff --git a/src/utils/providerAutoDetect.ts b/src/utils/providerAutoDetect.ts index 328f5f8ead..65a955e62e 100644 --- a/src/utils/providerAutoDetect.ts +++ b/src/utils/providerAutoDetect.ts @@ -18,10 +18,11 @@ * 7. MINIMAX_API_KEY * 8. MIMO_API_KEY (Xiaomi Mimo) * 9. XAI_API_KEY - * 10. NEARAI_API_KEY - * 11. FIREWORKS_API_KEY - * 12. Local Ollama reachable (default localhost:11434) - * 13. Local LM Studio reachable (default localhost:1234) + * 10. NEARAI_API_KEY + * 11. FIREWORKS_API_KEY + * 12. LONGCAT_API_KEY + * 13. Local Ollama reachable (default localhost:11434) + * 14. Local LM Studio reachable (default localhost:1234) * * Local-service probes are parallelized and cheap (short timeout, no * request body). Env scans are synchronous and run first so we don't make @@ -49,6 +50,7 @@ export type DetectedProviderKind = | 'xai' | 'nearai' | 'fireworks' + | 'longcat' | 'ollama' | 'lm-studio' | 'gitlawb-opengateway' @@ -208,6 +210,13 @@ export function detectProviderFromEnv( } } + if (envHasNonEmpty(env, 'LONGCAT_API_KEY')) { + return { + kind: 'longcat', + source: 'LONGCAT_API_KEY set', + } + } + return null } diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index f8ee77b756..0adc6ad605 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -40,6 +40,7 @@ const ENV_KEYS = [ 'VENICE_API_KEY', 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', + 'LONGCAT_API_KEY', 'OPENGATEWAY_API_KEY', 'OPENGATEWAY_BASE_URL', 'CLOUDFLARE_API_TOKEN', @@ -88,6 +89,7 @@ const RESET_KEYS = [ 'VENICE_API_KEY', 'MIMO_API_KEY', 'ATLAS_CLOUD_API_KEY', + 'LONGCAT_API_KEY', 'OPENGATEWAY_API_KEY', 'OPENGATEWAY_BASE_URL', 'CLOUDFLARE_API_TOKEN', @@ -267,6 +269,7 @@ describe('VALID_PROVIDERS', () => { expect(VALID_PROVIDERS).toContain('venice') expect(VALID_PROVIDERS).toContain('xiaomi-mimo') expect(VALID_PROVIDERS).toContain('xiaomi-mimo-token') + expect(VALID_PROVIDERS).toContain('longcat') expect(VALID_PROVIDERS).toContain('custom-anthropic') }) }) @@ -760,6 +763,68 @@ describe('applyProviderFlag - zai', () => { }) }) +describe('applyProviderFlag - longcat', () => { + test('sets LongCat OpenAI-compatible defaults and mirrors LONGCAT_API_KEY', () => { + process.env.LONGCAT_API_KEY = 'longcat-secret-key' + + const result = applyProviderFlag('longcat', []) + + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('https://api.longcat.chat/openai/v1') + expect(process.env.OPENAI_MODEL).toBe('LongCat-2.0') + expect(process.env.OPENAI_API_KEY).toBe('longcat-secret-key') + }) + + test('sets LongCat OPENAI_MODEL when --model is provided', () => { + applyProviderFlag('longcat', ['--model', 'LongCat-2.0']) + + expect(process.env.OPENAI_MODEL).toBe('LongCat-2.0') + }) + + test('clears incompatible OpenAI transport and auth overrides', () => { + process.env.LONGCAT_API_KEY = 'longcat-secret-key' + process.env.OPENAI_API_FORMAT = 'responses' + process.env.OPENAI_AUTH_HEADER = 'api-key' + process.env.OPENAI_AUTH_SCHEME = 'token' + process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-secret' + + const result = applyProviderFlag('longcat', []) + + expect(result.error).toBeUndefined() + 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('does not mirror LONGCAT_API_KEY to a preserved custom base URL', () => { + process.env.OPENAI_BASE_URL = 'https://untrusted.example/v1' + process.env.LONGCAT_API_KEY = 'longcat-secret-key' + + const result = applyProviderFlag('longcat', []) + + expect(result.error).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://untrusted.example/v1') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + test('clears LONGCAT_API_KEY copied into OPENAI_API_KEY when switching routes', () => { + process.env.LONGCAT_API_KEY = 'longcat-live-key' + + const longcatResult = applyProviderFlag('longcat', []) + expect(longcatResult.error).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBe('longcat-live-key') + + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + const openrouterResult = applyProviderFlag('openrouter', []) + + expect(openrouterResult.error).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_BASE_URL).toBe('https://openrouter.ai/api/v1') + }) +}) + describe('applyProviderFlag - xiaomi-mimo', () => { test('sets Xiaomi MiMo OpenAI-compatible defaults and mirrors MIMO_API_KEY', () => { process.env.MIMO_API_KEY = 'mimo-secret-key' diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 6d49246157..aa5f279525 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -22,6 +22,7 @@ import { getGateway, getVendor, isCloudflareBaseUrl, + isLongcatBaseUrl, resolveProfileRoute, resolveRouteIdFromBaseUrl, } from '../integrations/index.js' @@ -47,6 +48,7 @@ const PREFERRED_PROVIDER_ORDER = [ 'atlas-cloud', 'nearai', 'fireworks', + 'longcat', ] as const function buildValidProviders(): string[] { @@ -319,6 +321,9 @@ export function applyProviderFlag( : process.env.OPENAI_API_KEY !== undefined && process.env.OPENAI_API_KEY === process.env.FIREWORKS_API_KEY ? 'fireworks' + : process.env.OPENAI_API_KEY !== undefined && + process.env.OPENAI_API_KEY === process.env.LONGCAT_API_KEY + ? 'longcat' : process.env.OPENAI_API_KEY !== undefined && opengatewayApiKey !== undefined && opengatewayApiKey.length > 0 && @@ -589,6 +594,34 @@ export function applyProviderFlag( } break + case 'longcat': + process.env.CLAUDE_CODE_USE_OPENAI = '1' + // LongCat only implements its documented chat-completions endpoint and + // Bearer authentication. Do not let stale OpenAI-compatible settings + // from a previously selected provider change that wire contract. + delete process.env.OPENAI_API_FORMAT + delete process.env.OPENAI_AUTH_HEADER + delete process.env.OPENAI_AUTH_SCHEME + delete process.env.OPENAI_AUTH_HEADER_VALUE + applyOpenAIBaseUrlDefault( + provider, + defaultBaseUrl ?? 'https://api.longcat.chat/openai/v1', + ) + process.env.OPENAI_MODEL ??= defaultModel ?? 'LongCat-2.0' + if (model) process.env.OPENAI_MODEL = model + // Do not copy a dedicated LongCat credential to a stale custom URL. + // applyOpenAIBaseUrlDefault preserves an existing base URL, so only + // expose this key when that URL is the documented HTTPS LongCat API. + if ( + process.env.LONGCAT_API_KEY && + isLongcatBaseUrl(getConfiguredOpenAIBaseUrl()) + ) { + process.env.OPENAI_API_KEY = process.env.LONGCAT_API_KEY + } else { + delete process.env.OPENAI_API_KEY + } + break + case 'cloudflare': process.env.CLAUDE_CODE_USE_OPENAI = '1' // applyOpenAIBaseUrlDefault skips unresolved `<...>` placeholder diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 68410f55f1..0e162bcad6 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -309,6 +309,34 @@ test('openai launch carries AIMLAPI_API_KEY only when the route resolves to aiml assert.equal(onRoute.AIMLAPI_API_KEY, 'aimlapi-key') }) +test('openai launch carries LONGCAT_API_KEY only when the route resolves to LongCat', async () => { + const offRoute = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_API_KEY: 'sk-openai', + LONGCAT_API_KEY: 'longcat-persisted', + }), + goal: 'coding', + processEnv: { LONGCAT_API_KEY: 'longcat-ambient' }, + }) + + assert.equal(offRoute.LONGCAT_API_KEY, undefined) + + const onRoute = await buildLaunchEnv({ + profile: 'openai', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.longcat.chat/openai/v1', + OPENAI_API_KEY: 'longcat-key', + LONGCAT_API_KEY: 'longcat-key', + }), + goal: 'coding', + processEnv: {}, + }) + + assert.equal(onRoute.LONGCAT_API_KEY, 'longcat-key') +}) + test('openai launch mirrors rotated OPENAI_API_KEY into AIMLAPI_API_KEY on aimlapi route', async () => { const env = await buildLaunchEnv({ profile: 'openai', @@ -1054,6 +1082,23 @@ test('buildStartupEnvFromProfile preserves env-only NEAR AI setup without a save assert.equal(isDefaultStartupProviderEnv(env), false) }) +test('buildStartupEnvFromProfile preserves env-only LongCat setup without a saved profile', async () => { + const env = await buildStartupEnvFromProfile({ + persisted: null, + processEnv: { + LONGCAT_API_KEY: 'longcat-key', + }, + }) + + assert.equal(env.LONGCAT_API_KEY, 'longcat-key') + assert.equal( + env.OPENAI_BASE_URL, + undefined, + 'should not inject Gitlawb Opengateway base URL', + ) + assert.equal(isDefaultStartupProviderEnv(env), false) +}) + test('openai launch does not apply persisted Azure mode to a shell-selected base', async () => { const env = await buildLaunchEnv({ profile: 'openai', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index bd798b834a..64cbaf1919 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -25,6 +25,7 @@ import { getErrnoCode } from './errors.js' import { getRouteDefaultBaseUrl, getRouteDefaultModel, + isLongcatBaseUrl, normalizeXiaomiMimoBaseUrl, resolveRouteCredentialValue, resolveRouteIdFromBaseUrl, @@ -112,6 +113,7 @@ const PROFILE_ENV_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'NEARAI_API_KEY', 'FIREWORKS_API_KEY', + 'LONGCAT_API_KEY', 'CLINE_API_KEY', 'OPENCODE_API_KEY', 'CLAUDE_CODE_PROVIDER_ROUTE_ID', @@ -197,6 +199,7 @@ export type ProfileEnv = { CLINE_API_KEY?: string NEARAI_API_KEY?: string FIREWORKS_API_KEY?: string + LONGCAT_API_KEY?: string OPENCODE_API_KEY?: string CLOUDFLARE_API_TOKEN?: string CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS?: string @@ -1346,7 +1349,8 @@ function hasConcreteProviderSelection( // Env-only provider setups — no CLAUDE_CODE_USE_* flag needed return ( sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined || - sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined + sanitizeApiKey(processEnv.NEARAI_API_KEY) !== undefined || + sanitizeApiKey(processEnv.LONGCAT_API_KEY) !== undefined ) } @@ -2045,6 +2049,7 @@ export async function buildLaunchEnv(options: { 'ATLAS_CLOUD_API_KEY', 'NEARAI_API_KEY', 'FIREWORKS_API_KEY', + 'LONGCAT_API_KEY', 'AIMLAPI_API_KEY', 'MIMO_API_KEY', 'NVIDIA_API_KEY', @@ -2060,6 +2065,12 @@ export async function buildLaunchEnv(options: { if (dedicatedKey === 'NVIDIA_API_KEY' && effectiveOpenAIRouteId !== 'nvidia-nim') { continue } + if ( + dedicatedKey === 'LONGCAT_API_KEY' && + (effectiveOpenAIRouteId !== 'longcat' || !isLongcatBaseUrl(env.OPENAI_BASE_URL)) + ) { + 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 diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 6d7015c54f..f9810d6721 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -53,6 +53,7 @@ import { isCloudflareBaseUrl, isClinePassBaseUrl, isFireworksBaseUrl, + isLongcatBaseUrl, isNearaiBaseUrl, isXaiBaseUrl, isXiaomiMimoBaseUrl, @@ -226,18 +227,15 @@ function resolveProfileCapabilityRouteId( return routeIdFromBaseUrl } - // A cloudflare profile retargeted away from the real Workers AI endpoint - // (e.g. to gateway.ai.cloudflare.com or another OpenAI-compatible host) is - // run generically at runtime — resolveActiveRouteIdFromEnv no longer resolves - // it to the cloudflare route. Mirror that boundary here so capability-driven - // surfaces (apiFormat, custom auth headers, custom request headers) are not - // stripped based on the stale cloudflare route id. Keep the cloudflare route - // only when the base URL is the real Workers AI URL (already handled above) or - // unset (the descriptor default); any other base URL falls back to generic. + // Cloudflare and LongCat profiles retargeted away from their dedicated + // endpoints run generically at runtime. Mirror that boundary here so + // capability-driven surfaces are not stripped based on stale route ids. if ( - providerRouteId === 'cloudflare' && + (providerRouteId === 'cloudflare' || providerRouteId === 'longcat') && baseUrl && - !isCloudflareBaseUrl(baseUrl) + !(providerRouteId === 'cloudflare' + ? isCloudflareBaseUrl(baseUrl) + : isLongcatBaseUrl(baseUrl)) ) { return 'custom' } @@ -811,6 +809,10 @@ function isProcessEnvAlignedWithProfile( ? !includeApiKey || sameOptionalEnvValue(processEnv.FIREWORKS_API_KEY, profile.apiKey) : true) && + (isLongcatBaseUrl(profile.baseUrl) + ? !includeApiKey || + sameOptionalEnvValue(processEnv.LONGCAT_API_KEY, profile.apiKey) + : true) && (isCloudflareBaseUrl(profile.baseUrl) ? !includeApiKey || sameOptionalEnvValue(processEnv.CLOUDFLARE_API_TOKEN, profile.apiKey) @@ -1021,6 +1023,9 @@ export function applyProviderProfileToProcessEnv( if (route.routeId === 'fireworks' || isFireworksBaseUrl(profile.baseUrl)) { openAIProfileEnv.FIREWORKS_API_KEY = profile.apiKey } + if (isLongcatBaseUrl(profile.baseUrl)) { + openAIProfileEnv.LONGCAT_API_KEY = profile.apiKey + } // Gate on the Workers AI path predicate (isCloudflareBaseUrl: exact // api.cloudflare.com host AND the `/client/v4/accounts//ai/v1` path), // not the saved route id and not the host alone. A cloudflare profile @@ -1373,6 +1378,9 @@ function buildOpenAICompatibleStartupEnv( if (isFireworksBaseUrl(activeProfile.baseUrl)) { strictEnv.FIREWORKS_API_KEY = activeProfile.apiKey } + if (isLongcatBaseUrl(activeProfile.baseUrl)) { + strictEnv.LONGCAT_API_KEY = activeProfile.apiKey + } // Cloudflare's transport reads the dedicated CLOUDFLARE_API_TOKEN; mirror // it like nearai/fireworks, but only when the base URL is a real Workers // AI endpoint per the isCloudflareBaseUrl path predicate (exact @@ -1439,6 +1447,9 @@ function buildOpenAICompatibleStartupEnv( if (isFireworksBaseUrl(activeProfile.baseUrl)) { env.FIREWORKS_API_KEY = activeProfile.apiKey } + if (isLongcatBaseUrl(activeProfile.baseUrl)) { + env.LONGCAT_API_KEY = activeProfile.apiKey + } // Cloudflare Workers AI authenticates over the generic OpenAI-compatible // header, so mirror the saved key into CLOUDFLARE_API_TOKEN only when the // endpoint is the real Workers AI route: api.cloudflare.com with the diff --git a/src/utils/providerStartupOverrides.test.ts b/src/utils/providerStartupOverrides.test.ts index 2419614fdb..18fd38613c 100644 --- a/src/utils/providerStartupOverrides.test.ts +++ b/src/utils/providerStartupOverrides.test.ts @@ -22,6 +22,7 @@ describe('clearStartupProviderOverrides', () => { OPENAI_API_KEY: 'single-key', MINIMAX_API_KEY: 'sk-minimax', VENICE_API_KEY: 'sk-venice', + LONGCAT_API_KEY: 'sk-longcat', ANTHROPIC_AUTH_TOKEN: 'stale-proxy-token', KEEP_ME: '1', }, @@ -45,6 +46,7 @@ describe('clearStartupProviderOverrides', () => { OPENAI_API_KEY: undefined, MINIMAX_API_KEY: undefined, VENICE_API_KEY: undefined, + LONGCAT_API_KEY: undefined, ANTHROPIC_AUTH_TOKEN: undefined, }), }), diff --git a/src/utils/providerStartupOverrides.ts b/src/utils/providerStartupOverrides.ts index 29af28e297..06409dad91 100644 --- a/src/utils/providerStartupOverrides.ts +++ b/src/utils/providerStartupOverrides.ts @@ -41,6 +41,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [ 'NVIDIA_API_KEY', 'NVIDIA_NIM', 'VENICE_API_KEY', + 'LONGCAT_API_KEY', ] as const type GlobalConfigWithEnv = { diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 82d811e477..92440ccac2 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -31,6 +31,7 @@ const ENV_KEYS = [ 'CLAUDE_CODE_SIMPLE', 'MISTRAL_API_KEY', 'MINIMAX_API_KEY', + 'LONGCAT_API_KEY', 'NVIDIA_API_KEY', 'NVIDIA_NIM', 'BNKR_API_KEY', @@ -176,6 +177,14 @@ test('non-Workers Cloudflare path falls back to generic OpenAI validation', asyn ) }) +test('non-OpenAI LongCat path falls back to generic OpenAI validation', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.longcat.chat/anthropic/v1' + process.env.OPENAI_API_KEY = 'generic-openai-key' + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + test('codex auth error redacts descriptor-declared provider secret values used as model text', async () => { const providerSecret = 'ogw-provider-secret' process.env.CLAUDE_CODE_USE_OPENAI = '1' diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 998c4de4eb..0434aec1fb 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -16,6 +16,7 @@ import { getRouteDescriptor, getRouteDefaultModel, isCloudflareBaseUrl, + isLongcatBaseUrl, matchHostnameAgainstRouteHosts, resolveActiveRouteIdFromEnv, resolveRouteIdFromBaseUrl, @@ -277,8 +278,10 @@ function getRuntimeValidationTarget( // auth instead of falling back to generic OpenAI validation. Mirror the // runtime route resolver's boundary here. if ( - target.descriptor.id === 'cloudflare' && - !isCloudflareBaseUrl(request.baseUrl) + ((target.descriptor.id === 'cloudflare' && + !isCloudflareBaseUrl(request.baseUrl)) || + (target.descriptor.id === 'longcat' && + !isLongcatBaseUrl(request.baseUrl))) ) { return false } diff --git a/src/utils/status.routes.test.ts b/src/utils/status.routes.test.ts index 169cf29346..14ddca01c3 100644 --- a/src/utils/status.routes.test.ts +++ b/src/utils/status.routes.test.ts @@ -24,6 +24,7 @@ const PROVIDER_ENV_PREFIXES = [ 'GITHUB_', 'GOOGLE_', 'GROQ_', + 'LONGCAT_', 'MINIMAX_', 'MIMO_', 'MISTRAL_',