Add OpenAI responses mode and custom auth headers (#906)
* Add OpenAI profile responses and custom auth header support * Fix knowledge graph config reference in query loop * Address OpenAI profile review edge cases * Remove unused getGlobalConfig import Delete an unused import of getGlobalConfig from src/query.ts. This cleans up dead code and avoids unused-import lint warnings; no functional behavior changes. * Address follow-up OpenAI profile review comments * Refine OpenAI responses auth review fixes * Fix custom auth header default scheme
This commit is contained in:
@@ -157,6 +157,15 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|||||||
|
|
||||||
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
|
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# Choose the OpenAI-compatible API surface (optional — defaults to chat_completions)
|
||||||
|
# Supported: chat_completions, responses
|
||||||
|
# OPENAI_API_FORMAT=chat_completions
|
||||||
|
# Choose a custom auth header for OpenAI-compatible providers (optional).
|
||||||
|
# Authorization defaults to Bearer; custom headers default to the raw API key.
|
||||||
|
# Set OPENAI_AUTH_HEADER_VALUE when the header value differs from OPENAI_API_KEY.
|
||||||
|
# OPENAI_AUTH_HEADER=api-key
|
||||||
|
# OPENAI_AUTH_SCHEME=raw
|
||||||
|
# OPENAI_AUTH_HEADER_VALUE=your-header-value-here
|
||||||
|
|
||||||
# Fallback context window size (tokens) when the model is not found in the
|
# Fallback context window size (tokens) when the model is not found in the
|
||||||
# built-in table (default: 128000). Increase this for models with larger
|
# built-in table (default: 128000). Increase this for models with larger
|
||||||
|
|||||||
@@ -1061,25 +1061,43 @@ test('ProviderManager editing an active multi-model provider keeps app state on
|
|||||||
mounted.getOutput,
|
mounted.getOutput,
|
||||||
frame =>
|
frame =>
|
||||||
frame.includes('Edit provider profile') &&
|
frame.includes('Edit provider profile') &&
|
||||||
frame.includes('Step 1 of 4'),
|
frame.includes('Step 1 of 7'),
|
||||||
)
|
)
|
||||||
|
|
||||||
mounted.stdin.write('\r')
|
mounted.stdin.write('\r')
|
||||||
await waitForFrameOutput(
|
await waitForFrameOutput(
|
||||||
mounted.getOutput,
|
mounted.getOutput,
|
||||||
frame => frame.includes('Step 2 of 4'),
|
frame => frame.includes('Step 2 of 7'),
|
||||||
)
|
)
|
||||||
|
|
||||||
mounted.stdin.write('\r')
|
mounted.stdin.write('\r')
|
||||||
await waitForFrameOutput(
|
await waitForFrameOutput(
|
||||||
mounted.getOutput,
|
mounted.getOutput,
|
||||||
frame => frame.includes('Step 3 of 4'),
|
frame => frame.includes('Step 3 of 7'),
|
||||||
)
|
)
|
||||||
|
|
||||||
mounted.stdin.write('\r')
|
mounted.stdin.write('\r')
|
||||||
await waitForFrameOutput(
|
await waitForFrameOutput(
|
||||||
mounted.getOutput,
|
mounted.getOutput,
|
||||||
frame => frame.includes('Step 4 of 4'),
|
frame => frame.includes('Step 4 of 7'),
|
||||||
|
)
|
||||||
|
|
||||||
|
mounted.stdin.write('\r')
|
||||||
|
await waitForFrameOutput(
|
||||||
|
mounted.getOutput,
|
||||||
|
frame => frame.includes('Step 5 of 7'),
|
||||||
|
)
|
||||||
|
|
||||||
|
mounted.stdin.write('\r')
|
||||||
|
await waitForFrameOutput(
|
||||||
|
mounted.getOutput,
|
||||||
|
frame => frame.includes('Step 6 of 7'),
|
||||||
|
)
|
||||||
|
|
||||||
|
mounted.stdin.write('\r')
|
||||||
|
await waitForFrameOutput(
|
||||||
|
mounted.getOutput,
|
||||||
|
frame => frame.includes('Step 7 of 7'),
|
||||||
)
|
)
|
||||||
|
|
||||||
mounted.stdin.write('\r')
|
mounted.stdin.write('\r')
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ type Screen =
|
|||||||
| 'select-edit'
|
| 'select-edit'
|
||||||
| 'select-delete'
|
| 'select-delete'
|
||||||
|
|
||||||
type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey'
|
type DraftField =
|
||||||
|
| 'name'
|
||||||
|
| 'baseUrl'
|
||||||
|
| 'model'
|
||||||
|
| 'apiKey'
|
||||||
|
| 'apiFormat'
|
||||||
|
| 'authHeader'
|
||||||
|
| 'authHeaderValue'
|
||||||
|
|
||||||
type ProviderDraft = Record<DraftField, string>
|
type ProviderDraft = Record<DraftField, string>
|
||||||
|
|
||||||
@@ -130,6 +137,27 @@ const FORM_STEPS: Array<{
|
|||||||
placeholder: 'e.g. llama3.1:8b or glm-4.7; glm-4.7-flash',
|
placeholder: 'e.g. llama3.1:8b or glm-4.7; glm-4.7-flash',
|
||||||
helpText: 'Model name(s) to use. Separate multiple with ";" or ","; first is default.',
|
helpText: 'Model name(s) to use. Separate multiple with ";" or ","; first is default.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'apiFormat',
|
||||||
|
label: 'API mode',
|
||||||
|
placeholder: 'chat_completions',
|
||||||
|
helpText: 'Choose the OpenAI-compatible API surface for this provider.',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'authHeader',
|
||||||
|
label: 'Auth header',
|
||||||
|
placeholder: 'e.g. api-key or X-API-Key',
|
||||||
|
helpText: 'Optional. Header name used for a custom provider key.',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'authHeaderValue',
|
||||||
|
label: 'Auth header value',
|
||||||
|
placeholder: 'Leave empty to use the API key value',
|
||||||
|
helpText: 'Optional. Value sent in the custom auth header.',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'apiKey',
|
key: 'apiKey',
|
||||||
label: 'API key',
|
label: 'API key',
|
||||||
@@ -154,6 +182,9 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
|
|||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
model: profile.model,
|
model: profile.model,
|
||||||
apiKey: profile.apiKey ?? '',
|
apiKey: profile.apiKey ?? '',
|
||||||
|
apiFormat: profile.apiFormat ?? 'chat_completions',
|
||||||
|
authHeader: profile.authHeader ?? '',
|
||||||
|
authHeaderValue: profile.authHeaderValue ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +195,9 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
|
|||||||
baseUrl: defaults.baseUrl,
|
baseUrl: defaults.baseUrl,
|
||||||
model: defaults.model,
|
model: defaults.model,
|
||||||
apiKey: defaults.apiKey ?? '',
|
apiKey: defaults.apiKey ?? '',
|
||||||
|
apiFormat: 'chat_completions',
|
||||||
|
authHeader: '',
|
||||||
|
authHeaderValue: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +211,15 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
|||||||
models.length <= 3
|
models.length <= 3
|
||||||
? models.join(', ')
|
? models.join(', ')
|
||||||
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
|
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
|
||||||
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}`
|
const modeInfo =
|
||||||
|
profile.provider === 'openai'
|
||||||
|
? ` · ${profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}`
|
||||||
|
: ''
|
||||||
|
const authInfo =
|
||||||
|
profile.provider === 'openai' && profile.authHeader
|
||||||
|
? ` · ${profile.authHeader} auth`
|
||||||
|
: ''
|
||||||
|
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGithubCredentialSourceFromEnv(
|
function getGithubCredentialSourceFromEnv(
|
||||||
@@ -456,7 +498,18 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
|
const formSteps = React.useMemo(
|
||||||
|
() =>
|
||||||
|
draftProvider === 'openai'
|
||||||
|
? FORM_STEPS
|
||||||
|
: FORM_STEPS.filter(step =>
|
||||||
|
step.key !== 'apiFormat' &&
|
||||||
|
step.key !== 'authHeader' &&
|
||||||
|
step.key !== 'authHeaderValue'
|
||||||
|
),
|
||||||
|
[draftProvider],
|
||||||
|
)
|
||||||
|
const currentStep = formSteps[formStepIndex] ?? formSteps[0] ?? FORM_STEPS[0]
|
||||||
const currentStepKey = currentStep.key
|
const currentStepKey = currentStep.key
|
||||||
const currentValue = draft[currentStepKey]
|
const currentValue = draft[currentStepKey]
|
||||||
|
|
||||||
@@ -940,6 +993,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
baseUrl: defaults.baseUrl,
|
baseUrl: defaults.baseUrl,
|
||||||
model: defaults.model,
|
model: defaults.model,
|
||||||
apiKey: defaults.apiKey ?? '',
|
apiKey: defaults.apiKey ?? '',
|
||||||
|
apiFormat: 'chat_completions',
|
||||||
|
authHeader: '',
|
||||||
|
authHeaderValue: '',
|
||||||
}
|
}
|
||||||
setEditingProfileId(null)
|
setEditingProfileId(null)
|
||||||
setDraftProvider(defaults.provider ?? 'openai')
|
setDraftProvider(defaults.provider ?? 'openai')
|
||||||
@@ -986,6 +1042,22 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
baseUrl: nextDraft.baseUrl,
|
baseUrl: nextDraft.baseUrl,
|
||||||
model: nextDraft.model,
|
model: nextDraft.model,
|
||||||
apiKey: nextDraft.apiKey,
|
apiKey: nextDraft.apiKey,
|
||||||
|
apiFormat:
|
||||||
|
draftProvider === 'openai' && nextDraft.apiFormat === 'responses'
|
||||||
|
? 'responses'
|
||||||
|
: 'chat_completions',
|
||||||
|
authHeader:
|
||||||
|
draftProvider === 'openai' && nextDraft.authHeader
|
||||||
|
? nextDraft.authHeader
|
||||||
|
: undefined,
|
||||||
|
authScheme:
|
||||||
|
draftProvider === 'openai' && nextDraft.authHeader
|
||||||
|
? (nextDraft.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw')
|
||||||
|
: undefined,
|
||||||
|
authHeaderValue:
|
||||||
|
draftProvider === 'openai' && nextDraft.authHeaderValue
|
||||||
|
? nextDraft.authHeaderValue
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = editingProfileId
|
const saved = editingProfileId
|
||||||
@@ -1208,9 +1280,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
setDraft(nextDraft)
|
setDraft(nextDraft)
|
||||||
setErrorMessage(undefined)
|
setErrorMessage(undefined)
|
||||||
|
|
||||||
if (formStepIndex < FORM_STEPS.length - 1) {
|
if (formStepIndex < formSteps.length - 1) {
|
||||||
const nextIndex = formStepIndex + 1
|
const nextIndex = formStepIndex + 1
|
||||||
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
const nextKey = formSteps[nextIndex]?.key ?? 'name'
|
||||||
setFormStepIndex(nextIndex)
|
setFormStepIndex(nextIndex)
|
||||||
setCursorOffset(nextDraft[nextKey].length)
|
setCursorOffset(nextDraft[nextKey].length)
|
||||||
return
|
return
|
||||||
@@ -1224,7 +1296,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
|
|
||||||
if (formStepIndex > 0) {
|
if (formStepIndex > 0) {
|
||||||
const nextIndex = formStepIndex - 1
|
const nextIndex = formStepIndex - 1
|
||||||
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
const nextKey = formSteps[nextIndex]?.key ?? 'name'
|
||||||
setFormStepIndex(nextIndex)
|
setFormStepIndex(nextIndex)
|
||||||
setCursorOffset(draft[nextKey].length)
|
setCursorOffset(draft[nextKey].length)
|
||||||
return
|
return
|
||||||
@@ -1424,8 +1496,33 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
: 'OpenAI-compatible API'}
|
: 'OpenAI-compatible API'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Step {formStepIndex + 1} of {FORM_STEPS.length}: {currentStep.label}
|
Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{currentStepKey === 'apiFormat' ? (
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'chat_completions',
|
||||||
|
label: 'Chat Completions',
|
||||||
|
description: 'Use /chat/completions for broad OpenAI-compatible support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'responses',
|
||||||
|
label: 'Responses',
|
||||||
|
description: 'Use /responses for providers that support the Responses API',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
defaultValue={
|
||||||
|
currentValue === 'responses' ? 'responses' : 'chat_completions'
|
||||||
|
}
|
||||||
|
defaultFocusValue={
|
||||||
|
currentValue === 'responses' ? 'responses' : 'chat_completions'
|
||||||
|
}
|
||||||
|
onChange={value => handleFormSubmit(value)}
|
||||||
|
onCancel={handleBackFromForm}
|
||||||
|
visibleOptionCount={2}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Box flexDirection="row" gap={1}>
|
<Box flexDirection="row" gap={1}>
|
||||||
<Text>{figures.pointer}</Text>
|
<Text>{figures.pointer}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -1440,12 +1537,18 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
focus={true}
|
focus={true}
|
||||||
showCursor={true}
|
showCursor={true}
|
||||||
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
||||||
mask={currentStepKey === 'apiKey' ? '*' : undefined}
|
mask={
|
||||||
|
currentStepKey === 'apiKey' ||
|
||||||
|
currentStepKey === 'authHeaderValue'
|
||||||
|
? '*'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
columns={80}
|
columns={80}
|
||||||
cursorOffset={cursorOffset}
|
cursorOffset={cursorOffset}
|
||||||
onChangeCursorOffset={setCursorOffset}
|
onChangeCursorOffset={setCursorOffset}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
{errorMessage && <Text color="error">{errorMessage}</Text>}
|
{errorMessage && <Text color="error">{errorMessage}</Text>}
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Press Enter to continue. Press Esc to go back.
|
Press Enter to continue. Press Esc to go back.
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ import {
|
|||||||
} from './bootstrap/state.js'
|
} from './bootstrap/state.js'
|
||||||
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
|
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
|
||||||
import { count } from './utils/array.js'
|
import { count } from './utils/array.js'
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const snipModule = feature('HISTORY_SNIP')
|
const snipModule = feature('HISTORY_SNIP')
|
||||||
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
|
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const originalEnv = {
|
|||||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT,
|
||||||
|
OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER,
|
||||||
|
OPENAI_AUTH_SCHEME: process.env.OPENAI_AUTH_SCHEME,
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: process.env.OPENAI_AUTH_HEADER_VALUE,
|
||||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
GH_TOKEN: process.env.GH_TOKEN,
|
GH_TOKEN: process.env.GH_TOKEN,
|
||||||
@@ -75,6 +79,10 @@ beforeEach(() => {
|
|||||||
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
||||||
process.env.OPENAI_API_KEY = 'test-key'
|
process.env.OPENAI_API_KEY = 'test-key'
|
||||||
delete process.env.OPENAI_MODEL
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.OPENAI_API_FORMAT
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER
|
||||||
|
delete process.env.OPENAI_AUTH_SCHEME
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
delete process.env.GITHUB_TOKEN
|
delete process.env.GITHUB_TOKEN
|
||||||
delete process.env.GH_TOKEN
|
delete process.env.GH_TOKEN
|
||||||
@@ -94,6 +102,10 @@ afterEach(() => {
|
|||||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||||
|
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||||
|
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
|
||||||
|
restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME)
|
||||||
|
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
|
||||||
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
||||||
restoreEnv('GITHUB_TOKEN', originalEnv.GITHUB_TOKEN)
|
restoreEnv('GITHUB_TOKEN', originalEnv.GITHUB_TOKEN)
|
||||||
restoreEnv('GH_TOKEN', originalEnv.GH_TOKEN)
|
restoreEnv('GH_TOKEN', originalEnv.GH_TOKEN)
|
||||||
@@ -172,6 +184,243 @@ test('strips canonical Anthropic headers from direct shim defaultHeaders', async
|
|||||||
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses OpenAI-compatible responses endpoint when OPENAI_API_FORMAT=responses', async () => {
|
||||||
|
process.env.OPENAI_API_FORMAT = 'responses'
|
||||||
|
let capturedUrl = ''
|
||||||
|
let capturedBody: Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input, init) => {
|
||||||
|
capturedUrl = String(input)
|
||||||
|
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'resp-1',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: 'ok' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 8,
|
||||||
|
output_tokens: 3,
|
||||||
|
total_tokens: 11,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({ defaultHeaders: {} }) as OpenAIShimClient
|
||||||
|
|
||||||
|
await client.beta.messages.create({
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
system: 'test system',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedUrl).toBe('http://example.test/v1/responses')
|
||||||
|
expect(capturedBody?.model).toBe('gpt-5.4')
|
||||||
|
expect(capturedBody?.instructions).toBe('test system')
|
||||||
|
expect(capturedBody?.max_output_tokens).toBe(64)
|
||||||
|
expect(capturedBody?.store).toBe(false)
|
||||||
|
expect(capturedBody?.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('strips store from strict OpenAI-compatible responses providers', async () => {
|
||||||
|
process.env.OPENAI_BASE_URL = 'https://api.moonshot.ai/v1'
|
||||||
|
process.env.OPENAI_API_FORMAT = 'responses'
|
||||||
|
let capturedUrl = ''
|
||||||
|
let capturedBody: Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input, init) => {
|
||||||
|
capturedUrl = String(input)
|
||||||
|
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'resp-1',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: 'ok' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({ defaultHeaders: {} }) as OpenAIShimClient
|
||||||
|
|
||||||
|
await client.beta.messages.create({
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedUrl).toBe('https://api.moonshot.ai/v1/responses')
|
||||||
|
expect(capturedBody?.store).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses custom OpenAI-compatible auth header value when configured', async () => {
|
||||||
|
process.env.OPENAI_API_KEY = 'generic-key'
|
||||||
|
process.env.OPENAI_AUTH_HEADER = 'api-key'
|
||||||
|
process.env.OPENAI_AUTH_HEADER_VALUE = 'hicap-header-value'
|
||||||
|
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: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedHeaders?.get('api-key')).toBe('hicap-header-value')
|
||||||
|
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'
|
||||||
|
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: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedHeaders?.get('authorization')).toBe('Bearer authorization-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honors bearer scheme for custom OpenAI-compatible auth headers', async () => {
|
||||||
|
process.env.OPENAI_API_KEY = 'custom-key'
|
||||||
|
process.env.OPENAI_AUTH_HEADER = 'X-Custom-Authorization'
|
||||||
|
process.env.OPENAI_AUTH_SCHEME = 'bearer'
|
||||||
|
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: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedHeaders?.get('x-custom-authorization')).toBe('Bearer custom-key')
|
||||||
|
expect(capturedHeaders?.get('authorization')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores custom auth header value when no custom header is configured', async () => {
|
||||||
|
delete process.env.OPENAI_API_KEY
|
||||||
|
process.env.OPENAI_AUTH_HEADER_VALUE = 'gateway-header-value'
|
||||||
|
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: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedHeaders?.get('authorization')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('strips canonical Anthropic headers from per-request shim headers too', async () => {
|
test('strips canonical Anthropic headers from per-request shim headers too', async () => {
|
||||||
let capturedHeaders: Headers | undefined
|
let capturedHeaders: Headers | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
* Environment variables:
|
* Environment variables:
|
||||||
* CLAUDE_CODE_USE_OPENAI=1 — enable this provider
|
* CLAUDE_CODE_USE_OPENAI=1 — enable this provider
|
||||||
* OPENAI_API_KEY=sk-... — API key (optional for local models)
|
* OPENAI_API_KEY=sk-... — API key (optional for local models)
|
||||||
|
* OPENAI_AUTH_HEADER=api-key — optional custom auth header name
|
||||||
|
* OPENAI_AUTH_HEADER_VALUE=... — optional custom auth header value
|
||||||
|
* OPENAI_AUTH_SCHEME=bearer|raw — auth scheme for Authorization/custom header handling
|
||||||
|
* OPENAI_API_FORMAT=chat_completions|responses — request format for compatible APIs
|
||||||
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
|
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
|
||||||
* OPENAI_MODEL=gpt-4o — default model override
|
* OPENAI_MODEL=gpt-4o — default model override
|
||||||
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
||||||
@@ -74,6 +78,7 @@ import { createStreamState, processStreamChunk, getStreamStats } from '../../uti
|
|||||||
|
|
||||||
type SecretValueSource = Partial<{
|
type SecretValueSource = Partial<{
|
||||||
OPENAI_API_KEY: string
|
OPENAI_API_KEY: string
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: string
|
||||||
CODEX_API_KEY: string
|
CODEX_API_KEY: string
|
||||||
GEMINI_API_KEY: string
|
GEMINI_API_KEY: string
|
||||||
GOOGLE_API_KEY: string
|
GOOGLE_API_KEY: string
|
||||||
@@ -1350,7 +1355,11 @@ class OpenAIShimMessages {
|
|||||||
if (params.stream) {
|
if (params.stream) {
|
||||||
const isResponsesStream = response.url?.includes('/responses')
|
const isResponsesStream = response.url?.includes('/responses')
|
||||||
return new OpenAIShimStream(
|
return new OpenAIShimStream(
|
||||||
(request.transport === 'codex_responses' || isResponsesStream)
|
(
|
||||||
|
request.transport === 'codex_responses' ||
|
||||||
|
request.transport === 'responses' ||
|
||||||
|
isResponsesStream
|
||||||
|
)
|
||||||
? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
|
? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
|
||||||
: openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
|
: openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
|
||||||
)
|
)
|
||||||
@@ -1365,7 +1374,11 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isResponsesNonStream = response.url?.includes('/responses')
|
const isResponsesNonStream = response.url?.includes('/responses')
|
||||||
if (isResponsesNonStream || (request.transport === 'chat_completions' && isGithubModelsMode())) {
|
if (
|
||||||
|
request.transport === 'responses' ||
|
||||||
|
isResponsesNonStream ||
|
||||||
|
(request.transport === 'chat_completions' && isGithubModelsMode())
|
||||||
|
) {
|
||||||
const contentType = response.headers.get('content-type') ?? ''
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
const parsed = await response.json() as Record<string, unknown>
|
const parsed = await response.json() as Record<string, unknown>
|
||||||
@@ -1644,6 +1657,65 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let omitResponsesTools = false
|
||||||
|
const buildResponsesBody = (): Record<string, unknown> => {
|
||||||
|
const responsesBody: Record<string, unknown> = {
|
||||||
|
model: request.resolvedModel,
|
||||||
|
input: convertAnthropicMessagesToResponsesInput(
|
||||||
|
params.messages as Array<{
|
||||||
|
role?: string
|
||||||
|
message?: { role?: string; content?: unknown }
|
||||||
|
content?: unknown
|
||||||
|
}>,
|
||||||
|
),
|
||||||
|
stream: params.stream ?? false,
|
||||||
|
store: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMistral || isGeminiMode() || isMoonshot || isDeepSeek || isZai) {
|
||||||
|
delete responsesBody.store
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(responsesBody.input) || responsesBody.input.length === 0) {
|
||||||
|
responsesBody.input = [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: '' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemText = convertSystemPrompt(params.system)
|
||||||
|
if (systemText) {
|
||||||
|
responsesBody.instructions = systemText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.max_tokens !== undefined) {
|
||||||
|
responsesBody.max_output_tokens = body.max_tokens
|
||||||
|
} else if (body.max_completion_tokens !== undefined) {
|
||||||
|
responsesBody.max_output_tokens = body.max_completion_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.temperature !== undefined) responsesBody.temperature = params.temperature
|
||||||
|
if (params.top_p !== undefined) responsesBody.top_p = params.top_p
|
||||||
|
|
||||||
|
if (!omitResponsesTools && params.tools && params.tools.length > 0) {
|
||||||
|
const convertedTools = convertToolsToResponsesTools(
|
||||||
|
params.tools as Array<{
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
input_schema?: Record<string, unknown>
|
||||||
|
}>,
|
||||||
|
)
|
||||||
|
if (convertedTools.length > 0) {
|
||||||
|
responsesBody.tools = convertedTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responsesBody
|
||||||
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.defaultHeaders,
|
...this.defaultHeaders,
|
||||||
@@ -1656,6 +1728,15 @@ class OpenAIShimMessages {
|
|||||||
this.providerOverride?.apiKey ??
|
this.providerOverride?.apiKey ??
|
||||||
process.env.OPENAI_API_KEY ??
|
process.env.OPENAI_API_KEY ??
|
||||||
(isMiniMax ? process.env.MINIMAX_API_KEY : '')
|
(isMiniMax ? process.env.MINIMAX_API_KEY : '')
|
||||||
|
const configuredAuthHeaderValue = process.env.OPENAI_AUTH_HEADER_VALUE?.trim()
|
||||||
|
const customAuthHeader = process.env.OPENAI_AUTH_HEADER?.trim()
|
||||||
|
const hasCustomAuthHeader = Boolean(
|
||||||
|
customAuthHeader &&
|
||||||
|
/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(customAuthHeader),
|
||||||
|
)
|
||||||
|
const authValue = hasCustomAuthHeader
|
||||||
|
? configuredAuthHeaderValue || apiKey
|
||||||
|
: apiKey
|
||||||
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
||||||
// path segments like https://evil.com/cognitiveservices.azure.com/
|
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||||
let isAzure = false
|
let isAzure = false
|
||||||
@@ -1670,15 +1751,27 @@ class OpenAIShimMessages {
|
|||||||
isBankr = request.baseUrl.toLowerCase().includes('bankr')
|
isBankr = request.baseUrl.toLowerCase().includes('bankr')
|
||||||
} catch { /* malformed URL — not Bankr */ }
|
} catch { /* malformed URL — not Bankr */ }
|
||||||
|
|
||||||
if (apiKey) {
|
if (authValue) {
|
||||||
if (isAzure) {
|
if (hasCustomAuthHeader && customAuthHeader) {
|
||||||
|
const defaultCustomAuthScheme =
|
||||||
|
customAuthHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw'
|
||||||
|
const customAuthScheme =
|
||||||
|
process.env.OPENAI_AUTH_SCHEME === 'raw' ||
|
||||||
|
process.env.OPENAI_AUTH_SCHEME === 'bearer'
|
||||||
|
? process.env.OPENAI_AUTH_SCHEME
|
||||||
|
: defaultCustomAuthScheme
|
||||||
|
headers[customAuthHeader] =
|
||||||
|
customAuthScheme === 'bearer'
|
||||||
|
? `Bearer ${authValue}`
|
||||||
|
: authValue
|
||||||
|
} else if (isAzure) {
|
||||||
// Azure uses api-key header instead of Bearer token
|
// Azure uses api-key header instead of Bearer token
|
||||||
headers['api-key'] = apiKey
|
headers['api-key'] = authValue
|
||||||
} else if (isBankr) {
|
} else if (isBankr) {
|
||||||
// Bankr uses X-API-Key header instead of Bearer token
|
// Bankr uses X-API-Key header instead of Bearer token
|
||||||
headers['X-API-Key'] = apiKey
|
headers['X-API-Key'] = authValue
|
||||||
} else {
|
} else {
|
||||||
headers.Authorization = `Bearer ${apiKey}`
|
headers.Authorization = `Bearer ${authValue}`
|
||||||
}
|
}
|
||||||
} else if (isGemini) {
|
} else if (isGemini) {
|
||||||
const geminiCredential = await resolveGeminiCredential(process.env)
|
const geminiCredential = await resolveGeminiCredential(process.env)
|
||||||
@@ -1725,8 +1818,13 @@ class OpenAIShimMessages {
|
|||||||
? getLocalProviderRetryBaseUrls(request.baseUrl)
|
? getLocalProviderRetryBaseUrls(request.baseUrl)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const buildRequestUrl = (baseUrl: string): string =>
|
||||||
|
request.transport === 'responses'
|
||||||
|
? `${baseUrl}/responses`
|
||||||
|
: buildChatCompletionsUrl(baseUrl)
|
||||||
|
|
||||||
let activeBaseUrl = request.baseUrl
|
let activeBaseUrl = request.baseUrl
|
||||||
let chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
|
let requestUrl = buildRequestUrl(activeBaseUrl)
|
||||||
const attemptedLocalBaseUrls = new Set<string>([activeBaseUrl])
|
const attemptedLocalBaseUrls = new Set<string>([activeBaseUrl])
|
||||||
let didRetryWithoutTools = false
|
let didRetryWithoutTools = false
|
||||||
|
|
||||||
@@ -1738,13 +1836,13 @@ class OpenAIShimMessages {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousUrl = chatCompletionsUrl
|
const previousUrl = requestUrl
|
||||||
attemptedLocalBaseUrls.add(candidateBaseUrl)
|
attemptedLocalBaseUrls.add(candidateBaseUrl)
|
||||||
activeBaseUrl = candidateBaseUrl
|
activeBaseUrl = candidateBaseUrl
|
||||||
chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
|
requestUrl = buildRequestUrl(activeBaseUrl)
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[OpenAIShim] self-heal retry reason=${reason} method=POST from=${redactUrlForDiagnostics(previousUrl)} to=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
|
`[OpenAIShim] self-heal retry reason=${reason} method=POST from=${redactUrlForDiagnostics(previousUrl)} to=${redactUrlForDiagnostics(requestUrl)} model=${request.resolvedModel}`,
|
||||||
{ level: 'warn' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1754,10 +1852,14 @@ class OpenAIShimMessages {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let serializedBody = JSON.stringify(body)
|
let serializedBody = JSON.stringify(
|
||||||
|
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||||
|
)
|
||||||
|
|
||||||
const refreshSerializedBody = (): void => {
|
const refreshSerializedBody = (): void => {
|
||||||
serializedBody = JSON.stringify(body)
|
serializedBody = JSON.stringify(
|
||||||
|
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildFetchInit = () => ({
|
const buildFetchInit = () => ({
|
||||||
@@ -1852,7 +1954,7 @@ class OpenAIShimMessages {
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
response = await fetchWithProxyRetry(
|
response = await fetchWithProxyRetry(
|
||||||
chatCompletionsUrl,
|
requestUrl,
|
||||||
buildFetchInit(),
|
buildFetchInit(),
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1871,7 +1973,7 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failure = classifyOpenAINetworkFailure(error, {
|
const failure = classifyOpenAINetworkFailure(error, {
|
||||||
url: chatCompletionsUrl,
|
url: requestUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1882,7 +1984,7 @@ class OpenAIShimMessages {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
throwClassifiedTransportError(error, chatCompletionsUrl, failure)
|
throwClassifiedTransportError(error, requestUrl, failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1927,50 +2029,7 @@ class OpenAIShimMessages {
|
|||||||
if (isGithub && response.status === 400) {
|
if (isGithub && response.status === 400) {
|
||||||
if (errorBody.includes('/chat/completions') || errorBody.includes('not accessible')) {
|
if (errorBody.includes('/chat/completions') || errorBody.includes('not accessible')) {
|
||||||
const responsesUrl = `${request.baseUrl}/responses`
|
const responsesUrl = `${request.baseUrl}/responses`
|
||||||
const responsesBody: Record<string, unknown> = {
|
const responsesBody = buildResponsesBody()
|
||||||
model: request.resolvedModel,
|
|
||||||
input: convertAnthropicMessagesToResponsesInput(
|
|
||||||
params.messages as Array<{
|
|
||||||
role?: string
|
|
||||||
message?: { role?: string; content?: unknown }
|
|
||||||
content?: unknown
|
|
||||||
}>,
|
|
||||||
),
|
|
||||||
stream: params.stream ?? false,
|
|
||||||
store: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(responsesBody.input) || responsesBody.input.length === 0) {
|
|
||||||
responsesBody.input = [
|
|
||||||
{
|
|
||||||
type: 'message',
|
|
||||||
role: 'user',
|
|
||||||
content: [{ type: 'input_text', text: '' }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemText = convertSystemPrompt(params.system)
|
|
||||||
if (systemText) {
|
|
||||||
responsesBody.instructions = systemText
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.max_tokens !== undefined) {
|
|
||||||
responsesBody.max_output_tokens = body.max_tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.tools && params.tools.length > 0) {
|
|
||||||
const convertedTools = convertToolsToResponsesTools(
|
|
||||||
params.tools as Array<{
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
input_schema?: Record<string, unknown>
|
|
||||||
}>,
|
|
||||||
)
|
|
||||||
if (convertedTools.length > 0) {
|
|
||||||
responsesBody.tools = convertedTools
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let responsesResponse: Response
|
let responsesResponse: Response
|
||||||
try {
|
try {
|
||||||
@@ -2020,8 +2079,9 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasToolsPayload =
|
const hasToolsPayload =
|
||||||
Array.isArray(body.tools) &&
|
request.transport === 'responses'
|
||||||
body.tools.length > 0
|
? Array.isArray(params.tools) && params.tools.length > 0
|
||||||
|
: Array.isArray(body.tools) && body.tools.length > 0
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!didRetryWithoutTools &&
|
!didRetryWithoutTools &&
|
||||||
@@ -2034,10 +2094,11 @@ class OpenAIShimMessages {
|
|||||||
didRetryWithoutTools = true
|
didRetryWithoutTools = true
|
||||||
delete body.tools
|
delete body.tools
|
||||||
delete body.tool_choice
|
delete body.tool_choice
|
||||||
|
omitResponsesTools = true
|
||||||
refreshSerializedBody()
|
refreshSerializedBody()
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[OpenAIShim] self-heal retry reason=tool_call_incompatible mode=toolless method=POST url=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
|
`[OpenAIShim] self-heal retry reason=tool_call_incompatible mode=toolless method=POST url=${redactUrlForDiagnostics(requestUrl)} model=${request.resolvedModel}`,
|
||||||
{ level: 'warn' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -2050,7 +2111,7 @@ class OpenAIShimMessages {
|
|||||||
errorBody,
|
errorBody,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
response.headers as unknown as Headers,
|
response.headers as unknown as Headers,
|
||||||
chatCompletionsUrl,
|
requestUrl,
|
||||||
rateHint,
|
rateHint,
|
||||||
failure,
|
failure,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,12 +12,22 @@ const originalEnv = {
|
|||||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT,
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||||
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||||
|
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('treats localhost endpoints as local', () => {
|
test('treats localhost endpoints as local', () => {
|
||||||
@@ -78,6 +88,34 @@ test('keeps codex alias models on chat completions for local openai-compatible p
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses responses transport when OpenAI-compatible API format requests responses', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL = 'https://api.openai.com/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.openai.com/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'
|
||||||
|
process.env.OPENAI_MODEL = 'codexplan'
|
||||||
|
process.env.OPENAI_API_FORMAT = 'chat_completions'
|
||||||
|
|
||||||
|
expect(resolveProviderRequest()).toMatchObject({
|
||||||
|
transport: 'codex_responses',
|
||||||
|
requestedModel: 'codexplan',
|
||||||
|
resolvedModel: 'gpt-5.5',
|
||||||
|
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('skips local model cache scope for remote openai-compatible providers', () => {
|
test('skips local model cache scope for remote openai-compatible providers', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'
|
|||||||
|
|
||||||
const OPENAI_CODEX_SHORTCUT_ALIASES = new Set(['codexplan', 'codexspark'])
|
const OPENAI_CODEX_SHORTCUT_ALIASES = new Set(['codexplan', 'codexspark'])
|
||||||
|
|
||||||
export type ProviderTransport = 'chat_completions' | 'codex_responses'
|
export type ProviderTransport = 'chat_completions' | 'responses' | 'codex_responses'
|
||||||
|
export type OpenAICompatibleApiFormat = 'chat_completions' | 'responses'
|
||||||
|
|
||||||
export type ResolvedProviderRequest = {
|
export type ResolvedProviderRequest = {
|
||||||
transport: ProviderTransport
|
transport: ProviderTransport
|
||||||
@@ -200,6 +201,30 @@ function parseReasoningEffort(value: string | undefined): ReasoningEffort | unde
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseOpenAICompatibleApiFormat(
|
||||||
|
value: string | undefined,
|
||||||
|
): OpenAICompatibleApiFormat | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/[- ]+/g, '_')
|
||||||
|
if (
|
||||||
|
normalized === 'responses' ||
|
||||||
|
normalized === 'response' ||
|
||||||
|
normalized === 'responses_api'
|
||||||
|
) {
|
||||||
|
return 'responses'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === 'chat_completions' ||
|
||||||
|
normalized === 'chat_completion' ||
|
||||||
|
normalized === 'completions' ||
|
||||||
|
normalized === 'completion' ||
|
||||||
|
normalized === 'chat'
|
||||||
|
) {
|
||||||
|
return 'chat_completions'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function parseModelDescriptor(model: string): ModelDescriptor {
|
function parseModelDescriptor(model: string): ModelDescriptor {
|
||||||
const trimmed = model.trim()
|
const trimmed = model.trim()
|
||||||
const queryIndex = trimmed.indexOf('?')
|
const queryIndex = trimmed.indexOf('?')
|
||||||
@@ -482,6 +507,7 @@ export function resolveProviderRequest(options?: {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
fallbackModel?: string
|
fallbackModel?: string
|
||||||
reasoningEffortOverride?: ReasoningEffort
|
reasoningEffortOverride?: ReasoningEffort
|
||||||
|
apiFormat?: OpenAICompatibleApiFormat | string
|
||||||
}): ResolvedProviderRequest {
|
}): ResolvedProviderRequest {
|
||||||
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
|
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
|
||||||
@@ -571,10 +597,15 @@ export function resolveProviderRequest(options?: {
|
|||||||
? normalizeGithubModelsApiModel(requestedModel)
|
? normalizeGithubModelsApiModel(requestedModel)
|
||||||
: requestedModel
|
: requestedModel
|
||||||
|
|
||||||
|
const requestedApiFormat =
|
||||||
|
parseOpenAICompatibleApiFormat(options?.apiFormat) ??
|
||||||
|
parseOpenAICompatibleApiFormat(process.env.OPENAI_API_FORMAT)
|
||||||
const transport: ProviderTransport =
|
const transport: ProviderTransport =
|
||||||
shouldUseCodexTransport(requestedModel, finalBaseUrl) ||
|
shouldUseCodexTransport(requestedModel, finalBaseUrl) ||
|
||||||
(isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel))
|
(isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel))
|
||||||
? 'codex_responses'
|
? 'codex_responses'
|
||||||
|
: requestedApiFormat === 'responses'
|
||||||
|
? 'responses'
|
||||||
: 'chat_completions'
|
: 'chat_completions'
|
||||||
|
|
||||||
// For GitHub Copilot API, normalize to real model ID (e.g., "github:copilot" -> "gpt-4o")
|
// For GitHub Copilot API, normalize to real model ID (e.g., "github:copilot" -> "gpt-4o")
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const SHOW_CACHE_STATS_MODES = ['off', 'compact', 'full'] as const satisf
|
|||||||
export type OutputStyle = string
|
export type OutputStyle = string
|
||||||
|
|
||||||
export type Providers = typeof PROVIDERS[number]
|
export type Providers = typeof PROVIDERS[number]
|
||||||
|
export type OpenAICompatibleApiFormat = 'chat_completions' | 'responses'
|
||||||
|
export type OpenAICompatibleAuthScheme = 'bearer' | 'raw'
|
||||||
|
|
||||||
export type ProviderProfile = {
|
export type ProviderProfile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -193,6 +195,10 @@ export type ProviderProfile = {
|
|||||||
baseUrl: string
|
baseUrl: string
|
||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
apiFormat?: OpenAICompatibleApiFormat
|
||||||
|
authHeader?: string
|
||||||
|
authScheme?: OpenAICompatibleAuthScheme
|
||||||
|
authHeaderValue?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GlobalConfig = {
|
export type GlobalConfig = {
|
||||||
|
|||||||
@@ -171,6 +171,39 @@ test('openai launch ignores codex persisted transport hints', async () => {
|
|||||||
assert.equal(env.OPENAI_API_KEY, 'sk-live')
|
assert.equal(env.OPENAI_API_KEY, 'sk-live')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai launch preserves shell responses format and custom auth overrides', async () => {
|
||||||
|
const env = await buildLaunchEnv({
|
||||||
|
profile: 'openai',
|
||||||
|
persisted: profile('openai', {
|
||||||
|
OPENAI_BASE_URL: 'https://persisted.example/v1',
|
||||||
|
OPENAI_MODEL: 'persisted-model',
|
||||||
|
OPENAI_API_FORMAT: 'chat_completions',
|
||||||
|
OPENAI_AUTH_HEADER: 'X-Persisted-Key',
|
||||||
|
OPENAI_AUTH_SCHEME: 'raw',
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: 'persisted-secret',
|
||||||
|
OPENAI_API_KEY: 'sk-persisted',
|
||||||
|
}),
|
||||||
|
goal: 'balanced',
|
||||||
|
processEnv: {
|
||||||
|
OPENAI_BASE_URL: 'https://shell.example/v1',
|
||||||
|
OPENAI_MODEL: 'shell-model',
|
||||||
|
OPENAI_API_FORMAT: 'responses',
|
||||||
|
OPENAI_AUTH_HEADER: 'api-key',
|
||||||
|
OPENAI_AUTH_SCHEME: 'raw',
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: 'shell-secret',
|
||||||
|
OPENAI_API_KEY: 'sk-live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env.OPENAI_BASE_URL, 'https://shell.example/v1')
|
||||||
|
assert.equal(env.OPENAI_MODEL, 'shell-model')
|
||||||
|
assert.equal(env.OPENAI_API_FORMAT, 'responses')
|
||||||
|
assert.equal(env.OPENAI_AUTH_HEADER, 'api-key')
|
||||||
|
assert.equal(env.OPENAI_AUTH_SCHEME, 'raw')
|
||||||
|
assert.equal(env.OPENAI_AUTH_HEADER_VALUE, 'shell-secret')
|
||||||
|
assert.equal(env.OPENAI_API_KEY, 'sk-live')
|
||||||
|
})
|
||||||
|
|
||||||
test('matching persisted gemini env is reused for gemini launch', async () => {
|
test('matching persisted gemini env is reused for gemini launch', async () => {
|
||||||
const env = await buildLaunchEnv({
|
const env = await buildLaunchEnv({
|
||||||
profile: 'gemini',
|
profile: 'gemini',
|
||||||
@@ -382,6 +415,32 @@ test('codex profiles require a chatgpt account id', () => {
|
|||||||
assert.equal(env, null)
|
assert.equal(env, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('codex launch clears openai-compatible format and custom auth env', async () => {
|
||||||
|
const env = await buildLaunchEnv({
|
||||||
|
profile: 'codex',
|
||||||
|
persisted: profile('codex', {
|
||||||
|
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
OPENAI_MODEL: 'codexspark',
|
||||||
|
CHATGPT_ACCOUNT_ID: 'acct_persisted',
|
||||||
|
}),
|
||||||
|
goal: 'balanced',
|
||||||
|
processEnv: {
|
||||||
|
OPENAI_API_FORMAT: 'responses',
|
||||||
|
OPENAI_AUTH_HEADER: 'api-key',
|
||||||
|
OPENAI_AUTH_SCHEME: 'raw',
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: 'hicap-header-secret',
|
||||||
|
CODEX_API_KEY: 'codex-live',
|
||||||
|
CHATGPT_ACCOUNT_ID: 'acct_live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env.OPENAI_API_FORMAT, undefined)
|
||||||
|
assert.equal(env.OPENAI_AUTH_HEADER, undefined)
|
||||||
|
assert.equal(env.OPENAI_AUTH_SCHEME, undefined)
|
||||||
|
assert.equal(env.OPENAI_AUTH_HEADER_VALUE, undefined)
|
||||||
|
assert.equal(env.CODEX_API_KEY, 'codex-live')
|
||||||
|
})
|
||||||
|
|
||||||
test('gemini profiles accept google api key fallback', () => {
|
test('gemini profiles accept google api key fallback', () => {
|
||||||
const env = buildGeminiProfileEnv({
|
const env = buildGeminiProfileEnv({
|
||||||
processEnv: {
|
processEnv: {
|
||||||
@@ -745,11 +804,18 @@ test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
|
|||||||
|
|
||||||
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
|
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
|
||||||
const apiKey = 'sk-secret-12345678'
|
const apiKey = 'sk-secret-12345678'
|
||||||
|
const authHeaderValue = 'hicap-header-secret'
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
|
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
|
||||||
'sk-...678',
|
'sk-...678',
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
redactSecretValueForDisplay(authHeaderValue, {
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: authHeaderValue,
|
||||||
|
}),
|
||||||
|
'hic...ret',
|
||||||
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
||||||
'gpt-4o',
|
'gpt-4o',
|
||||||
@@ -787,6 +853,22 @@ test('openai profiles ignore codex shell transport hints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai profiles keep shell base and model when shell format is responses', () => {
|
||||||
|
const env = buildOpenAIProfileEnv({
|
||||||
|
goal: 'balanced',
|
||||||
|
processEnv: {
|
||||||
|
OPENAI_BASE_URL: 'https://shell.example/v1',
|
||||||
|
OPENAI_MODEL: 'shell-model',
|
||||||
|
OPENAI_API_FORMAT: 'responses',
|
||||||
|
OPENAI_API_KEY: 'sk-live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env?.OPENAI_BASE_URL, 'https://shell.example/v1')
|
||||||
|
assert.equal(env?.OPENAI_MODEL, 'shell-model')
|
||||||
|
assert.equal(env?.OPENAI_API_KEY, 'sk-live')
|
||||||
|
})
|
||||||
|
|
||||||
test('openai profiles use the first model from a semicolon-separated list', () => {
|
test('openai profiles use the first model from a semicolon-separated list', () => {
|
||||||
const env = buildOpenAIProfileEnv({
|
const env = buildOpenAIProfileEnv({
|
||||||
goal: 'balanced',
|
goal: 'balanced',
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ const PROFILE_ENV_KEYS = [
|
|||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
'OPENAI_BASE_URL',
|
'OPENAI_BASE_URL',
|
||||||
'OPENAI_MODEL',
|
'OPENAI_MODEL',
|
||||||
|
'OPENAI_API_FORMAT',
|
||||||
|
'OPENAI_AUTH_HEADER',
|
||||||
|
'OPENAI_AUTH_SCHEME',
|
||||||
|
'OPENAI_AUTH_HEADER_VALUE',
|
||||||
'OPENAI_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
'CODEX_API_KEY',
|
'CODEX_API_KEY',
|
||||||
'CODEX_CREDENTIAL_SOURCE',
|
'CODEX_CREDENTIAL_SOURCE',
|
||||||
@@ -79,6 +83,7 @@ const PROFILE_ENV_KEYS = [
|
|||||||
|
|
||||||
const SECRET_ENV_KEYS = [
|
const SECRET_ENV_KEYS = [
|
||||||
'OPENAI_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
|
'OPENAI_AUTH_HEADER_VALUE',
|
||||||
'CODEX_API_KEY',
|
'CODEX_API_KEY',
|
||||||
'GEMINI_API_KEY',
|
'GEMINI_API_KEY',
|
||||||
'GOOGLE_API_KEY',
|
'GOOGLE_API_KEY',
|
||||||
@@ -93,6 +98,10 @@ export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic
|
|||||||
export type ProfileEnv = {
|
export type ProfileEnv = {
|
||||||
OPENAI_BASE_URL?: string
|
OPENAI_BASE_URL?: string
|
||||||
OPENAI_MODEL?: string
|
OPENAI_MODEL?: string
|
||||||
|
OPENAI_API_FORMAT?: 'chat_completions' | 'responses'
|
||||||
|
OPENAI_AUTH_HEADER?: string
|
||||||
|
OPENAI_AUTH_SCHEME?: 'bearer' | 'raw'
|
||||||
|
OPENAI_AUTH_HEADER_VALUE?: string
|
||||||
OPENAI_API_KEY?: string
|
OPENAI_API_KEY?: string
|
||||||
CODEX_API_KEY?: string
|
CODEX_API_KEY?: string
|
||||||
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
|
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
|
||||||
@@ -125,6 +134,7 @@ export type ProfileFile = {
|
|||||||
type SecretValueSource = Partial<
|
type SecretValueSource = Partial<
|
||||||
Record<
|
Record<
|
||||||
| 'OPENAI_API_KEY'
|
| 'OPENAI_API_KEY'
|
||||||
|
| 'OPENAI_AUTH_HEADER_VALUE'
|
||||||
| 'CODEX_API_KEY'
|
| 'CODEX_API_KEY'
|
||||||
| 'GEMINI_API_KEY'
|
| 'GEMINI_API_KEY'
|
||||||
| 'GOOGLE_API_KEY'
|
| 'GOOGLE_API_KEY'
|
||||||
@@ -320,16 +330,26 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
model?: string | null
|
model?: string | null
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
|
apiFormat?: 'chat_completions' | 'responses' | null
|
||||||
|
authHeader?: string | null
|
||||||
|
authScheme?: 'bearer' | 'raw' | null
|
||||||
|
authHeaderValue?: string | null
|
||||||
processEnv?: NodeJS.ProcessEnv
|
processEnv?: NodeJS.ProcessEnv
|
||||||
}): ProfileEnv | null {
|
}): ProfileEnv | null {
|
||||||
const processEnv = options.processEnv ?? process.env
|
const processEnv = options.processEnv ?? process.env
|
||||||
const key = sanitizeApiKey(options.apiKey ?? processEnv.OPENAI_API_KEY)
|
const key = sanitizeApiKey(options.apiKey ?? processEnv.OPENAI_API_KEY)
|
||||||
if (!key) {
|
const authHeaderValue = sanitizeApiKey(
|
||||||
|
options.authHeaderValue ?? processEnv.OPENAI_AUTH_HEADER_VALUE,
|
||||||
|
)
|
||||||
|
if (!key && !authHeaderValue) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
||||||
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
|
const secretSource: SecretValueSource = {
|
||||||
|
OPENAI_API_KEY: key,
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: authHeaderValue,
|
||||||
|
}
|
||||||
const shellOpenAIModel = normalizeProfileModel(
|
const shellOpenAIModel = normalizeProfileModel(
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_MODEL,
|
processEnv.OPENAI_MODEL,
|
||||||
@@ -344,8 +364,9 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
model: shellOpenAIModel,
|
model: shellOpenAIModel,
|
||||||
baseUrl: shellOpenAIBaseUrl,
|
baseUrl: shellOpenAIBaseUrl,
|
||||||
fallbackModel: defaultModel,
|
fallbackModel: defaultModel,
|
||||||
|
apiFormat: processEnv.OPENAI_API_FORMAT,
|
||||||
})
|
})
|
||||||
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
const useShellOpenAIConfig = shellOpenAIRequest.transport !== 'codex_responses'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
OPENAI_BASE_URL:
|
OPENAI_BASE_URL:
|
||||||
@@ -358,7 +379,11 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
) ||
|
) ||
|
||||||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
||||||
defaultModel,
|
defaultModel,
|
||||||
OPENAI_API_KEY: key,
|
...(options.apiFormat ? { OPENAI_API_FORMAT: options.apiFormat } : {}),
|
||||||
|
...(options.authHeader ? { OPENAI_AUTH_HEADER: options.authHeader } : {}),
|
||||||
|
...(options.authScheme ? { OPENAI_AUTH_SCHEME: options.authScheme } : {}),
|
||||||
|
...(authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: authHeaderValue } : {}),
|
||||||
|
...(key ? { OPENAI_API_KEY: key } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +647,12 @@ export async function buildLaunchEnv(options: {
|
|||||||
persistedEnv.OPENAI_BASE_URL,
|
persistedEnv.OPENAI_BASE_URL,
|
||||||
persistedEnv,
|
persistedEnv,
|
||||||
)
|
)
|
||||||
|
const persistedOpenAIApiFormat = persistedEnv.OPENAI_API_FORMAT
|
||||||
|
const persistedOpenAIAuthHeader = persistedEnv.OPENAI_AUTH_HEADER
|
||||||
|
const persistedOpenAIAuthScheme = persistedEnv.OPENAI_AUTH_SCHEME
|
||||||
|
const persistedOpenAIAuthHeaderValue = sanitizeApiKey(
|
||||||
|
persistedEnv.OPENAI_AUTH_HEADER_VALUE,
|
||||||
|
)
|
||||||
const shellOpenAIModel = normalizeProfileModel(
|
const shellOpenAIModel = normalizeProfileModel(
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_MODEL,
|
processEnv.OPENAI_MODEL,
|
||||||
@@ -723,6 +754,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
delete env.GOOGLE_API_KEY
|
delete env.GOOGLE_API_KEY
|
||||||
delete env.OPENAI_BASE_URL
|
delete env.OPENAI_BASE_URL
|
||||||
delete env.OPENAI_MODEL
|
delete env.OPENAI_MODEL
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
delete env.CODEX_API_KEY
|
delete env.CODEX_API_KEY
|
||||||
delete env.CHATGPT_ACCOUNT_ID
|
delete env.CHATGPT_ACCOUNT_ID
|
||||||
@@ -790,6 +825,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
delete env.GOOGLE_API_KEY
|
delete env.GOOGLE_API_KEY
|
||||||
delete env.OPENAI_BASE_URL
|
delete env.OPENAI_BASE_URL
|
||||||
delete env.OPENAI_MODEL
|
delete env.OPENAI_MODEL
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
delete env.CODEX_API_KEY
|
delete env.CODEX_API_KEY
|
||||||
delete env.CHATGPT_ACCOUNT_ID
|
delete env.CHATGPT_ACCOUNT_ID
|
||||||
@@ -829,6 +868,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
(await resolveOllamaModel(options.goal))
|
(await resolveOllamaModel(options.goal))
|
||||||
|
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete env.CODEX_API_KEY
|
delete env.CODEX_API_KEY
|
||||||
delete env.CHATGPT_ACCOUNT_ID
|
delete env.CHATGPT_ACCOUNT_ID
|
||||||
delete env.CODEX_ACCOUNT_ID
|
delete env.CODEX_ACCOUNT_ID
|
||||||
@@ -849,6 +892,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
''
|
''
|
||||||
|
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete env.CODEX_API_KEY
|
delete env.CODEX_API_KEY
|
||||||
delete env.CHATGPT_ACCOUNT_ID
|
delete env.CHATGPT_ACCOUNT_ID
|
||||||
delete env.CODEX_ACCOUNT_ID
|
delete env.CODEX_ACCOUNT_ID
|
||||||
@@ -863,6 +910,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
: DEFAULT_CODEX_BASE_URL
|
: DEFAULT_CODEX_BASE_URL
|
||||||
env.OPENAI_MODEL = persistedOpenAIModel || 'codexplan'
|
env.OPENAI_MODEL = persistedOpenAIModel || 'codexplan'
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
|
|
||||||
const codexKey =
|
const codexKey =
|
||||||
sanitizeApiKey(processEnv.CODEX_API_KEY) ||
|
sanitizeApiKey(processEnv.CODEX_API_KEY) ||
|
||||||
@@ -895,16 +946,18 @@ export async function buildLaunchEnv(options: {
|
|||||||
model: shellOpenAIModel,
|
model: shellOpenAIModel,
|
||||||
baseUrl: shellOpenAIBaseUrl,
|
baseUrl: shellOpenAIBaseUrl,
|
||||||
fallbackModel: defaultOpenAIModel,
|
fallbackModel: defaultOpenAIModel,
|
||||||
|
apiFormat: processEnv.OPENAI_API_FORMAT,
|
||||||
})
|
})
|
||||||
const persistedOpenAIRequest = resolveProviderRequest({
|
const persistedOpenAIRequest = resolveProviderRequest({
|
||||||
model: persistedOpenAIModel,
|
model: persistedOpenAIModel,
|
||||||
baseUrl: persistedOpenAIBaseUrl,
|
baseUrl: persistedOpenAIBaseUrl,
|
||||||
fallbackModel: defaultOpenAIModel,
|
fallbackModel: defaultOpenAIModel,
|
||||||
|
apiFormat: persistedOpenAIApiFormat,
|
||||||
})
|
})
|
||||||
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
const useShellOpenAIConfig = shellOpenAIRequest.transport !== 'codex_responses'
|
||||||
const usePersistedOpenAIConfig =
|
const usePersistedOpenAIConfig =
|
||||||
(!persistedOpenAIModel && !persistedOpenAIBaseUrl) ||
|
(!persistedOpenAIModel && !persistedOpenAIBaseUrl) ||
|
||||||
persistedOpenAIRequest.transport === 'chat_completions'
|
persistedOpenAIRequest.transport !== 'codex_responses'
|
||||||
|
|
||||||
env.OPENAI_BASE_URL =
|
env.OPENAI_BASE_URL =
|
||||||
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
||||||
@@ -914,6 +967,38 @@ export async function buildLaunchEnv(options: {
|
|||||||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
||||||
(usePersistedOpenAIConfig ? persistedOpenAIModel : undefined) ||
|
(usePersistedOpenAIConfig ? persistedOpenAIModel : undefined) ||
|
||||||
defaultOpenAIModel
|
defaultOpenAIModel
|
||||||
|
const openAIApiFormat =
|
||||||
|
processEnv.OPENAI_API_FORMAT ||
|
||||||
|
(usePersistedOpenAIConfig ? persistedOpenAIApiFormat : undefined)
|
||||||
|
if (openAIApiFormat) {
|
||||||
|
env.OPENAI_API_FORMAT = openAIApiFormat
|
||||||
|
} else {
|
||||||
|
delete env.OPENAI_API_FORMAT
|
||||||
|
}
|
||||||
|
const openAIAuthHeader =
|
||||||
|
processEnv.OPENAI_AUTH_HEADER ||
|
||||||
|
(usePersistedOpenAIConfig ? persistedOpenAIAuthHeader : undefined)
|
||||||
|
if (openAIAuthHeader) {
|
||||||
|
env.OPENAI_AUTH_HEADER = openAIAuthHeader
|
||||||
|
} else {
|
||||||
|
delete env.OPENAI_AUTH_HEADER
|
||||||
|
}
|
||||||
|
const openAIAuthScheme =
|
||||||
|
processEnv.OPENAI_AUTH_SCHEME ||
|
||||||
|
(usePersistedOpenAIConfig ? persistedOpenAIAuthScheme : undefined)
|
||||||
|
if (openAIAuthScheme) {
|
||||||
|
env.OPENAI_AUTH_SCHEME = openAIAuthScheme
|
||||||
|
} else {
|
||||||
|
delete env.OPENAI_AUTH_SCHEME
|
||||||
|
}
|
||||||
|
const openAIAuthHeaderValue =
|
||||||
|
sanitizeApiKey(processEnv.OPENAI_AUTH_HEADER_VALUE) ||
|
||||||
|
(usePersistedOpenAIConfig ? persistedOpenAIAuthHeaderValue : undefined)
|
||||||
|
if (openAIAuthHeaderValue) {
|
||||||
|
env.OPENAI_AUTH_HEADER_VALUE = openAIAuthHeaderValue
|
||||||
|
} else {
|
||||||
|
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||||
|
}
|
||||||
const openAIKey = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY
|
const openAIKey = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY
|
||||||
if (openAIKey) {
|
if (openAIKey) {
|
||||||
env.OPENAI_API_KEY = openAIKey
|
env.OPENAI_API_KEY = openAIKey
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const RESTORED_KEYS = [
|
|||||||
'OPENAI_BASE_URL',
|
'OPENAI_BASE_URL',
|
||||||
'OPENAI_API_BASE',
|
'OPENAI_API_BASE',
|
||||||
'OPENAI_MODEL',
|
'OPENAI_MODEL',
|
||||||
|
'OPENAI_API_FORMAT',
|
||||||
|
'OPENAI_AUTH_HEADER',
|
||||||
|
'OPENAI_AUTH_SCHEME',
|
||||||
|
'OPENAI_AUTH_HEADER_VALUE',
|
||||||
'OPENAI_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_MODEL',
|
'ANTHROPIC_MODEL',
|
||||||
@@ -238,6 +242,45 @@ describe('applyProviderProfileToProcessEnv', () => {
|
|||||||
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai responses profile sets OPENAI_API_FORMAT', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
apiFormat: 'responses',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-5.4')
|
||||||
|
expect(process.env.OPENAI_API_FORMAT).toBe('responses')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('openai profile sets custom auth header name and value', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.hicap.ai/v1',
|
||||||
|
model: 'claude-opus-4.6',
|
||||||
|
authHeader: 'api-key',
|
||||||
|
authScheme: 'raw',
|
||||||
|
authHeaderValue: 'hicap-header-value',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.OPENAI_AUTH_HEADER).toBe('api-key')
|
||||||
|
expect(process.env.OPENAI_AUTH_SCHEME).toBe('raw')
|
||||||
|
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBe('hicap-header-value')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
test('anthropic profile with multi-model string sets only first model in ANTHROPIC_MODEL', async () => {
|
test('anthropic profile with multi-model string sets only first model in ANTHROPIC_MODEL', async () => {
|
||||||
const { applyProviderProfileToProcessEnv } =
|
const { applyProviderProfileToProcessEnv } =
|
||||||
await importFreshProviderProfileModules()
|
await importFreshProviderProfileModules()
|
||||||
@@ -720,6 +763,7 @@ describe('setActiveProviderProfile', () => {
|
|||||||
baseUrl: 'https://api.deepseek.com/v1',
|
baseUrl: 'https://api.deepseek.com/v1',
|
||||||
model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
|
model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
|
||||||
apiKey: 'sk-deepseek-live',
|
apiKey: 'sk-deepseek-live',
|
||||||
|
apiFormat: 'responses',
|
||||||
})
|
})
|
||||||
|
|
||||||
saveMockGlobalConfig(current => ({
|
saveMockGlobalConfig(current => ({
|
||||||
@@ -737,6 +781,7 @@ describe('setActiveProviderProfile', () => {
|
|||||||
expect(persisted.env).toEqual({
|
expect(persisted.env).toEqual({
|
||||||
OPENAI_BASE_URL: 'https://api.deepseek.com/v1',
|
OPENAI_BASE_URL: 'https://api.deepseek.com/v1',
|
||||||
OPENAI_MODEL: 'deepseek-v4-flash',
|
OPENAI_MODEL: 'deepseek-v4-flash',
|
||||||
|
OPENAI_API_FORMAT: 'responses',
|
||||||
OPENAI_API_KEY: 'sk-deepseek-live',
|
OPENAI_API_KEY: 'sk-deepseek-live',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { isCodexBaseUrl } from '../services/api/providerConfig.js'
|
import {
|
||||||
|
isCodexBaseUrl,
|
||||||
|
parseOpenAICompatibleApiFormat,
|
||||||
|
} from '../services/api/providerConfig.js'
|
||||||
import {
|
import {
|
||||||
getGlobalConfig,
|
getGlobalConfig,
|
||||||
saveGlobalConfig,
|
saveGlobalConfig,
|
||||||
@@ -46,6 +49,10 @@ export type ProviderProfileInput = {
|
|||||||
baseUrl: string
|
baseUrl: string
|
||||||
model: string
|
model: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
apiFormat?: ProviderProfile['apiFormat']
|
||||||
|
authHeader?: ProviderProfile['authHeader']
|
||||||
|
authScheme?: ProviderProfile['authScheme']
|
||||||
|
authHeaderValue?: ProviderProfile['authHeaderValue']
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderPresetDefaults = Omit<ProviderProfileInput, 'provider'> & {
|
export type ProviderPresetDefaults = Omit<ProviderProfileInput, 'provider'> & {
|
||||||
@@ -67,6 +74,20 @@ function trimOrUndefined(value: string | undefined): string | undefined {
|
|||||||
return trimmed.length > 0 ? trimmed : undefined
|
return trimmed.length > 0 ? trimmed : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAuthHeader(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = trimOrUndefined(value)
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAuthScheme(value: string | undefined): ProviderProfile['authScheme'] | undefined {
|
||||||
|
return value === 'raw' || value === 'bearer' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string): string {
|
function normalizeBaseUrl(value: string): string {
|
||||||
return trimValue(value).replace(/\/+$/, '')
|
return trimValue(value).replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
@@ -84,12 +105,16 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
|||||||
: 'openai'
|
: 'openai'
|
||||||
const baseUrl = normalizeBaseUrl(profile.baseUrl)
|
const baseUrl = normalizeBaseUrl(profile.baseUrl)
|
||||||
const model = trimValue(profile.model)
|
const model = trimValue(profile.model)
|
||||||
|
const apiFormat = parseOpenAICompatibleApiFormat(profile.apiFormat)
|
||||||
|
const authHeader = sanitizeAuthHeader(profile.authHeader)
|
||||||
|
const authScheme = sanitizeAuthScheme(profile.authScheme)
|
||||||
|
const authHeaderValue = trimOrUndefined(profile.authHeaderValue)
|
||||||
|
|
||||||
if (!id || !name || !baseUrl || !model) {
|
if (!id || !name || !baseUrl || !model) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const sanitized: ProviderProfile = {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
provider,
|
provider,
|
||||||
@@ -97,6 +122,17 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
|
|||||||
model,
|
model,
|
||||||
apiKey: trimOrUndefined(profile.apiKey),
|
apiKey: trimOrUndefined(profile.apiKey),
|
||||||
}
|
}
|
||||||
|
if (provider === 'openai' && apiFormat) {
|
||||||
|
sanitized.apiFormat = apiFormat
|
||||||
|
}
|
||||||
|
if (provider === 'openai' && authHeader) {
|
||||||
|
sanitized.authHeader = authHeader
|
||||||
|
sanitized.authScheme = authScheme ?? (
|
||||||
|
authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw'
|
||||||
|
)
|
||||||
|
sanitized.authHeaderValue = authHeaderValue
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeProfiles(profiles: ProviderProfile[] | undefined): ProviderProfile[] {
|
function sanitizeProfiles(profiles: ProviderProfile[] | undefined): ProviderProfile[] {
|
||||||
@@ -130,6 +166,10 @@ function toProfile(
|
|||||||
baseUrl: input.baseUrl,
|
baseUrl: input.baseUrl,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
apiKey: input.apiKey,
|
apiKey: input.apiKey,
|
||||||
|
apiFormat: input.apiFormat,
|
||||||
|
authHeader: input.authHeader,
|
||||||
|
authScheme: input.authScheme,
|
||||||
|
authHeaderValue: input.authHeaderValue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +550,10 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_MODEL, getPrimaryModel(profile.model)) &&
|
sameOptionalEnvValue(processEnv.OPENAI_MODEL, getPrimaryModel(profile.model)) &&
|
||||||
|
sameOptionalEnvValue(processEnv.OPENAI_API_FORMAT, profile.apiFormat) &&
|
||||||
|
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER, profile.authHeader) &&
|
||||||
|
sameOptionalEnvValue(processEnv.OPENAI_AUTH_SCHEME, profile.authScheme) &&
|
||||||
|
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER_VALUE, profile.authHeaderValue) &&
|
||||||
(!includeApiKey ||
|
(!includeApiKey ||
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey)) &&
|
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey)) &&
|
||||||
(profile.baseUrl?.toLowerCase().includes('bankr')
|
(profile.baseUrl?.toLowerCase().includes('bankr')
|
||||||
@@ -545,6 +589,10 @@ export function clearProviderProfileEnvFromProcessEnv(
|
|||||||
delete processEnv.OPENAI_BASE_URL
|
delete processEnv.OPENAI_BASE_URL
|
||||||
delete processEnv.OPENAI_API_BASE
|
delete processEnv.OPENAI_API_BASE
|
||||||
delete processEnv.OPENAI_MODEL
|
delete processEnv.OPENAI_MODEL
|
||||||
|
delete processEnv.OPENAI_API_FORMAT
|
||||||
|
delete processEnv.OPENAI_AUTH_HEADER
|
||||||
|
delete processEnv.OPENAI_AUTH_SCHEME
|
||||||
|
delete processEnv.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete processEnv.OPENAI_API_KEY
|
delete processEnv.OPENAI_API_KEY
|
||||||
|
|
||||||
delete processEnv.ANTHROPIC_BASE_URL
|
delete processEnv.ANTHROPIC_BASE_URL
|
||||||
@@ -591,6 +639,10 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
delete process.env.OPENAI_BASE_URL
|
delete process.env.OPENAI_BASE_URL
|
||||||
delete process.env.OPENAI_API_BASE
|
delete process.env.OPENAI_API_BASE
|
||||||
delete process.env.OPENAI_MODEL
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.OPENAI_API_FORMAT
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER
|
||||||
|
delete process.env.OPENAI_AUTH_SCHEME
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
delete process.env.OPENAI_API_KEY
|
delete process.env.OPENAI_API_KEY
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -609,6 +661,10 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
delete process.env.OPENAI_BASE_URL
|
delete process.env.OPENAI_BASE_URL
|
||||||
delete process.env.OPENAI_API_KEY
|
delete process.env.OPENAI_API_KEY
|
||||||
delete process.env.OPENAI_MODEL
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.OPENAI_API_FORMAT
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER
|
||||||
|
delete process.env.OPENAI_AUTH_SCHEME
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,12 +682,36 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
delete process.env.OPENAI_BASE_URL
|
delete process.env.OPENAI_BASE_URL
|
||||||
delete process.env.OPENAI_API_KEY
|
delete process.env.OPENAI_API_KEY
|
||||||
delete process.env.OPENAI_MODEL
|
delete process.env.OPENAI_MODEL
|
||||||
|
delete process.env.OPENAI_API_FORMAT
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER
|
||||||
|
delete process.env.OPENAI_AUTH_SCHEME
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = profile.baseUrl
|
process.env.OPENAI_BASE_URL = profile.baseUrl
|
||||||
process.env.OPENAI_MODEL = getPrimaryModel(profile.model)
|
process.env.OPENAI_MODEL = getPrimaryModel(profile.model)
|
||||||
|
if (profile.apiFormat) {
|
||||||
|
process.env.OPENAI_API_FORMAT = profile.apiFormat
|
||||||
|
} else {
|
||||||
|
delete process.env.OPENAI_API_FORMAT
|
||||||
|
}
|
||||||
|
if (profile.authHeader) {
|
||||||
|
process.env.OPENAI_AUTH_HEADER = profile.authHeader
|
||||||
|
process.env.OPENAI_AUTH_SCHEME = profile.authScheme ?? (
|
||||||
|
profile.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw'
|
||||||
|
)
|
||||||
|
if (profile.authHeaderValue) {
|
||||||
|
process.env.OPENAI_AUTH_HEADER_VALUE = profile.authHeaderValue
|
||||||
|
} else {
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER
|
||||||
|
delete process.env.OPENAI_AUTH_SCHEME
|
||||||
|
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.apiKey) {
|
if (profile.apiKey) {
|
||||||
process.env.OPENAI_API_KEY = profile.apiKey
|
process.env.OPENAI_API_KEY = profile.apiKey
|
||||||
@@ -887,6 +967,10 @@ function buildOpenAICompatibleStartupEnv(
|
|||||||
model: activeProfile.model,
|
model: activeProfile.model,
|
||||||
baseUrl: activeProfile.baseUrl,
|
baseUrl: activeProfile.baseUrl,
|
||||||
apiKey: activeProfile.apiKey,
|
apiKey: activeProfile.apiKey,
|
||||||
|
apiFormat: activeProfile.apiFormat,
|
||||||
|
authHeader: activeProfile.authHeader,
|
||||||
|
authScheme: activeProfile.authScheme,
|
||||||
|
authHeaderValue: activeProfile.authHeaderValue,
|
||||||
processEnv: {},
|
processEnv: {},
|
||||||
})
|
})
|
||||||
if (strictEnv) {
|
if (strictEnv) {
|
||||||
@@ -898,6 +982,18 @@ function buildOpenAICompatibleStartupEnv(
|
|||||||
OPENAI_BASE_URL: activeProfile.baseUrl,
|
OPENAI_BASE_URL: activeProfile.baseUrl,
|
||||||
OPENAI_MODEL: getPrimaryModel(activeProfile.model),
|
OPENAI_MODEL: getPrimaryModel(activeProfile.model),
|
||||||
}
|
}
|
||||||
|
if (activeProfile.apiFormat) {
|
||||||
|
env.OPENAI_API_FORMAT = activeProfile.apiFormat
|
||||||
|
}
|
||||||
|
if (activeProfile.authHeader) {
|
||||||
|
env.OPENAI_AUTH_HEADER = activeProfile.authHeader
|
||||||
|
env.OPENAI_AUTH_SCHEME = activeProfile.authScheme ?? (
|
||||||
|
activeProfile.authHeader.toLowerCase() === 'authorization' ? 'bearer' : 'raw'
|
||||||
|
)
|
||||||
|
if (activeProfile.authHeaderValue) {
|
||||||
|
env.OPENAI_AUTH_HEADER_VALUE = activeProfile.authHeaderValue
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeProfile.apiKey) {
|
if (activeProfile.apiKey) {
|
||||||
env.OPENAI_API_KEY = activeProfile.apiKey
|
env.OPENAI_API_KEY = activeProfile.apiKey
|
||||||
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
|
if (activeProfile.baseUrl?.toLowerCase().includes('bankr')) {
|
||||||
@@ -974,6 +1070,10 @@ export function setActiveProviderProfile(
|
|||||||
model: getPrimaryModel(activeProfile.model),
|
model: getPrimaryModel(activeProfile.model),
|
||||||
baseUrl: activeProfile.baseUrl,
|
baseUrl: activeProfile.baseUrl,
|
||||||
apiKey: activeProfile.apiKey,
|
apiKey: activeProfile.apiKey,
|
||||||
|
apiFormat: activeProfile.apiFormat,
|
||||||
|
authHeader: activeProfile.authHeader,
|
||||||
|
authScheme: activeProfile.authScheme,
|
||||||
|
authHeaderValue: activeProfile.authHeaderValue,
|
||||||
processEnv: process.env,
|
processEnv: process.env,
|
||||||
}) ?? null
|
}) ?? null
|
||||||
)
|
)
|
||||||
@@ -989,6 +1089,10 @@ export function setActiveProviderProfile(
|
|||||||
env: {
|
env: {
|
||||||
OPENAI_BASE_URL: activeProfile.baseUrl,
|
OPENAI_BASE_URL: activeProfile.baseUrl,
|
||||||
OPENAI_MODEL: getPrimaryModel(activeProfile.model),
|
OPENAI_MODEL: getPrimaryModel(activeProfile.model),
|
||||||
|
OPENAI_API_FORMAT: activeProfile.apiFormat,
|
||||||
|
OPENAI_AUTH_HEADER: activeProfile.authHeader,
|
||||||
|
OPENAI_AUTH_SCHEME: activeProfile.authScheme,
|
||||||
|
OPENAI_AUTH_HEADER_VALUE: activeProfile.authHeaderValue,
|
||||||
OPENAI_API_KEY: activeProfile.apiKey,
|
OPENAI_API_KEY: activeProfile.apiKey,
|
||||||
},
|
},
|
||||||
} as const)
|
} as const)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const SECRET_ENV_KEYS = [
|
const SECRET_ENV_KEYS = [
|
||||||
'OPENAI_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
|
'OPENAI_AUTH_HEADER_VALUE',
|
||||||
'CODEX_API_KEY',
|
'CODEX_API_KEY',
|
||||||
'GEMINI_API_KEY',
|
'GEMINI_API_KEY',
|
||||||
'GOOGLE_API_KEY',
|
'GOOGLE_API_KEY',
|
||||||
|
|||||||
Reference in New Issue
Block a user