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:
TechBrewBoss
2026-04-26 07:24:03 -05:00
committed by GitHub
parent a3e728a114
commit 6dedffe5ff
14 changed files with 938 additions and 107 deletions

View File

@@ -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

View File

@@ -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')

View File

@@ -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,28 +1496,59 @@ 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>
<Box flexDirection="row" gap={1}> {currentStepKey === 'apiFormat' ? (
<Text>{figures.pointer}</Text> <Select
<TextInput options={[
value={currentValue} {
onChange={value => value: 'chat_completions',
setDraft(prev => ({ label: 'Chat Completions',
...prev, description: 'Use /chat/completions for broad OpenAI-compatible support',
[currentStepKey]: value, },
})) {
value: 'responses',
label: 'Responses',
description: 'Use /responses for providers that support the Responses API',
},
]}
defaultValue={
currentValue === 'responses' ? 'responses' : 'chat_completions'
} }
onSubmit={handleFormSubmit} defaultFocusValue={
focus={true} currentValue === 'responses' ? 'responses' : 'chat_completions'
showCursor={true} }
placeholder={`${currentStep.placeholder}${figures.ellipsis}`} onChange={value => handleFormSubmit(value)}
mask={currentStepKey === 'apiKey' ? '*' : undefined} onCancel={handleBackFromForm}
columns={80} visibleOptionCount={2}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/> />
</Box> ) : (
<Box flexDirection="row" gap={1}>
<Text>{figures.pointer}</Text>
<TextInput
value={currentValue}
onChange={value =>
setDraft(prev => ({
...prev,
[currentStepKey]: value,
}))
}
onSubmit={handleFormSubmit}
focus={true}
showCursor={true}
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
mask={
currentStepKey === 'apiKey' ||
currentStepKey === 'authHeaderValue'
? '*'
: undefined
}
columns={80}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</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.

View File

@@ -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'))

View File

@@ -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

View File

@@ -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,
) )

View File

@@ -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'

View File

@@ -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,11 +597,16 @@ 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'
: 'chat_completions' : requestedApiFormat === 'responses'
? 'responses'
: '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")
// For GitHub Models/custom endpoints: // For GitHub Models/custom endpoints:

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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',