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:
@@ -7,6 +7,10 @@ const originalEnv = {
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
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,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
GH_TOKEN: process.env.GH_TOKEN,
|
||||
@@ -75,6 +79,10 @@ beforeEach(() => {
|
||||
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
||||
process.env.OPENAI_API_KEY = 'test-key'
|
||||
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.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
@@ -94,6 +102,10 @@ afterEach(() => {
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
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('GITHUB_TOKEN', originalEnv.GITHUB_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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* Environment variables:
|
||||
* CLAUDE_CODE_USE_OPENAI=1 — enable this provider
|
||||
* 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_MODEL=gpt-4o — default model override
|
||||
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
|
||||
@@ -74,6 +78,7 @@ import { createStreamState, processStreamChunk, getStreamStats } from '../../uti
|
||||
|
||||
type SecretValueSource = Partial<{
|
||||
OPENAI_API_KEY: string
|
||||
OPENAI_AUTH_HEADER_VALUE: string
|
||||
CODEX_API_KEY: string
|
||||
GEMINI_API_KEY: string
|
||||
GOOGLE_API_KEY: string
|
||||
@@ -1350,7 +1355,11 @@ class OpenAIShimMessages {
|
||||
if (params.stream) {
|
||||
const isResponsesStream = response.url?.includes('/responses')
|
||||
return new OpenAIShimStream(
|
||||
(request.transport === 'codex_responses' || isResponsesStream)
|
||||
(
|
||||
request.transport === 'codex_responses' ||
|
||||
request.transport === 'responses' ||
|
||||
isResponsesStream
|
||||
)
|
||||
? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
|
||||
: openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
|
||||
)
|
||||
@@ -1365,7 +1374,11 @@ class OpenAIShimMessages {
|
||||
}
|
||||
|
||||
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') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
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> = {
|
||||
'Content-Type': 'application/json',
|
||||
...this.defaultHeaders,
|
||||
@@ -1656,6 +1728,15 @@ class OpenAIShimMessages {
|
||||
this.providerOverride?.apiKey ??
|
||||
process.env.OPENAI_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
|
||||
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||
let isAzure = false
|
||||
@@ -1670,15 +1751,27 @@ class OpenAIShimMessages {
|
||||
isBankr = request.baseUrl.toLowerCase().includes('bankr')
|
||||
} catch { /* malformed URL — not Bankr */ }
|
||||
|
||||
if (apiKey) {
|
||||
if (isAzure) {
|
||||
if (authValue) {
|
||||
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
|
||||
headers['api-key'] = apiKey
|
||||
headers['api-key'] = authValue
|
||||
} else if (isBankr) {
|
||||
// Bankr uses X-API-Key header instead of Bearer token
|
||||
headers['X-API-Key'] = apiKey
|
||||
headers['X-API-Key'] = authValue
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
headers.Authorization = `Bearer ${authValue}`
|
||||
}
|
||||
} else if (isGemini) {
|
||||
const geminiCredential = await resolveGeminiCredential(process.env)
|
||||
@@ -1725,8 +1818,13 @@ class OpenAIShimMessages {
|
||||
? getLocalProviderRetryBaseUrls(request.baseUrl)
|
||||
: []
|
||||
|
||||
const buildRequestUrl = (baseUrl: string): string =>
|
||||
request.transport === 'responses'
|
||||
? `${baseUrl}/responses`
|
||||
: buildChatCompletionsUrl(baseUrl)
|
||||
|
||||
let activeBaseUrl = request.baseUrl
|
||||
let chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
|
||||
let requestUrl = buildRequestUrl(activeBaseUrl)
|
||||
const attemptedLocalBaseUrls = new Set<string>([activeBaseUrl])
|
||||
let didRetryWithoutTools = false
|
||||
|
||||
@@ -1738,13 +1836,13 @@ class OpenAIShimMessages {
|
||||
continue
|
||||
}
|
||||
|
||||
const previousUrl = chatCompletionsUrl
|
||||
const previousUrl = requestUrl
|
||||
attemptedLocalBaseUrls.add(candidateBaseUrl)
|
||||
activeBaseUrl = candidateBaseUrl
|
||||
chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
|
||||
requestUrl = buildRequestUrl(activeBaseUrl)
|
||||
|
||||
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' },
|
||||
)
|
||||
|
||||
@@ -1754,10 +1852,14 @@ class OpenAIShimMessages {
|
||||
return false
|
||||
}
|
||||
|
||||
let serializedBody = JSON.stringify(body)
|
||||
let serializedBody = JSON.stringify(
|
||||
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||
)
|
||||
|
||||
const refreshSerializedBody = (): void => {
|
||||
serializedBody = JSON.stringify(body)
|
||||
serializedBody = JSON.stringify(
|
||||
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||
)
|
||||
}
|
||||
|
||||
const buildFetchInit = () => ({
|
||||
@@ -1852,7 +1954,7 @@ class OpenAIShimMessages {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
response = await fetchWithProxyRetry(
|
||||
chatCompletionsUrl,
|
||||
requestUrl,
|
||||
buildFetchInit(),
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -1871,7 +1973,7 @@ class OpenAIShimMessages {
|
||||
}
|
||||
|
||||
const failure = classifyOpenAINetworkFailure(error, {
|
||||
url: chatCompletionsUrl,
|
||||
url: requestUrl,
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -1882,7 +1984,7 @@ class OpenAIShimMessages {
|
||||
continue
|
||||
}
|
||||
|
||||
throwClassifiedTransportError(error, chatCompletionsUrl, failure)
|
||||
throwClassifiedTransportError(error, requestUrl, failure)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
@@ -1927,50 +2029,7 @@ class OpenAIShimMessages {
|
||||
if (isGithub && response.status === 400) {
|
||||
if (errorBody.includes('/chat/completions') || errorBody.includes('not accessible')) {
|
||||
const responsesUrl = `${request.baseUrl}/responses`
|
||||
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 (!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
|
||||
}
|
||||
}
|
||||
const responsesBody = buildResponsesBody()
|
||||
|
||||
let responsesResponse: Response
|
||||
try {
|
||||
@@ -2020,8 +2079,9 @@ class OpenAIShimMessages {
|
||||
}
|
||||
|
||||
const hasToolsPayload =
|
||||
Array.isArray(body.tools) &&
|
||||
body.tools.length > 0
|
||||
request.transport === 'responses'
|
||||
? Array.isArray(params.tools) && params.tools.length > 0
|
||||
: Array.isArray(body.tools) && body.tools.length > 0
|
||||
|
||||
if (
|
||||
!didRetryWithoutTools &&
|
||||
@@ -2034,10 +2094,11 @@ class OpenAIShimMessages {
|
||||
didRetryWithoutTools = true
|
||||
delete body.tools
|
||||
delete body.tool_choice
|
||||
omitResponsesTools = true
|
||||
refreshSerializedBody()
|
||||
|
||||
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' },
|
||||
)
|
||||
continue
|
||||
@@ -2050,7 +2111,7 @@ class OpenAIShimMessages {
|
||||
errorBody,
|
||||
errorResponse,
|
||||
response.headers as unknown as Headers,
|
||||
chatCompletionsUrl,
|
||||
requestUrl,
|
||||
rateHint,
|
||||
failure,
|
||||
)
|
||||
|
||||
@@ -12,12 +12,22 @@ const originalEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
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(() => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
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'])
|
||||
|
||||
export type ProviderTransport = 'chat_completions' | 'codex_responses'
|
||||
export type ProviderTransport = 'chat_completions' | 'responses' | 'codex_responses'
|
||||
export type OpenAICompatibleApiFormat = 'chat_completions' | 'responses'
|
||||
|
||||
export type ResolvedProviderRequest = {
|
||||
transport: ProviderTransport
|
||||
@@ -200,6 +201,30 @@ function parseReasoningEffort(value: string | undefined): ReasoningEffort | unde
|
||||
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 {
|
||||
const trimmed = model.trim()
|
||||
const queryIndex = trimmed.indexOf('?')
|
||||
@@ -482,6 +507,7 @@ export function resolveProviderRequest(options?: {
|
||||
baseUrl?: string
|
||||
fallbackModel?: string
|
||||
reasoningEffortOverride?: ReasoningEffort
|
||||
apiFormat?: OpenAICompatibleApiFormat | string
|
||||
}): ResolvedProviderRequest {
|
||||
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
|
||||
@@ -571,11 +597,16 @@ export function resolveProviderRequest(options?: {
|
||||
? normalizeGithubModelsApiModel(requestedModel)
|
||||
: requestedModel
|
||||
|
||||
const requestedApiFormat =
|
||||
parseOpenAICompatibleApiFormat(options?.apiFormat) ??
|
||||
parseOpenAICompatibleApiFormat(process.env.OPENAI_API_FORMAT)
|
||||
const transport: ProviderTransport =
|
||||
shouldUseCodexTransport(requestedModel, finalBaseUrl) ||
|
||||
(isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel))
|
||||
? '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 Models/custom endpoints:
|
||||
|
||||
Reference in New Issue
Block a user