From 884746dbe9d5526aed5fc70ee5fef75a6003a3f7 Mon Sep 17 00:00:00 2001 From: TechBrewBoss Date: Mon, 4 May 2026 03:21:11 -0500 Subject: [PATCH] Provider: Add Hicap gateway provider (#979) * Add Hicap provider and gateway auth presets * Fix Hicap compatibility preset coverage * Authenticate ripgrep download in PR checks * Use Opus 4.7 as Hicap default * Address Hicap review feedback * Address provider review blockers * Clarify gateway header UI docs * Remove Hicap endpoint from README --- .env.example | 4 + README.md | 1 + docs/architecture/integrations.md | 2 + docs/integrations/how-to/add-gateway.md | 55 ++++-- src/components/ProviderManager.test.tsx | 168 ++++++++++++++++++ src/components/ProviderManager.tsx | 89 +++++++--- src/integrations/compatibility.test.ts | 1 + src/integrations/descriptors.ts | 16 ++ src/integrations/discoveryService.test.ts | 55 ++++++ src/integrations/discoveryService.ts | 9 +- src/integrations/gateways/hicap.ts | 68 +++++++ .../integrationArtifacts.generated.ts | 18 +- src/integrations/index.ts | 3 + src/integrations/models/claude.ts | 18 ++ src/integrations/routeMetadata.test.ts | 5 + src/integrations/routeMetadata.ts | 36 ++++ src/integrations/runtimeMetadata.ts | 30 ++++ src/services/api/openaiShim.test.ts | 34 ++++ src/services/api/openaiShim.ts | 5 + src/services/api/providerConfig.local.test.ts | 28 +++ src/services/api/providerConfig.ts | 22 ++- src/utils/providerProfiles.test.ts | 17 +- 22 files changed, 649 insertions(+), 35 deletions(-) create mode 100644 src/integrations/gateways/hicap.ts diff --git a/.env.example b/.env.example index c2db50881..059e5c186 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,10 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # OPENAI_BASE_URL=https://api.z.ai/api/coding/paas/v4 # OPENAI_MODEL=GLM-5.1 # Optional: OPENAI_MODEL=GLM-5-Turbo, GLM-4.7, or GLM-4.5-Air +# For Hicap, use the OpenAI-compatible route flag above and set: +# HICAP_API_KEY=your-hicap-key-here +# OPENAI_BASE_URL=https://api.hicap.ai/v1 +# OPENAI_MODEL=claude-opus-4.7 # Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com) # OPENAI_BASE_URL=https://api.openai.com/v1 diff --git a/README.md b/README.md index 28a6e44c3..cf8c73fdf 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Advanced and source-build guides: | Provider | Setup Path | Notes | | --- | --- | --- | | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | +| Hicap | `/provider` or OpenAI-compatible env vars | Uses `api-key` auth, discovers models from unauthenticated `/models`, and supports Responses mode for `gpt-` models | | Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials | | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely | diff --git a/docs/architecture/integrations.md b/docs/architecture/integrations.md index 6c79a91cb..1255a39b8 100644 --- a/docs/architecture/integrations.md +++ b/docs/architecture/integrations.md @@ -204,6 +204,8 @@ should remain explicit unless the external API changes. It still requires dedicated env selection and request shaping. - Azure OpenAI and Bankr have distinct auth/header contracts. Azure uses `api-key` and deployment URLs; Bankr uses `X-API-Key`. + Descriptor-backed gateways can use `defaultAuthHeader` for route-owned + default auth headers. - Gemini still has provider-specific credential handling and thought-signature behavior at the shim boundary. - DeepSeek and Moonshot/Kimi still need route-specific `reasoning_content`, diff --git a/docs/integrations/how-to/add-gateway.md b/docs/integrations/how-to/add-gateway.md index 59ce437a2..560f37efc 100644 --- a/docs/integrations/how-to/add-gateway.md +++ b/docs/integrations/how-to/add-gateway.md @@ -30,10 +30,13 @@ Typical gateway cases: Use `static`, `dynamic`, or `hybrid`. 6. Decide whether the gateway needs discovery cache TTL, refresh mode, and manual refresh. -7. For OpenAI-compatible or local routes, add any required static headers and - decide whether users may edit API mode and auth/header fields through +7. For OpenAI-compatible or local routes, add any required static headers, + decide whether users may edit API mode and header-related settings through `transportConfig.openaiShim.supportsApiFormatSelection` and - `transportConfig.openaiShim.supportsAuthHeaders`. + `transportConfig.openaiShim.supportsAuthHeaders`, and use + `transportConfig.openaiShim.ui.show*` flags to choose which auth-header, + auth-value, or custom-header prompts appear for tighter built-in preset + flows. 8. If the gateway should appear in preset-driven `/provider` flows, add a `preset` block on the descriptor. 9. Run `bun run integrations:generate` so the generated loader and preset @@ -120,6 +123,15 @@ export default defineGateway({ }, supportsApiFormatSelection: false, supportsAuthHeaders: true, + ui: { + showAuthHeader: false, + showAuthHeaderValue: false, + showCustomHeaders: true, + }, + // Optional: use a non-Authorization default auth header. + defaultAuthHeader: { name: 'api-key', scheme: 'raw' }, + // Optional: restrict Responses API mode to model ids with these prefixes. + responsesApiModelPrefixes: ['gpt-'], maxTokensField: 'max_completion_tokens', }, }, @@ -142,7 +154,8 @@ What this example covers: - hosted OpenAI-compatible routing; - required static custom headers; - API mode editing disabled for a fixed hosted gateway; -- optional user-supplied auth/header fields enabled; +- route-owned auth with only regular custom-header prompts shown in the preset UI; +- route-owned default auth header and Responses API model-prefix rules; - a static catalog; - a gateway with only its own hosted models; - different reasoning/context/input/output behavior across models; @@ -230,6 +243,8 @@ export default defineGateway({ source: 'dynamic', discovery: { kind: 'openai-compatible', + // Set requiresAuth: false when /models is public even if inference needs auth. + requiresAuth: false, }, discoveryCacheTtl: '1d', discoveryRefreshMode: 'startup', @@ -472,8 +487,8 @@ transportConfig: { For OpenAI-compatible or local routes, required static headers belong in `transportConfig.openaiShim.headers`. -Optional user-editable API mode and auth/header fields should be allowed only -when the route really supports them: +Optional user-editable API mode, auth header, auth-value, and custom-header +fields should be allowed only when the route really supports them: ```ts transportConfig: { @@ -484,14 +499,23 @@ transportConfig: { }, supportsApiFormatSelection: false, supportsAuthHeaders: true, + ui: { + showAuthHeader: false, + showAuthHeaderValue: false, + showCustomHeaders: true, + }, }, } ``` Do not use custom headers as a substitute for transport-family selection. -Set these flags explicitly. When `supportsAuthHeaders` is false, `/provider -add` and `/provider edit` should only expose the route's normal credential -fields. When `supportsApiFormatSelection` is false, `/provider add` and +Set these flags explicitly. `supportsAuthHeaders` enables header customization +in general, including auth header prompts and arbitrary custom headers. +When it is false, `/provider add` and `/provider edit` should only expose the +route's normal credential fields. When it is true, the +`openaiShim.ui.showAuthHeader`, `showAuthHeaderValue`, and +`showCustomHeaders` flags decide which header-related prompts are visible. +When `supportsApiFormatSelection` is false, `/provider add` and `/provider edit` should not expose the API mode picker. Use: @@ -501,10 +525,21 @@ Use: - `supportsApiFormatSelection: false` for fixed hosted or local routes where the descriptor owns the API contract. - `supportsAuthHeaders: true` - for gateways that support user-provided custom auth/header fields. + for gateways that support any user-configurable header behavior, including + auth header names, auth header values, or arbitrary custom headers. - `supportsAuthHeaders: false` for gateways that require a fixed auth contract and should only collect the configured credential. +- `ui.showAuthHeader: false` + when the route has descriptor-owned auth and the preset flow should not ask + users for an auth header name. Pair this with `defaultAuthHeader` when the + descriptor should route the collected API key to a nonstandard auth header. +- `ui.showAuthHeaderValue: false` + when the preset flow should collect only the header name and reuse the API key + as the header value. +- `ui.showCustomHeaders: false` + when the route supports gateway header behavior but the built-in preset should + not expose arbitrary extra headers. ## Presets and user-facing gateway onboarding diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 8a770b27e..54c683561 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -114,6 +114,7 @@ const PRESET_ORDER = [ 'Codex OAuth', 'Google Gemini', 'Groq', + 'Hicap', 'LM Studio', 'Atomic Chat', 'Ollama', @@ -206,6 +207,16 @@ function mockProviderProfilesModule(options?: { } } + if (preset === 'hicap') { + return { + provider: 'hicap', + name: 'Hicap', + baseUrl: 'https://api.hicap.ai/v1', + model: 'claude-opus-4.7', + apiKey: '', + } + } + return { provider: 'openai', name: 'Mock provider', @@ -631,6 +642,163 @@ test('ProviderManager skips advanced auth fields when adding MiniMax', async () } }) +test('ProviderManager explains when Hicap non-gpt responses mode is saved as chat completions', async () => { + mockProviderManagerDependencies(() => undefined, async () => undefined, { + addProviderProfile: (payload: any) => ({ + id: 'hicap_profile', + ...payload, + apiFormat: + payload.provider === 'hicap' && + payload.model === 'claude-opus-4.7' && + payload.apiFormat === 'responses' + ? 'chat_completions' + : payload.apiFormat, + }), + }) + + 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, 'Hicap') + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Provider name'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Base URL'), + ) + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Default model'), + ) + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('API mode'), + ) + + mounted.stdin.write('j') + await Bun.sleep(25) + mounted.stdin.write('\r') + const apiKeyOutput = await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Step 5 of 6: API key'), + ) + expect(apiKeyOutput).not.toContain('Auth header') + expect(apiKeyOutput).not.toContain('Auth header value') + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Step 6 of 6: Custom headers'), + ) + mounted.stdin.write('\r') + + const output = await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Hicap only supports the Responses API for gpt- models'), + ) + expect(output).toMatch(/saved\s+using Chat Completions/) + } finally { + await mounted.dispose() + } +}) + +test('ProviderManager clears hidden Hicap auth fields when editing', async () => { + const legacyHicapProfile = { + id: 'provider_legacy_hicap', + provider: 'hicap', + name: 'Legacy Hicap', + baseUrl: 'https://api.hicap.ai/v1', + model: 'claude-opus-4.7', + apiKey: 'hicap-key', + apiFormat: 'chat_completions', + authHeader: 'Authorization', + authHeaderValue: 'stale-hidden-secret', + customHeaders: { + 'X-Regular-Header': 'kept', + }, + } + const updateProviderProfile = mock((id: string, payload: any) => ({ + ...legacyHicapProfile, + id, + ...payload, + })) + + mockProviderManagerDependencies( + () => undefined, + async () => undefined, + { + getProviderProfiles: () => [legacyHicapProfile], + getActiveProviderProfile: () => legacyHicapProfile, + updateProviderProfile, + }, + ) + + 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('Legacy Hicap'), + ) + + await Bun.sleep(25) + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Edit provider profile') && + frame.includes('Step 1 of 6'), + ) + + for (let step = 2; step <= 6; step++) { + mounted.stdin.write('\r') + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes(`Step ${step} of 6`), + ) + } + mounted.stdin.write('\r') + + await waitForCondition(() => updateProviderProfile.mock.calls.length > 0) + expect(updateProviderProfile).toHaveBeenCalledWith( + 'provider_legacy_hicap', + expect.objectContaining({ + provider: 'hicap', + customHeaders: { + 'X-Regular-Header': 'kept', + }, + }), + ) + expect(updateProviderProfile.mock.calls[0]?.[1]).toMatchObject({ + authHeader: undefined, + authScheme: undefined, + authHeaderValue: undefined, + }) + } finally { + await mounted.dispose() + } +}) + test('ProviderManager skips advanced fields for legacy Kimi Code profiles', async () => { const legacyKimiProfile = { id: 'provider_legacy_kimi', diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 111c23e51..d88a1cb3a 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -24,13 +24,18 @@ import { import { getProviderPresetUiMetadata, getRouteProviderTypeLabel, + getRouteDescriptor, ORDERED_PROVIDER_PRESETS, routeSupportsApiFormatSelection, routeSupportsAuthHeaders, routeSupportsCustomHeaders, + routeShowsAuthHeader, + routeShowsAuthHeaderValue, + routeShowsCustomHeaders, resolveProfileRoute, resolveRouteIdFromBaseUrl, } from '../integrations/index.js' +import { openAIShimSupportsApiFormatForModel } from '../integrations/runtimeMetadata.js' import { probeRouteReadiness } from '../integrations/discoveryService.js' import { addProviderProfile, @@ -266,6 +271,25 @@ function resolveProviderEditorRouteId( return resolveRouteIdFromBaseUrl(baseUrl) ?? route } +function routeSupportsResponsesModel(routeId: string, model: string): boolean { + return openAIShimSupportsApiFormatForModel( + getRouteDescriptor(routeId)?.transportConfig.openaiShim, + 'responses', + getPrimaryModel(model), + ) +} + +function getResponsesApiModelSetLabel(routeId: string): string { + const prefixes = + getRouteDescriptor(routeId)?.transportConfig.openaiShim + ?.responsesApiModelPrefixes + if (!prefixes || prefixes.length === 0) { + return "this provider's configured model set" + } + + return `${prefixes.join(', ')} models` +} + async function resolveGithubCredentialSource( processEnv: NodeJS.ProcessEnv = process.env, ): Promise { @@ -536,16 +560,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const formSteps = React.useMemo( () => { const routeId = resolveProviderEditorRouteId(draftProvider, draft.baseUrl) - const supportsCustomHeaders = routeSupportsCustomHeaders(routeId) + const showsAuthHeader = routeShowsAuthHeader(routeId) + const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId) + const showsCustomHeaders = routeShowsCustomHeaders(routeId) return FORM_STEPS.filter(step => { if (step.key === 'apiFormat') { return routeSupportsApiFormatSelection(routeId) } - if (step.key === 'authHeader' || step.key === 'authHeaderValue') { - return routeSupportsAuthHeaders(routeId) + if (step.key === 'authHeader') { + return showsAuthHeader + } + if (step.key === 'authHeaderValue') { + return showsAuthHeaderValue } if (step.key === 'customHeaders') { - return supportsCustomHeaders + return showsCustomHeaders } return true }) @@ -959,7 +988,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { } function closeWithCancelled(message: string): void { - onDone({ action: 'cancelled', message }) + onDone({ + action: 'cancelled', + message: + message === 'Provider manager closed' && statusMessage + ? statusMessage + : message, + }) } function activateGithubProvider(): string | null { @@ -1100,41 +1135,46 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { } function persistDraft(nextDraft: ProviderDraft = draft): void { + const routeId = resolveProviderEditorRouteId(draftProvider, nextDraft.baseUrl) + const supportsApiFormat = routeSupportsApiFormatSelection(routeId) + const showsAuthHeader = routeShowsAuthHeader(routeId) + const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId) + const showsCustomHeaders = routeShowsCustomHeaders(routeId) const parsedCustomHeaders = parseProfileCustomHeadersInput( - nextDraft.customHeaders, + showsCustomHeaders ? nextDraft.customHeaders : '', ) if (parsedCustomHeaders.error) { setErrorMessage(parsedCustomHeaders.error) return } - const routeId = resolveProviderEditorRouteId(draftProvider, nextDraft.baseUrl) - const supportsApiFormat = routeSupportsApiFormatSelection(routeId) - const supportsAuthHeaders = routeSupportsAuthHeaders(routeId) + const requestedResponses = + supportsApiFormat && nextDraft.apiFormat === 'responses' + const shouldUseChatCompletions = + !supportsApiFormat || + nextDraft.apiFormat !== 'responses' || + !routeSupportsResponsesModel(routeId, nextDraft.model) const payload: ProviderProfileInput = { provider: draftProvider, name: nextDraft.name, baseUrl: nextDraft.baseUrl, model: nextDraft.model, apiKey: nextDraft.apiKey, - apiFormat: - supportsApiFormat && nextDraft.apiFormat === 'responses' - ? 'responses' - : 'chat_completions', + apiFormat: shouldUseChatCompletions ? 'chat_completions' : 'responses', authHeader: - supportsAuthHeaders && nextDraft.authHeader + showsAuthHeader && nextDraft.authHeader ? nextDraft.authHeader : undefined, authScheme: - supportsAuthHeaders && nextDraft.authHeader + showsAuthHeader && nextDraft.authHeader ? (nextDraft.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw') : undefined, authHeaderValue: - supportsAuthHeaders && nextDraft.authHeaderValue + showsAuthHeaderValue && nextDraft.authHeaderValue ? nextDraft.authHeaderValue : undefined, customHeaders: - routeSupportsCustomHeaders(routeId) && + showsCustomHeaders && Object.keys(parsedCustomHeaders.headers).length > 0 ? parsedCustomHeaders.headers : undefined, @@ -1166,17 +1206,26 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { editingProfileId ? `Updated provider: ${saved.name}` : `Added provider: ${saved.name} (now active)` + const adjustedApiFormat = + requestedResponses && saved.apiFormat !== 'responses' + const routeLabel = + getRouteDescriptor(routeId)?.label ?? getRouteProviderTypeLabel(routeId) + const responseModelSetLabel = getResponsesApiModelSetLabel(routeId) + const apiFormatMessage = adjustedApiFormat + ? `. ${routeLabel} only supports the Responses API for ${responseModelSetLabel}, so this profile was saved using Chat Completions.` + : '' + const finalSuccessMessage = `${successMessage}${apiFormatMessage}` setStatusMessage( settingsOverrideError - ? `${successMessage}. Warning: could not clear startup provider override (${settingsOverrideError}).` - : successMessage, + ? `${finalSuccessMessage}. Warning: could not clear startup provider override (${settingsOverrideError}).` + : finalSuccessMessage, ) if (mode === 'first-run') { onDone({ action: 'saved', activeProfileId: saved.id, - message: `Provider configured: ${saved.name}`, + message: `Provider configured: ${saved.name}${apiFormatMessage}`, }) return } diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 452744b5f..71cc099ce 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -25,6 +25,7 @@ const EXPECTED_PRESETS = [ 'mistral', 'together', 'groq', + 'hicap', 'azure-openai', 'openrouter', 'lmstudio', diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index d2f2f4181..eacd5092f 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -14,11 +14,26 @@ export type TransportKind = | 'vertex' export type OpenAIShimTokenField = 'max_tokens' | 'max_completion_tokens' +export type OpenAIShimAuthScheme = 'bearer' | 'raw' + +export interface OpenAIShimAuthHeaderConfig { + name: string + scheme?: OpenAIShimAuthScheme +} + +export interface OpenAIShimUiConfig { + showAuthHeader?: boolean + showAuthHeaderValue?: boolean + showCustomHeaders?: boolean +} export interface OpenAIShimTransportConfig { headers?: Record supportsApiFormatSelection?: boolean supportsAuthHeaders?: boolean + ui?: OpenAIShimUiConfig + defaultAuthHeader?: OpenAIShimAuthHeaderConfig + responsesApiModelPrefixes?: string[] preserveReasoningContent?: boolean requireReasoningContentOnAssistantMessages?: boolean reasoningContentFallback?: '' | 'omit' @@ -85,6 +100,7 @@ export type ModelDiscoveryKind = 'openai-compatible' | 'ollama' | 'custom' export interface ModelDiscoveryConfig { kind: ModelDiscoveryKind + requiresAuth?: boolean path?: string parse?: 'openai-models-list' | 'ollama-tags' | 'custom' mapModel?: (raw: unknown) => ModelCatalogEntry | null diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 3de6990d9..6b3f5b5c4 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -284,6 +284,61 @@ describe('discoverModelsForRoute', () => { expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['discovered-model']) }) + test('openai-compatible discovery can opt out of auth', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + + registerGateway({ + id: 'discovery-no-auth-test', + label: 'Discovery No Auth Test', + category: 'hosted', + defaultBaseUrl: 'https://discovery-no-auth-test.example/v1', + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['DISCOVERY_NO_AUTH_TEST_API_KEY'], + }, + transportConfig: { + kind: 'openai-compatible', + }, + catalog: { + source: 'dynamic', + discovery: { + kind: 'openai-compatible', + requiresAuth: false, + }, + discoveryCacheTtl: '1d', + }, + }) + + let callCount = 0 + setMockFetch(mock((_input, init) => { + callCount++ + expect(init?.headers).toBeUndefined() + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: 'public-model' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await discoverModelsForRoute('discovery-no-auth-test', { + apiKey: 'discovery-key', + forceRefresh: true, + }) + const cached = await discoverModelsForRoute('discovery-no-auth-test', { + apiKey: 'different-discovery-key', + }) + + expect(result?.source).toBe('network') + expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['public-model']) + expect(cached?.source).toBe('cache') + expect(cached?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['public-model']) + expect(callCount).toBe(1) + }) + test('skips descriptor network discovery when nonessential traffic is disabled', async () => { process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1' process.env.OPENROUTER_API_KEY = 'or-key' diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index c6335c174..7fa6cfd66 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -121,10 +121,11 @@ export function getDiscoveryCacheKey( headers?: Record }, ): string { + const discoveryApiKey = getRouteDiscoveryApiKey(routeId, options) const partition = { baseUrl: normalizeDiscoveryCacheBaseUrl(getRouteBaseUrl(routeId, options)), - apiKeyHash: options?.apiKey?.trim() - ? hashDiscoveryCachePartition(options.apiKey.trim()) + apiKeyHash: discoveryApiKey + ? hashDiscoveryCachePartition(discoveryApiKey) : '', headers: normalizeDiscoveryCacheHeaders( getRouteDiscoveryHeaders(routeId, options), @@ -145,6 +146,10 @@ function getRouteDiscoveryApiKey( routeId: string, options?: { apiKey?: string }, ): string | undefined { + if (getRouteCatalog(routeId)?.discovery?.requiresAuth === false) { + return undefined + } + if (options?.apiKey?.trim()) { return options.apiKey.trim() } diff --git a/src/integrations/gateways/hicap.ts b/src/integrations/gateways/hicap.ts new file mode 100644 index 000000000..e87de23c5 --- /dev/null +++ b/src/integrations/gateways/hicap.ts @@ -0,0 +1,68 @@ +import { defineGateway } from '../define.js' + +export default defineGateway({ + id: 'hicap', + label: 'Hicap', + category: 'aggregating', + defaultBaseUrl: 'https://api.hicap.ai/v1', + defaultModel: 'claude-opus-4.7', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['HICAP_API_KEY'], + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: true, + supportsAuthHeaders: true, + ui: { + showAuthHeader: false, + showAuthHeaderValue: false, + showCustomHeaders: true, + }, + defaultAuthHeader: { + name: 'api-key', + scheme: 'raw', + }, + responsesApiModelPrefixes: ['gpt-'], + }, + }, + preset: { + id: 'hicap', + description: 'Hicap OpenAI-compatible gateway', + apiKeyEnvVars: ['HICAP_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + vendorId: 'openai', + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + matchBaseUrlHosts: ['api.hicap.ai'], + }, + credentialEnvVars: ['HICAP_API_KEY', 'OPENAI_API_KEY'], + missingCredentialMessage: + 'Set HICAP_API_KEY or OPENAI_API_KEY for the Hicap provider.', + }, + catalog: { + source: 'hybrid', + discovery: { kind: 'openai-compatible', requiresAuth: false }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [ + { + id: 'hicap-claude-opus-4.7', + apiName: 'claude-opus-4.7', + label: 'Claude Opus 4.7', + modelDescriptorId: 'claude-opus-4-7', + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 33ce2d472..90ddaef4c 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -19,6 +19,7 @@ import gatewayDashscopeCn from '../gateways/dashscope-cn.js' import gatewayDashscopeIntl from '../gateways/dashscope-intl.js' import gatewayGithub from '../gateways/github.js' import gatewayGroq from '../gateways/groq.js' +import gatewayHicap from '../gateways/hicap.js' import gatewayKimiCode from '../gateways/kimi-code.js' import gatewayLmstudio from '../gateways/lmstudio.js' import gatewayMistral from '../gateways/mistral.js' @@ -55,7 +56,7 @@ import modelQwen from '../models/qwen.js' import modelXai from '../models/xai.js' export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorXai, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGroq, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[] export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandXai] as const satisfies readonly BrandDescriptor[] export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNemotron, modelOpenaiCompatibleAlias, modelQwen, modelXai] as const satisfies readonly (readonly ModelDescriptor[])[] @@ -156,6 +157,20 @@ export const PROVIDER_PRESET_MANIFEST = [ "GROQ_API_KEY" ] }, + { + "preset": "hicap", + "routeKind": "gateway", + "routeId": "hicap", + "vendorId": "openai", + "gatewayId": "hicap", + "description": "Hicap OpenAI-compatible gateway", + "apiKeyEnvVars": [ + "HICAP_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "lmstudio", "routeKind": "gateway", @@ -336,6 +351,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "deepseek", "gemini", "groq", + "hicap", "lmstudio", "atomic-chat", "ollama", diff --git a/src/integrations/index.ts b/src/integrations/index.ts index eba10eb7d..f0463d37e 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -118,6 +118,9 @@ export { routeSupportsApiFormatSelection, routeSupportsAuthHeaders, routeSupportsCustomHeaders, + routeShowsAuthHeader, + routeShowsAuthHeaderValue, + routeShowsCustomHeaders, } from './routeMetadata.js' export { getProviderPresetUiMetadata, diff --git a/src/integrations/models/claude.ts b/src/integrations/models/claude.ts index ef9ab89a9..7e3e422bb 100644 --- a/src/integrations/models/claude.ts +++ b/src/integrations/models/claude.ts @@ -19,6 +19,24 @@ export default [ contextWindow: 200_000, maxOutputTokens: 8192, }), + defineModel({ + id: 'claude-opus-4-7', + label: 'Claude Opus 4.7', + brandId: 'claude', + vendorId: 'anthropic', + classification: ['chat', 'reasoning', 'vision', 'coding'], + defaultModel: 'claude-opus-4-7', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 8192, + }), defineModel({ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', diff --git a/src/integrations/routeMetadata.test.ts b/src/integrations/routeMetadata.test.ts index 1505703db..3f9e51319 100644 --- a/src/integrations/routeMetadata.test.ts +++ b/src/integrations/routeMetadata.test.ts @@ -37,6 +37,10 @@ test('getRouteCredentialEnvVars keeps descriptor env vars and openai fallback fo 'DEEPSEEK_API_KEY', 'OPENAI_API_KEY', ]) + expect(getRouteCredentialEnvVars('hicap')).toEqual([ + 'HICAP_API_KEY', + 'OPENAI_API_KEY', + ]) expect(getRouteCredentialEnvVars('custom')).toEqual(['OPENAI_API_KEY']) }) @@ -104,6 +108,7 @@ test.each([ ['NVIDIA NIM', 'https://integrate.api.nvidia.com/v1', 'nvidia/llama-3.1-nemotron-70b-instruct', 'nvidia-nim'], ['OpenRouter', 'https://openrouter.ai/api/v1', 'openai/gpt-5-mini', 'openrouter'], ['DeepSeek', 'https://api.deepseek.com/v1', 'deepseek-v4-pro', 'deepseek'], + ['Hicap', 'https://api.hicap.ai/v1', 'claude-opus-4.7', 'hicap'], ])( 'resolveActiveRouteIdFromEnv refines generic OpenAI profile by %s base URL', (_label, baseUrl, model, expectedRouteId) => { diff --git a/src/integrations/routeMetadata.ts b/src/integrations/routeMetadata.ts index 232b492fe..feff982eb 100644 --- a/src/integrations/routeMetadata.ts +++ b/src/integrations/routeMetadata.ts @@ -368,6 +368,42 @@ export function routeSupportsCustomHeaders( return descriptor.transportConfig.openaiShim?.supportsAuthHeaders === true } +export function routeShowsAuthHeaderValue(routeId: string): boolean { + const descriptor = getRouteDescriptor(routeId) + if (!descriptor) { + return false + } + + return ( + descriptor.transportConfig.openaiShim?.supportsAuthHeaders === true && + descriptor.transportConfig.openaiShim?.ui?.showAuthHeaderValue !== false + ) +} + +export function routeShowsAuthHeader(routeId: string): boolean { + const descriptor = getRouteDescriptor(routeId) + if (!descriptor) { + return false + } + + return ( + descriptor.transportConfig.openaiShim?.supportsAuthHeaders === true && + descriptor.transportConfig.openaiShim?.ui?.showAuthHeader !== false + ) +} + +export function routeShowsCustomHeaders(routeId: string): boolean { + const descriptor = getRouteDescriptor(routeId) + if (!descriptor) { + return false + } + + return ( + routeSupportsCustomHeaders(routeId) && + descriptor.transportConfig.openaiShim?.ui?.showCustomHeaders !== false + ) +} + function routeSupportsOpenAIShimOption( routeId: string, option: 'supportsApiFormatSelection' | 'supportsAuthHeaders', diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index fb329860f..f1bd4a02f 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -104,6 +104,36 @@ function mergeOpenAIShimConfig( } } +function normalizePrefix(value: string): string { + return value.trim().toLowerCase() +} + +export function openAIShimSupportsApiFormatForModel( + config: + | Pick + | undefined, + apiFormat: 'responses', + modelApiName: string | undefined, +): boolean { + const prefixes = + apiFormat === 'responses' + ? config?.responsesApiModelPrefixes + ?.map(normalizePrefix) + .filter(Boolean) + : undefined + + if (!prefixes || prefixes.length === 0) { + return true + } + + const normalizedModel = normalizeModelApiName(modelApiName) + if (!normalizedModel) { + return false + } + + return prefixes.some(prefix => normalizedModel.startsWith(prefix)) +} + function inferRemoteModelOpenAIShimConfig( modelApiName: string | undefined, ): Partial | undefined { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 1907e3dd4..9a6f0fd95 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -418,6 +418,40 @@ test('uses custom OpenAI-compatible auth header value when configured', async () expect(capturedHeaders?.get('authorization')).toBeNull() }) +test('uses Hicap api-key auth header for the Hicap route', async () => { + process.env.OPENAI_API_KEY = 'hicap-live-key' + process.env.OPENAI_BASE_URL = 'https://api.hicap.ai/v1' + let capturedHeaders: Headers | undefined + + globalThis.fetch = (async (_input, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({ defaultHeaders: {} }) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'claude-opus-4.7', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(capturedHeaders?.get('api-key')).toBe('hicap-live-key') + expect(capturedHeaders?.get('authorization')).toBeNull() +}) + test('defaults Authorization custom auth header to bearer scheme', async () => { process.env.OPENAI_API_KEY = 'authorization-key' process.env.OPENAI_AUTH_HEADER = 'Authorization' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index adc8bd87f..482345a6c 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1780,6 +1780,11 @@ class OpenAIShimMessages { } else if (isBankr) { // Bankr uses X-API-Key header instead of Bearer token headers['X-API-Key'] = authValue + } else if (shimConfig.defaultAuthHeader?.name) { + headers[shimConfig.defaultAuthHeader.name] = + shimConfig.defaultAuthHeader.scheme === 'bearer' + ? `Bearer ${authValue}` + : authValue } else { headers.Authorization = `Bearer ${authValue}` } diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index 728a61d41..5ac5645c2 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -133,6 +133,34 @@ test('uses responses transport when OpenAI-compatible API format requests respon }) }) +test('uses responses transport for Hicap gpt models when requested', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.hicap.ai/v1' + process.env.OPENAI_MODEL = 'gpt-5.4' + process.env.OPENAI_API_FORMAT = 'responses' + + expect(resolveProviderRequest()).toMatchObject({ + transport: 'responses', + requestedModel: 'gpt-5.4', + resolvedModel: 'gpt-5.4', + baseUrl: 'https://api.hicap.ai/v1', + }) +}) + +test('falls back to chat completions for non-gpt Hicap models when responses is requested', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.hicap.ai/v1' + process.env.OPENAI_MODEL = 'claude-opus-4.7' + process.env.OPENAI_API_FORMAT = 'responses' + + expect(resolveProviderRequest()).toMatchObject({ + transport: 'chat_completions', + requestedModel: 'claude-opus-4.7', + resolvedModel: 'claude-opus-4.7', + baseUrl: 'https://api.hicap.ai/v1', + }) +}) + test('keeps Codex backend on Codex responses transport even when API format is set', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' process.env.OPENAI_BASE_URL = 'https://chatgpt.com/backend-api/codex' diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index e2cd3c5ed..6b4f7b825 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -19,6 +19,10 @@ import { DEFAULT_GEMINI_BASE_URL, DEFAULT_GEMINI_MODEL, } from 'src/utils/providerProfile.js' +import { + openAIShimSupportsApiFormatForModel, + resolveOpenAIShimRuntimeContext, +} from '../../integrations/runtimeMetadata.js' export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' @@ -618,11 +622,27 @@ export function resolveProviderRequest(options?: { ? undefined : parseOpenAICompatibleApiFormat(options?.apiFormat) ?? parseOpenAICompatibleApiFormat(process.env.OPENAI_API_FORMAT) + const supportsRequestedApiFormat = + requestedApiFormat !== 'responses' || + (() => { + const runtimeShimContext = resolveOpenAIShimRuntimeContext({ + processEnv: process.env, + baseUrl: finalBaseUrl, + model: descriptor.baseModel, + treatAsLocal: finalBaseUrl ? isLocalProviderUrl(finalBaseUrl) : false, + }) + + return openAIShimSupportsApiFormatForModel( + runtimeShimContext.openaiShimConfig, + 'responses', + descriptor.baseModel, + ) + })() const transport: ProviderTransport = shouldUseCodexTransport(requestedModel, finalBaseUrl) || (isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel)) ? 'codex_responses' - : requestedApiFormat === 'responses' + : requestedApiFormat === 'responses' && supportsRequestedApiFormat ? 'responses' : 'chat_completions' diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 94ed5d521..05cc09195 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -57,6 +57,7 @@ const RESTORED_KEYS = [ 'BNKR_API_KEY', 'BANKR_MODEL', 'XAI_API_KEY', + 'HICAP_API_KEY', ] as const type MockConfigState = { @@ -411,7 +412,7 @@ describe('applyProviderProfileToProcessEnv', () => { buildProfile({ provider: 'openai', baseUrl: 'https://api.hicap.ai/v1', - model: 'claude-opus-4.6', + model: 'claude-opus-4.7', authHeader: 'api-key', authScheme: 'raw', authHeaderValue: 'hicap-header-value', @@ -1002,6 +1003,20 @@ describe('getProviderPresetDefaults', () => { expect(defaults.requiresApiKey).toBe(true) }) + test('hicap preset defaults to the Hicap endpoint', async () => { + const { getProviderPresetDefaults } = await importFreshProviderProfileModules() + process.env.HICAP_API_KEY = 'hicap-live-key' + + const defaults = getProviderPresetDefaults('hicap') + + expect(defaults.provider).toBe('hicap') + expect(defaults.name).toBe('Hicap') + expect(defaults.baseUrl).toBe('https://api.hicap.ai/v1') + expect(defaults.model).toBe('claude-opus-4.7') + expect(defaults.apiKey).toBe('hicap-live-key') + expect(defaults.requiresApiKey).toBe(true) + }) + test('minimax preset defaults to MiniMax M2.7', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules()