mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const EXPECTED_PRESETS = [
|
||||
'mistral',
|
||||
'together',
|
||||
'groq',
|
||||
'hicap',
|
||||
'azure-openai',
|
||||
'openrouter',
|
||||
'lmstudio',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -118,6 +118,9 @@ export {
|
||||
routeSupportsApiFormatSelection,
|
||||
routeSupportsAuthHeaders,
|
||||
routeSupportsCustomHeaders,
|
||||
routeShowsAuthHeader,
|
||||
routeShowsAuthHeaderValue,
|
||||
routeShowsCustomHeaders,
|
||||
} from './routeMetadata.js'
|
||||
export {
|
||||
getProviderPresetUiMetadata,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user