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
This commit is contained in:
TechBrewBoss
2026-05-04 16:21:11 +08:00
committed by GitHub
parent 3d791bf07f
commit 884746dbe9
22 changed files with 649 additions and 35 deletions
+4
View File
@@ -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
+1
View File
@@ -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 |
+2
View File
@@ -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`,
+45 -10
View File
@@ -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
+168
View File
@@ -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',
+69 -20
View File
@@ -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<GithubCredentialSource> {
@@ -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
}
+1
View File
@@ -25,6 +25,7 @@ const EXPECTED_PRESETS = [
'mistral',
'together',
'groq',
'hicap',
'azure-openai',
'openrouter',
'lmstudio',
+16
View File
@@ -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<string, string>
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
+55
View File
@@ -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'
+7 -2
View File
@@ -121,10 +121,11 @@ export function getDiscoveryCacheKey(
headers?: Record<string, string>
},
): 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()
}
+68
View File
@@ -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 },
})
@@ -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",
+3
View File
@@ -118,6 +118,9 @@ export {
routeSupportsApiFormatSelection,
routeSupportsAuthHeaders,
routeSupportsCustomHeaders,
routeShowsAuthHeader,
routeShowsAuthHeaderValue,
routeShowsCustomHeaders,
} from './routeMetadata.js'
export {
getProviderPresetUiMetadata,
+18
View File
@@ -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',
+5
View File
@@ -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) => {
+36
View File
@@ -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',
+30
View File
@@ -104,6 +104,36 @@ function mergeOpenAIShimConfig(
}
}
function normalizePrefix(value: string): string {
return value.trim().toLowerCase()
}
export function openAIShimSupportsApiFormatForModel(
config:
| Pick<OpenAIShimTransportConfig, 'responsesApiModelPrefixes'>
| 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<OpenAIShimTransportConfig> | undefined {
+34
View File
@@ -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'
+5
View File
@@ -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}`
}
@@ -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'
+21 -1
View File
@@ -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'
+16 -1
View File
@@ -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()