feat(provider): route GPT-5.6 models to the OpenAI Responses API (#1961)

* feat(provider): route GPT-5.6 models to the OpenAI Responses API

GPT-5.4/5.5/5.6 (incl. gpt-5.6-sol/terra/luna) reject function tools +
reasoning_effort on /v1/chat/completions, so an agent CLI (which always
sends tools) can't use them. Add a model+base predicate
(modelRequiresResponsesApi) that auto-selects the existing /v1/responses
transport for these models on api.openai.com and Azure OpenAI hosts.
Precedence: explicit responses/responses_compat > catalog
requiredApiFormat > explicit chat_completions > predicate > default. The
gpt-5.6 catalog entries deliberately set no requiredApiFormat so the
chat_completions escape hatch works for them. Register the gpt-5.6
descriptors and openai-vendor catalog entries (with reasoning metadata so
buildResponsesBody emits nested reasoning.effort).

Also fix a latent bug: the responses branch of buildRequestUrl emitted a
bare ${base}/responses and skipped Azure handling, so a forced/auto
responses route 404'd on Azure. It now mirrors buildChatCompletionsUrl —
deployment-style bases get the deployment path + api-version, bases
already containing /deployments/ keep their path and gain api-version,
while the modern Azure v1 surface (.../openai/v1) is preserved as
${base}/responses.

* fix(provider): honor OPENAI_AZURE_STYLE in the responses gate and use the Azure v1 responses surface

CodeRabbit review on #1961: the responses auto-route gate only checked
hostnames, ignoring the OPENAI_AZURE_STYLE override the shim honors for
custom/private Azure endpoints (APIM-fronted, private link). The Azure
detection is now a single shared predicate (isAzureStyleBaseUrl) used by
both the gate and the shim: OPENAI_AZURE_STYLE truthiness first, then
hostname matching.

Per Microsoft's docs, the Responses API exists only on the Azure v1
surface ({resource}/openai/v1/responses, model in the request body, no
api-version, no deployment-scoped form), so buildResponsesUrl now
normalizes any Azure-style base to that surface instead of mirroring the
chat builder's deployment-path + api-version form, which built endpoints
that do not exist.
https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses

* fix(provider): address maintainer review on GPT-5.6 responses routing

Narrows the responses auto-route to verified variants (gpt-5.4/5.5/5.6
minus -mini/-nano, two-digit minors deliberately unmatched), corrects the
gpt-5.6 context window to 1,050,000 per the OpenAI model pages, documents
OPENAI_AZURE_STYLE's routing effect in .env.example, and hardens
buildResponsesUrl normalization (trailing-slash strip, stacked Azure
suffixes stripped until stable). Also pins --max-concurrency=1 on the
default test script and adds direct coverage for isAzureStyleBaseUrl, the
override-driven responses URL, and the gpt-5.6 catalog metadata.

* test(provider): pin responses predicate behavior for patch and suffixed ids

Pins gpt-5.4.1 (patch of a verified family, routed), gpt-5.41 (two-digit
minor read, not routed), and gpt-5.6-mini-high (mini variant, not routed)
so the predicate's edge behavior is asserted rather than implied.

* fix(provider): responses-contract test, regional OpenAI hosts, Azure deployment docs, env allowlist

Updates the providerOverride gpt-5.4 effort test to the Responses contract
the auto-route now sends (nested reasoning.effort, /responses URL); widens
the auto-route host check to OpenAI-controlled *.api.openai.com regional
endpoints; documents and regression-tests the explicit
OPENAI_API_FORMAT=responses path for arbitrary Azure deployment names; and
allows OPENAI_AZURE_STYLE through the --provider-env-file allowlist.

* fix(provider): narrow responses auto-route to verified minors and carry GPT-5.6 reasoning metadata on Azure

Narrows the model-name auto-route predicate from gpt-5.[4-9] to gpt-5.[4-6]
so unverified future minors (5.7/5.8/5.9) are not auto-routed, and syncs the
comment plus the two remaining "5.4+" phrasings in .env.example.

Fixes GPT-5.6 reasoning metadata on Azure and regional OpenAI bases: those
hosts resolve to route 'custom' (empty catalog), so resolveCatalogReasoningMetadata
returned undefined and the request dropped its default 'high' effort and the
reasoning.encrypted_content include. It now falls back to the openai vendor
catalog by model name on route 'custom', so gpt-5.6 carries its advertised
default 'high' and xhigh instead of incidental legacy controls.

* fix(provider): gate the custom-route reasoning fallback to verified OpenAI/Azure bases

The round-3 custom-route fallback also fired for arbitrary OpenAI-compatible
gateways (which resolve to route 'custom' too), injecting a default
reasoning_effort:high on a chat_completions request those gateways may reject
— a behavior change on third-party gateways the PR promised not to make.
Gate the fallback on baseUrlSupportsResponsesAutoRoute (the same verified
OpenAI/Azure surfaces the Responses auto-route uses), threading the request
base via the reasoning context (process.env fallback for the upstream path).

* test(provider): isolate OPENAI_API_BASE/OPENAI_AZURE_STYLE in the gpt-5.6 reasoning tests

The Azure/regional/gateway reasoning tests snapshot-restored only
CLAUDE_CODE_USE_OPENAI/OPENAI_BASE_URL/OPENAI_API_KEY. A leaked
OPENAI_AZURE_STYLE from another test would make isAzureStyleBaseUrl treat
the gateway base as Azure-style, firing the fallback and flipping the
'no injected default' assertion. Snapshot both keys and delete them before
each test's setup so a leaked value cannot corrupt the result.

* fix: preserve GPT-5.6 fallback and Azure routing

* fix: cover GPT-5.6 Azure edge cases

* fix: cover GPT-5 forced chat tools

* fix(provider): narrow Azure-style responses routing

* fix(provider): isolate agent overrides from Azure mode

* fix(provider): isolate override reasoning from Azure mode

* fix(provider): isolate override API format

* fix(provider): preserve responses effort routing

* fix(provider): restore safe context and clear Azure mode

* fix(provider): preserve Azure routing state

* fix(provider): preserve Azure profile routing

* fix(provider): preserve automatic Responses routing in profiles

---------

Co-authored-by: jatmn <the@jat.mn>
This commit is contained in:
Calvin
2026-07-18 22:47:24 +08:00
committed by GitHub
co-authored by jatmn
parent 0effa0f42b
commit de76950f60
26 changed files with 1492 additions and 82 deletions
+18 -2
View File
@@ -208,9 +208,25 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
# OPENAI_BASE_URL=https://api.openai.com/v1
# Choose the OpenAI-compatible API surface (optional — defaults to chat_completions)
# Supported: chat_completions, responses
# Choose the OpenAI-compatible API surface (optional).
# Supported: chat_completions, responses, responses_compat
# Left unset, GPT-5.4/5.5/5.6 models (mini/nano variants excluded) on
# api.openai.com or Azure OpenAI are auto-routed to responses (they reject
# function tools + reasoning_effort on chat_completions); everything else
# defaults to chat_completions. Set this to override: an explicit
# chat_completions wins over the model auto-detection, but a provider
# catalog's requiredApiFormat still takes precedence over it. GPT-5.6 omits
# reasoning effort on this fallback because chat_completions rejects it with tools.
# OPENAI_API_FORMAT=chat_completions
# Force Azure-style URL and api-key-header handling for
# custom or private Azure endpoints whose hostname would not otherwise match
# (APIM-fronted, private link). Note: this also widens the GPT-5.4/5.5/5.6
# responses auto-route above to ANY base URL — do not set it against gateways
# that lack /v1/responses.
# On Azure, OPENAI_MODEL is your deployment name; if it is not the model id
# (e.g. production-coding), the model-name auto-route cannot recognize it —
# set OPENAI_API_FORMAT=responses explicitly for gpt-5.4/5.5/5.6 deployments.
# OPENAI_AZURE_STYLE=1
# 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.
+1 -1
View File
@@ -53,7 +53,7 @@
"web:build": "bun run --cwd web build",
"web:preview": "bun run --cwd web preview",
"web:typecheck": "bun run --cwd web typecheck",
"test": "bun test --feature=UNATTENDED_RETRY",
"test": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:full": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:coverage": "bun test --feature=UNATTENDED_RETRY --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
+1 -1
View File
@@ -698,7 +698,7 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API mode') && frame.includes('Chat Completions'),
frame.includes('API mode') && frame.includes('Automatic'),
)
expect(output).toContain('Responses')
} finally {
+22 -13
View File
@@ -199,8 +199,8 @@ const FORM_STEPS: Array<{
{
key: 'apiFormat',
label: 'API mode',
placeholder: 'chat_completions',
helpText: 'Choose the OpenAI-compatible API surface for this provider.',
placeholder: 'automatic',
helpText: 'Automatically select the API surface, or choose one explicitly.',
optional: true,
},
{
@@ -251,7 +251,7 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
baseUrl: profile.baseUrl,
model: profile.model,
apiKey: profile.apiKey ?? '',
apiFormat: profile.apiFormat ?? 'chat_completions',
apiFormat: profile.apiFormat ?? 'auto',
authHeader: profile.authHeader ?? '',
authHeaderValue: profile.authHeaderValue ?? '',
customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '',
@@ -319,7 +319,7 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
const modeInfo =
routeSupportsApiFormatSelection(routeId)
? ` · ${profile.apiFormat === 'responses_compat' ? 'responses (compat)' : profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}`
? ` · ${profile.apiFormat === 'responses_compat' ? 'responses (compat)' : profile.apiFormat === 'responses' ? 'responses' : profile.apiFormat === 'chat_completions' ? 'chat/completions' : 'automatic'}`
: ''
const authInfo =
routeSupportsAuthHeaders(routeId) && profile.authHeader
@@ -1581,7 +1581,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
baseUrl: defaults.baseUrl,
model: defaults.model,
apiKey: defaults.apiKey ?? '',
apiFormat: 'chat_completions',
apiFormat: preset === 'custom' ? 'auto' : 'chat_completions',
authHeader: '',
authHeaderValue: '',
customHeaders: '',
@@ -1674,17 +1674,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const requestedResponses =
supportsApiFormat && (nextDraft.apiFormat === 'responses' || nextDraft.apiFormat === 'responses_compat')
const shouldUseChatCompletions =
!supportsApiFormat ||
(nextDraft.apiFormat !== 'responses' && nextDraft.apiFormat !== 'responses_compat') ||
!routeSupportsResponsesModel(routeId, nextDraft.model)
const selectedApiFormat =
!supportsApiFormat
? 'chat_completions'
: nextDraft.apiFormat === 'auto'
? undefined
: requestedResponses && !routeSupportsResponsesModel(routeId, nextDraft.model)
? 'chat_completions'
: nextDraft.apiFormat as OpenAICompatibleApiFormat
const payload: ProviderProfileInput = {
provider,
name: nextDraft.name,
baseUrl: nextDraft.baseUrl,
model: nextDraft.model,
apiKey: nextDraft.apiKey,
apiFormat: shouldUseChatCompletions ? 'chat_completions' : (nextDraft.apiFormat as OpenAICompatibleApiFormat),
apiFormat: selectedApiFormat,
authHeader:
showsAuthHeader && nextDraft.authHeader
? nextDraft.authHeader
@@ -2206,6 +2210,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
{currentStepKey === 'apiFormat' ? (
<Select
options={[
{
value: 'auto',
label: 'Automatic',
description: 'Use the provider and model defaults',
},
{
value: 'chat_completions',
label: 'Chat Completions',
@@ -2223,14 +2232,14 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
},
]}
defaultValue={
currentValue === 'responses_compat' ? 'responses_compat' : currentValue === 'responses' ? 'responses' : 'chat_completions'
currentValue === 'responses_compat' ? 'responses_compat' : currentValue === 'responses' ? 'responses' : currentValue === 'chat_completions' ? 'chat_completions' : 'auto'
}
defaultFocusValue={
currentValue === 'responses_compat' ? 'responses_compat' : currentValue === 'responses' ? 'responses' : 'chat_completions'
currentValue === 'responses_compat' ? 'responses_compat' : currentValue === 'responses' ? 'responses' : currentValue === 'chat_completions' ? 'chat_completions' : 'auto'
}
onChange={(value: string) => handleFormSubmit(value)}
onCancel={handleBackFromForm}
visibleOptionCount={3}
visibleOptionCount={4}
/>
) : (
<Box flexDirection="row" gap={1}>
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from 'bun:test'
import gptModels from './models/gpt.js'
import openaiVendor from './vendors/openai.js'
const GPT56_IDS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'] as const
test('gpt-5.6 model descriptors carry the verified limits', () => {
for (const id of GPT56_IDS) {
const descriptor = gptModels.find(model => model.id === id)
expect(descriptor).toBeDefined()
expect(descriptor?.contextWindow).toBe(1_050_000)
expect(descriptor?.maxOutputTokens).toBe(128_000)
}
})
test('openai vendor gpt-5.6 catalog entries carry limits and reasoning metadata', () => {
for (const id of GPT56_IDS) {
const entry = openaiVendor.catalog?.models?.find(model => model.id === id)
expect(entry).toBeDefined()
expect(entry?.contextWindow).toBe(1_050_000)
expect(entry?.maxOutputTokens).toBe(128_000)
expect(entry?.reasoning?.wireFormat).toBe('reasoning_effort')
expect(entry?.reasoning?.levels).toContain('xhigh')
}
})
+7
View File
@@ -29,6 +29,13 @@ function gptModel(
}
export default [
// gpt-5.6 (sol/terra/luna) reject /v1/chat/completions when function tools
// are combined with reasoning_effort — they must use /v1/responses. The
// openai-vendor catalog carries their reasoning metadata; the responses
// routing is handled by modelRequiresResponsesApi in providerConfig.
gptModel('gpt-5.6-sol', 'GPT-5.6 Sol', 1_050_000, 128_000),
gptModel('gpt-5.6-terra', 'GPT-5.6 Terra', 1_050_000, 128_000),
gptModel('gpt-5.6-luna', 'GPT-5.6 Luna', 1_050_000, 128_000),
// gpt-5.5 via Codex transport caps at ~272k effective input tokens; the
// 1.05M API descriptor value caused /context to under-report usage and
// auto-compact to fire too late, yielding 500 "input exceeds the context
+11 -1
View File
@@ -53,7 +53,17 @@ export default defineVendor({
catalog: {
source: 'static',
models: [
{ id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT-5.4' },
// gpt-5.6 (sol/terra/luna): reject function tools + reasoning_effort on
// /v1/chat/completions, so modelRequiresResponsesApi routes them to
// /v1/responses. The reasoning metadata here makes buildResponsesBody
// emit nested reasoning.effort; requiredApiFormat is intentionally NOT
// set so an explicit OPENAI_API_FORMAT=chat_completions stays an escape
// hatch.
{ id: 'gpt-5.6-sol', apiName: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', modelDescriptorId: 'gpt-5.6-sol', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } },
{ id: 'gpt-5.6-terra', apiName: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', modelDescriptorId: 'gpt-5.6-terra', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } },
{ id: 'gpt-5.6-luna', apiName: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', modelDescriptorId: 'gpt-5.6-luna', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } },
{ id: 'gpt-5.5', apiName: 'gpt-5.5', label: 'GPT-5.5', modelDescriptorId: 'gpt-5.5', contextWindow: 272_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } },
{ id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT-5.4', modelDescriptorId: 'gpt-5.4', contextWindow: 1_050_000, maxOutputTokens: 128_000, capabilities: { supportsReasoning: true }, reasoning: { mode: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], defaultLevel: 'high', wireFormat: 'reasoning_effort' } },
{ id: 'gpt-5-mini', apiName: 'gpt-5-mini', label: 'GPT-5 Mini' },
{ id: 'gpt-4o', apiName: 'gpt-4o', label: 'GPT-4o' },
{ id: 'gpt-4o-mini', apiName: 'gpt-4o-mini', label: 'GPT-4o Mini' },
+2
View File
@@ -703,6 +703,7 @@ describe('applyAgentProviderOverrideToEnv', () => {
ANTHROPIC_MODEL: 'claude-parent',
ANTHROPIC_API_KEY: 'anthropic-key',
OPENAI_API_BASE: 'https://old.example/v1',
OPENAI_AZURE_STYLE: '1',
OPENAI_AUTH_HEADER: 'X-Old-Key',
}
@@ -725,6 +726,7 @@ describe('applyAgentProviderOverrideToEnv', () => {
expect(env.GEMINI_MODEL).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBeUndefined()
expect(env.OPENAI_API_BASE).toBeUndefined()
expect(env.OPENAI_AZURE_STYLE).toBeUndefined()
expect(env.OPENAI_AUTH_HEADER).toBeUndefined()
expect(env.GEMINI_API_KEY).toBe('gemini-key')
expect(env.ANTHROPIC_API_KEY).toBe('anthropic-key')
+1
View File
@@ -53,6 +53,7 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [
'MISTRAL_BASE_URL',
'OPENAI_API_BASE',
'OPENAI_API_FORMAT',
'OPENAI_AZURE_STYLE',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
+201 -11
View File
@@ -56,6 +56,7 @@ const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
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,
@@ -150,6 +151,7 @@ beforeEach(async () => {
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_MODEL
delete process.env.MINIMAX_API_KEY
delete process.env.XAI_API_KEY
@@ -194,6 +196,7 @@ afterEach(() => {
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
restoreEnv('OPENAI_AZURE_STYLE', originalEnv.OPENAI_AZURE_STYLE)
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)
@@ -630,6 +633,7 @@ test('env-only MiniMax fallback drops stale OpenAI shim options', async () => {
clearEnvForMiniMaxOnlyTest()
process.env.MINIMAX_API_KEY = 'minimax-test-key'
process.env.OPENAI_API_FORMAT = 'responses'
process.env.OPENAI_AZURE_STYLE = '1'
process.env.OPENAI_AUTH_HEADER = 'api-key'
process.env.OPENAI_AUTH_SCHEME = 'raw'
process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-header-value'
@@ -674,6 +678,7 @@ test('env-only MiniMax fallback drops stale OpenAI shim options', async () => {
expect(capturedHeaders?.get('x-api-key')).toBe('minimax-test-key')
expect(capturedHeaders?.get('api-key')).toBeNull()
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
expect(process.env.OPENAI_AZURE_STYLE).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined()
@@ -1586,27 +1591,27 @@ test('strips Anthropic-specific custom headers on providerOverride shim requests
})
test('providerOverride OpenAI gpt effort does not fall back to ambient provider', async () => {
let requestUrl = ''
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
globalThis.fetch = (async (input, init) => {
requestUrl = String(input)
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-provider-override-openai',
id: 'resp-provider-override-openai',
model: 'gpt-5.4',
choices: [
output: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'ok' }],
},
],
usage: {
prompt_tokens: 8,
completion_tokens: 3,
input_tokens: 8,
output_tokens: 3,
total_tokens: 11,
},
}),
@@ -1636,8 +1641,193 @@ test('providerOverride OpenAI gpt effort does not fall back to ambient provider'
stream: false,
})
expect(requestBody?.reasoning_effort).toBe('xhigh')
// gpt-5.4 on api.openai.com auto-routes to the Responses API, where effort
// is nested as reasoning.effort rather than top-level reasoning_effort.
expect(requestUrl.endsWith('/responses')).toBe(true)
expect(requestBody?.reasoning).toEqual({ effort: 'xhigh', summary: 'auto' })
expect(requestBody).not.toHaveProperty('reasoning_effort')
})
test('normal OpenAI gpt effort uses catalog metadata', async () => {
let requestBody: Record<string, unknown> | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({
id: 'resp-normal-openai', model: 'gpt-5.6-sol',
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 = (await getAnthropicClient({
maxRetries: 0,
model: 'gpt-5.6-sol',
effortValue: 'xhigh',
})) as unknown as ShimClient
await client.beta.messages.create({ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(requestBody?.reasoning).toEqual({ effort: 'xhigh', summary: 'auto' })
})
test('auto-routed Azure gpt-5.4 and gpt-5.5 requests preserve selected effort', async () => {
const requestBodies: Record<string, unknown>[] = []
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
globalThis.fetch = (async (_input, init) => {
requestBodies.push(JSON.parse(String(init?.body)))
return new Response(JSON.stringify({
id: 'resp-azure-openai',
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
for (const model of ['gpt-5.4', 'gpt-5.5']) {
const client = (await getAnthropicClient({
maxRetries: 0,
model,
effortValue: 'xhigh',
})) as unknown as ShimClient
await client.beta.messages.create({ model, messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
}
expect(requestBodies).toEqual([
expect.objectContaining({ reasoning: { effort: 'xhigh', summary: 'auto' } }),
expect.objectContaining({ reasoning: { effort: 'xhigh', summary: 'auto' } }),
])
})
test('OPENAI_API_BASE gateway does not inherit first-party GPT-5.6 effort metadata', async () => {
let requestUrl = ''
let requestBody: Record<string, unknown> | undefined
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.GEMINI_API_KEY
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_API_BASE = 'https://gateway.example/v1'
process.env.OPENAI_API_KEY = 'test-key'
globalThis.fetch = (async (input, init) => {
requestUrl = String(input)
requestBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({
id: 'chatcmpl-gateway',
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = (await getAnthropicClient({
maxRetries: 0,
model: 'gpt-5.6-sol',
effortValue: 'xhigh',
})) as unknown as ShimClient
await client.beta.messages.create({ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(requestUrl).toBe('https://gateway.example/v1/chat/completions')
expect(requestBody?.reasoning_effort).toBeUndefined()
})
test('providerOverride Azure gpt effort uses the override base for catalog metadata', async () => {
let requestBody: Record<string, unknown> | undefined
process.env.OPENAI_BASE_URL = 'https://gateway.example/v1'
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({
id: 'resp-provider-override-azure', model: 'gpt-5.6-sol',
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 = (await getAnthropicClient({
maxRetries: 0,
effortValue: 'xhigh',
providerOverride: {
model: 'gpt-5.6-sol',
baseURL: 'https://myres.openai.azure.com/openai/v1',
apiKey: 'provider-test-key',
},
})) as unknown as ShimClient
await client.beta.messages.create({ model: 'unused', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(requestBody?.reasoning).toEqual({ effort: 'xhigh', summary: 'auto' })
})
test('providerOverride does not inherit Azure-style routing from its parent', async () => {
let requestUrl = ''
let requestHeaders: Headers | undefined
let requestBody: Record<string, unknown> | undefined
process.env.OPENAI_AZURE_STYLE = '1'
globalThis.fetch = (async (input, init) => {
requestUrl = String(input)
requestHeaders = new Headers(init?.headers)
requestBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({
id: 'chatcmpl-provider-override-gateway', model: 'gpt-5.6-sol',
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as FetchType
const client = (await getAnthropicClient({
maxRetries: 0,
providerOverride: {
model: 'gpt-5.6-sol',
baseURL: 'https://gateway.example/v1',
apiKey: 'provider-test-key',
},
})) as unknown as ShimClient
await client.beta.messages.create({ model: 'unused', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(requestUrl).toBe('https://gateway.example/v1/chat/completions')
expect(requestHeaders?.get('authorization')).toBe('Bearer provider-test-key')
expect(requestHeaders?.get('api-key')).toBeNull()
expect(requestBody?.reasoning_effort).toBeUndefined()
})
test('providerOverride preserves an explicit responses format from its parent', async () => {
let requestUrl = ''
process.env.OPENAI_API_FORMAT = 'responses'
globalThis.fetch = (async (input, _init) => {
requestUrl = String(input)
return new Response(JSON.stringify({
id: 'resp-provider-override-gateway',
model: 'response-only-model',
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 = (await getAnthropicClient({
maxRetries: 0,
providerOverride: {
model: 'response-only-model',
baseURL: 'https://gateway.example/v1',
apiKey: 'provider-test-key',
},
})) as unknown as ShimClient
await client.beta.messages.create({ model: 'unused', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(requestUrl).toBe('https://gateway.example/v1/responses')
})
test('providerOverride custom OpenAI-compatible gpt effort uses legacy support', async () => {
let requestBody: Record<string, unknown> | undefined
+35 -19
View File
@@ -198,6 +198,7 @@ function applyMiniMaxEnvOnlyDefaults(model: string | undefined): void {
getRouteDefaultModel('minimax')
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -226,6 +227,7 @@ function applyXiaomiMimoEnvOnlyDefaults(): void {
getRouteDefaultModel('xiaomi-mimo')
process.env.OPENAI_API_KEY = process.env.MIMO_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -246,6 +248,7 @@ function applyXaiEnvOnlyDefaults(): void {
getRouteDefaultModel('xai')
process.env.OPENAI_API_KEY = process.env.XAI_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -281,6 +284,7 @@ function applyNearaiEnvOnlyDefaults(): void {
getRouteDefaultModel('nearai')
process.env.OPENAI_API_KEY = process.env.NEARAI_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -317,6 +321,7 @@ function applyFireworksEnvOnlyDefaults(): void {
getRouteDefaultModel('fireworks')
process.env.OPENAI_API_KEY = process.env.FIREWORKS_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -335,6 +340,7 @@ function applyAimlapiEnvOnlyDefaults(): void {
process.env.OPENAI_MODEL = modelOverride ?? getRouteDefaultModel('aimlapi')
process.env.OPENAI_API_KEY = process.env.AIMLAPI_API_KEY
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -359,48 +365,58 @@ export async function getAnthropicClient({
}): Promise<Anthropic> {
// Convert the runtime effort value to the OpenAI-shaped enum the shim
// expects. Undefined → shim falls back to descriptor/alias defaults.
const effortProcessEnv = providerOverride
? { ...process.env, OPENAI_AZURE_STYLE: undefined }
: process.env
const effortModel = providerOverride?.model ?? model
const providerOverrideRuntimeContext = providerOverride && effortModel
const effortBaseUrl =
providerOverride?.baseURL ??
process.env.OPENAI_BASE_URL ??
process.env.OPENAI_API_BASE
const effortRuntimeContext = effortModel
? resolveOpenAIShimRuntimeContext({
processEnv: process.env,
baseUrl: providerOverride.baseURL,
processEnv: effortProcessEnv,
baseUrl: effortBaseUrl,
model: effortModel,
preferBaseUrlRoute: true,
preferBaseUrlRoute:
providerOverride !== undefined || isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI),
})
: undefined
const providerOverrideShimConfig = providerOverrideRuntimeContext?.openaiShimConfig
const providerOverrideEffortContext = providerOverrideRuntimeContext
? {
routeId: providerOverrideRuntimeContext.routeId,
const effortShimConfig = effortRuntimeContext?.openaiShimConfig
const effortContext = effortRuntimeContext
? {
routeId: effortRuntimeContext.routeId ?? 'custom',
useRuntimeFallback: false,
openaiShimConfig: providerOverrideShimConfig,
apiProvider: providerOverrideRuntimeContext.routeId === 'openai'
openaiShimConfig: effortShimConfig,
baseUrl: effortBaseUrl,
processEnv: effortProcessEnv,
apiProvider: effortRuntimeContext.routeId === 'openai'
? 'openai' as const
: providerOverrideRuntimeContext.routeId === 'codex'
: effortRuntimeContext.routeId === 'codex'
? 'codex' as const
: undefined,
}
: undefined
const supportsShimReasoningEffort = effortModel
? providerOverrideShimConfig
? effortShimConfig
? modelSupportsShimReasoningEffort(
effortModel,
providerOverrideShimConfig.thinkingRequestFormat,
providerOverrideShimConfig.removeBodyFields,
providerOverrideEffortContext,
effortShimConfig.thinkingRequestFormat,
effortShimConfig.removeBodyFields,
effortContext,
)
: modelSupportsWireEffort(effortModel)
: false
const appliedProviderOverrideEffort = effortModel && effortValue !== undefined
const appliedEffort = effortModel && effortValue !== undefined
? resolveAppliedEffort(
effortModel,
effortValue,
providerOverrideEffortContext,
effortContext,
)
: undefined
const shimReasoningEffort: OpenAIEffortLevel | undefined =
appliedProviderOverrideEffort !== undefined && supportsShimReasoningEffort
? standardEffortToOpenAI(convertEffortValueToLevel(appliedProviderOverrideEffort))
appliedEffort !== undefined && supportsShimReasoningEffort
? standardEffortToOpenAI(convertEffortValueToLevel(appliedEffort))
: undefined
const containerId = process.env.CLAUDE_CODE_CONTAINER_ID
const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
+596
View File
@@ -22,6 +22,7 @@ const originalEnv = {
OPENAI_API_KEYS: process.env.OPENAI_API_KEYS,
OPENAI_MODEL: process.env.OPENAI_MODEL,
OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
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,
@@ -437,6 +438,7 @@ beforeEach(async () => {
delete process.env.OPENAI_API_KEYS
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -480,6 +482,7 @@ afterEach(() => {
restoreEnv('OPENAI_API_KEYS', originalEnv.OPENAI_API_KEYS)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
restoreEnv('OPENAI_AZURE_STYLE', originalEnv.OPENAI_AZURE_STYLE)
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)
@@ -683,6 +686,572 @@ test('nests reasoning effort for OpenAI-compatible responses endpoint', async ()
expect(capturedBody).not.toHaveProperty('reasoning_summary')
})
test('auto-routes gpt-5.6 to /responses on api.openai.com with tools and nested reasoning', async () => {
// No OPENAI_API_FORMAT set: the model+base predicate must pick responses.
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
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.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({ reasoningEffort: 'high' }) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
name: 'get_weather',
description: 'Get the weather',
input_schema: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
},
],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://api.openai.com/v1/responses')
expect(Array.isArray(capturedBody?.tools)).toBe(true)
expect((capturedBody?.tools as unknown[]).length).toBe(1)
expect(JSON.stringify(capturedBody?.tools)).toContain('get_weather')
expect(capturedBody?.reasoning).toEqual({ effort: 'high', summary: 'auto' })
expect(capturedBody).not.toHaveProperty('reasoning_effort')
})
test('gpt-5.6 chat-completions escape hatch omits reasoning effort with tools', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENAI_API_FORMAT = 'chat_completions'
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: 'chatcmpl-1',
model: 'gpt-5.6-sol',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as FetchType
const client = createOpenAIShimClient({ reasoningEffort: 'high' }) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
tools: [{
name: 'get_weather',
description: 'Get the weather',
input_schema: { type: 'object', properties: {} },
}],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://api.openai.com/v1/chat/completions')
expect(capturedBody?.tools).toBeDefined()
expect(capturedBody).not.toHaveProperty('reasoning_effort')
})
test('gpt-5.4 chat-completions escape hatch omits reasoning effort with tools', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENAI_API_FORMAT = 'chat_completions'
let capturedBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(JSON.stringify({
id: 'chatcmpl-1', model: 'gpt-5.4',
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as unknown as FetchType
const client = createOpenAIShimClient({ reasoningEffort: 'high' }) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello' }],
tools: [{ name: 'get_weather', description: 'Get the weather', input_schema: { type: 'object', properties: {} } }],
max_tokens: 64,
stream: false,
})
expect(capturedBody).not.toHaveProperty('reasoning_effort')
})
test('gpt-5.6 chat-completions escape hatch keeps reasoning effort without tools', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENAI_API_FORMAT = 'chat_completions'
let capturedBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-5.6-sol',
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as FetchType
const client = createOpenAIShimClient({ reasoningEffort: 'high' }) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedBody?.reasoning_effort).toBe('high')
})
test('auto-route leaves non gpt-5.4+ models on chat/completions', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-4o',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://api.openai.com/v1/chat/completions')
})
test('auto-route does NOT fire for arbitrary non-OpenAI gateway bases', async () => {
process.env.OPENAI_BASE_URL = 'https://gateway.example/v1'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-5.6-sol',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://gateway.example/v1/chat/completions')
})
test('auto-routed responses on a bare Azure resource base normalizes to the v1 surface', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'resp-1',
model: 'gpt-5.6-terra',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-terra',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('auto-routed responses on the Azure v1 base appends /responses without rewriting the path', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'resp-1',
model: 'gpt-5.6-luna',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-luna',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('Azure responses URL normalization drops a configured query string', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1?api-version=2024-12-01-preview'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(JSON.stringify({
id: 'resp-1', model: 'gpt-5.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('Azure responses URL normalization drops a query string after a trailing slash', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1/?api-version=2024-12-01-preview'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(JSON.stringify({
id: 'resp-1', model: 'gpt-5.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('Azure chat-completions URL normalization drops a configured query string', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1?api-version=2024-12-01-preview'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENAI_API_FORMAT = 'chat_completions'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(JSON.stringify({
id: 'chatcmpl-1', model: 'gpt-5.6-sol',
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}), { headers: { 'Content-Type': 'application/json' } })
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64, stream: false })
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/deployments/gpt-5.6-sol/chat/completions?api-version=2024-12-01-preview')
})
test('auto-routed responses on an Azure /deployments/ base strips the deployment and uses the v1 surface', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/deployments/my-gpt56'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'resp-1',
model: 'gpt-5.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('OPENAI_AZURE_STYLE routes gpt-5.6 on a custom base to {base}/openai/v1/responses', async () => {
process.env.OPENAI_BASE_URL = 'https://apim.contoso.example/azure-openai'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENAI_AZURE_STYLE = '1'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'resp-1',
model: 'gpt-5.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://apim.contoso.example/azure-openai/openai/v1/responses')
})
test('Azure responses URL normalization strips stacked v1 and deployment suffixes', async () => {
process.env.OPENAI_BASE_URL =
'https://myres.openai.azure.com/openai/deployments/my-gpt56/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'resp-1',
model: 'gpt-5.6-terra',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-terra',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
})
test('explicit OPENAI_API_FORMAT=responses works for arbitrary Azure deployment names', async () => {
// Azure deployment names are arbitrary, so the model-name auto-route cannot
// recognize them; the documented path is the explicit responses format.
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
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: 'production-coding',
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 unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'production-coding',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe('https://myres.openai.azure.com/openai/v1/responses')
expect(capturedBody?.model).toBe('production-coding')
})
test('arbitrary Azure deployment names stay on chat/completions without the explicit format', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
let capturedUrl = ''
globalThis.fetch = (async (input, _init) => {
capturedUrl = String(input)
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'production-coding',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'production-coding',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl).toBe(
'https://myres.openai.azure.com/openai/deployments/production-coding/chat/completions?api-version=2024-12-01-preview',
)
})
test('auto-routed gpt-5.6 on an Azure base nests reasoning.effort and the encrypted-content include', async () => {
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
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.6-sol',
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 unknown as FetchType
const client = createOpenAIShimClient({ reasoningEffort: 'high' }) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
expect(capturedUrl.endsWith('/openai/v1/responses')).toBe(true)
expect(capturedBody?.reasoning).toEqual({ effort: 'high', summary: 'auto' })
expect(capturedBody?.include).toEqual(['reasoning.encrypted_content'])
})
test('uses OpenAI-compatible responses endpoint with text chunk types when OPENAI_API_FORMAT=responses_compat', async () => {
process.env.OPENAI_API_FORMAT = 'responses_compat'
let capturedUrl = ''
@@ -2470,6 +3039,27 @@ test('uses max_tokens instead of max_completion_tokens for local providers', asy
})
})
test('does not send stream_options to local OpenAI-compatible servers', async () => {
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8000/v1'
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body))
expect(body.stream).toBe(true)
expect(body.stream_options).toBeUndefined()
return new Response('', {
headers: { 'Content-Type': 'text/event-stream' },
})
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'local-vllm-model',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: true,
})
})
test('keeps max_completion_tokens for non-local non-github providers', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
@@ -9340,6 +9930,9 @@ test('strips Anthropic attribution header block from responses-API instructions
test('emits reasoning_effort on chat_completions when reasoningEffort is passed', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
// gpt-5.4 now auto-routes to /responses on api.openai.com; opt back into
// chat_completions to exercise its top-level reasoning_effort serialization.
process.env.OPENAI_API_FORMAT = 'chat_completions'
let requestBody: Record<string, unknown> | undefined
@@ -9414,6 +10007,9 @@ test('omits reasoning_effort on chat_completions when no override and model has
test('emits reasoning_effort from codex alias default when no override is passed', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
// gpt-5.4 now auto-routes to /responses on api.openai.com; opt back into
// chat_completions to exercise its top-level reasoning_effort serialization.
process.env.OPENAI_API_FORMAT = 'chat_completions'
let requestBody: Record<string, unknown> | undefined
+69 -28
View File
@@ -82,9 +82,12 @@ import {
getLocalFastPathConfig,
getLocalProviderRetryBaseUrls,
getGithubEndpointType,
baseUrlSupportsResponsesAutoRoute,
isAzureStyleBaseUrl,
isDirectLocalOllamaEndpoint,
isLikelyOllamaEndpoint,
isLocalProviderUrl,
modelRequiresResponsesApi,
resolveRuntimeCodexCredentials,
resolveProviderRequest,
shouldAttemptLocalToollessRetry,
@@ -3559,8 +3562,21 @@ class OpenAIShimMessages {
let httpResponse: Response | undefined
const promise = (async () => {
const request = resolveProviderRequest({ model: self.providerOverride?.model ?? params.model, baseUrl: self.providerOverride?.baseURL, reasoningEffortOverride: self.reasoningEffort })
const response = await self._doRequest(request, params, options)
// A provider override is a complete route, so it must not inherit an
// Azure-style escape hatch intended for the parent route.
const requestProcessEnv = self.providerOverride
? {
...process.env,
OPENAI_AZURE_STYLE: undefined,
}
: process.env
const request = resolveProviderRequest({
model: self.providerOverride?.model ?? params.model,
baseUrl: self.providerOverride?.baseURL,
reasoningEffortOverride: self.reasoningEffort,
processEnv: requestProcessEnv,
})
const response = await self._doRequest(request, params, options, requestProcessEnv)
httpResponse = response
if (params.stream) {
@@ -3672,6 +3688,7 @@ class OpenAIShimMessages {
request: ReturnType<typeof resolveProviderRequest>,
params: ShimCreateParams,
options?: { signal?: AbortSignal; headers?: Record<string, string> },
requestProcessEnv: NodeJS.ProcessEnv = process.env,
): Promise<Response> {
const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubMode = isGithubModelsMode()
@@ -3776,13 +3793,14 @@ class OpenAIShimMessages {
})
}
return this._doOpenAIRequest(request, params, options)
return this._doOpenAIRequest(request, params, options, requestProcessEnv)
}
private async _doOpenAIRequest(
request: ReturnType<typeof resolveProviderRequest>,
params: ShimCreateParams,
options?: { signal?: AbortSignal; headers?: Record<string, string> },
requestProcessEnv: NodeJS.ProcessEnv = process.env,
): Promise<Response> {
// Local backends (llama.cpp, vLLM, Ollama, LM Studio, …) do not implement
// the cloud-side caching/strict-validation behaviours that several of our
@@ -3799,7 +3817,7 @@ class OpenAIShimMessages {
? rawMessages
: compressToolHistory(rawMessages, request.resolvedModel)
const runtimeShimContext = resolveOpenAIShimRuntimeContext({
processEnv: process.env,
processEnv: requestProcessEnv,
baseUrl: request.baseUrl,
model: request.resolvedModel,
treatAsLocal: isLocalProviderUrl(request.baseUrl),
@@ -3836,14 +3854,25 @@ class OpenAIShimMessages {
routeId: runtimeShimContext.routeId,
useRuntimeFallback: false,
openaiShimConfig: shimConfig,
baseUrl: request.baseUrl,
processEnv: requestProcessEnv,
})
// The explicit chat-completions escape hatch for GPT-5.4/5.5/5.6 must
// also omit reasoning effort: these models reject the tools + effort
// combination on that API surface.
const suppressReasoningForForcedChat =
effectiveTransport === 'chat_completions' &&
Array.isArray(params.tools) &&
params.tools.length > 0 &&
modelRequiresResponsesApi(request.resolvedModel) &&
baseUrlSupportsResponsesAutoRoute(request.baseUrl, requestProcessEnv)
const reasoningRequestPlan = resolveOpenAIShimReasoningRequestPlan({
model: request.resolvedModel,
requestedEffort: request.reasoning?.effort,
requestedEffort: suppressReasoningForForcedChat ? undefined : request.reasoning?.effort,
requestThinkingType: (params.thinking as { type?: string } | undefined)?.type,
defaultThinkingType: request.thinking?.type,
thinkingRequestFormat: shimConfig.thinkingRequestFormat,
routeId: runtimeShimContext.routeId,
routeId: runtimeShimContext.routeId ?? 'custom',
useRuntimeFallback: false,
reasoningControl,
})
@@ -3877,7 +3906,7 @@ class OpenAIShimMessages {
body.max_completion_tokens = maxCompletionTokensValue
}
if (params.stream && !isLikelyOllamaEndpoint(request.baseUrl)) {
if (params.stream && !isLocalProviderUrl(request.baseUrl)) {
body.stream_options = { include_usage: true }
}
@@ -4349,22 +4378,9 @@ class OpenAIShimMessages {
new Headers(),
)
}
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
// path segments like https://evil.com/cognitiveservices.azure.com/
let isAzure = isEnvTruthy(process.env.OPENAI_AZURE_STYLE)
if (!isAzure) {
try {
const { hostname } = new URL(request.baseUrl)
isAzure =
hostname.endsWith('.azure.com') &&
(hostname.includes('cognitiveservices') ||
hostname.includes('openai') ||
hostname.includes('services.ai') ||
hostname.includes('inference.ml'))
} catch {
/* malformed URL — not Azure */
}
}
// Reads live process.env by design; must agree with the responses
// auto-route gate's processEnv (both default to process.env today).
const isAzure = isAzureStyleBaseUrl(request.baseUrl, requestProcessEnv)
let isBankr = false
try {
@@ -4448,17 +4464,17 @@ class OpenAIShimMessages {
// Azure Cognitive Services / Azure OpenAI require a deployment-specific
// path and an api-version query parameter.
if (isAzure) {
const normalizedBaseUrl = (baseUrl.split(/[?#]/, 1)[0] ?? baseUrl).replace(/\/+$/, '')
const apiVersion = process.env.AZURE_OPENAI_API_VERSION ?? '2024-12-01-preview'
const deployment = encodeURIComponent(request.resolvedModel ?? process.env.OPENAI_MODEL ?? 'gpt-4o')
// If base URL already contains /deployments/, use it as-is with api-version.
if (/\/deployments\//i.test(baseUrl)) {
const normalizedBase = baseUrl.replace(/\/+$/, '')
return `${normalizedBase}/chat/completions?api-version=${apiVersion}`
if (/\/deployments\//i.test(normalizedBaseUrl)) {
return `${normalizedBaseUrl}/chat/completions?api-version=${apiVersion}`
}
// Strip trailing /v1 or /openai/v1 if present, then build Azure path.
const normalizedBase = baseUrl
const normalizedBase = normalizedBaseUrl
.replace(/\/(openai\/)?v1\/?$/, '')
.replace(/\/+$/, '')
@@ -4468,6 +4484,31 @@ class OpenAIShimMessages {
return `${baseUrl}/chat/completions`
}
// Azure serves the Responses API only on the v1 surface
// ({resource}/openai/v1/responses — model in the request body, no
// api-version, no deployment-scoped form), so any Azure-style base is
// normalized to it: trailing /openai/v1, /v1, and
// /openai/deployments/<dep> segments are stripped until stable (bases
// can carry several, e.g. /openai/deployments/<dep>/openai/v1), then
// /openai/v1/responses is appended.
// https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses
const buildResponsesUrl = (baseUrl: string): string => {
const trimmedBase = baseUrl.replace(/\/+$/, '')
if (!isAzure) {
return `${trimmedBase}/responses`
}
let normalizedBase = (trimmedBase.split(/[?#]/, 1)[0] ?? trimmedBase).replace(/\/+$/, '')
for (;;) {
const stripped = normalizedBase
.replace(/\/(openai\/)?v1$/i, '')
.replace(/\/openai\/deployments\/[^/]+$/i, '')
.replace(/\/+$/, '')
if (stripped === normalizedBase) break
normalizedBase = stripped
}
return `${normalizedBase}/openai/v1/responses`
}
const localRetryBaseUrls = isLocal
? getLocalProviderRetryBaseUrls(request.baseUrl)
: []
@@ -4480,7 +4521,7 @@ class OpenAIShimMessages {
return buildOllamaChatUrl(baseUrl)
}
return request.transport === 'responses' || request.transport === 'responses_compat'
? `${baseUrl}/responses`
? buildResponsesUrl(baseUrl)
: buildChatCompletionsUrl(baseUrl)
}
@@ -4,7 +4,9 @@ import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test
import {
getAdditionalModelOptionsCacheScope,
getLocalProviderRetryBaseUrls,
isAzureStyleBaseUrl,
isLocalProviderUrl,
modelRequiresResponsesApi,
resolveProviderRequest,
shouldAttemptLocalToollessRetry,
} from './providerConfig.js'
@@ -20,6 +22,7 @@ const originalEnv = {
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
OPENAI_MODEL: process.env.OPENAI_MODEL,
OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
}
function restoreEnv(key: string, value: string | undefined): void {
@@ -46,6 +49,7 @@ afterEach(() => {
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
restoreEnv('OPENAI_AZURE_STYLE', originalEnv.OPENAI_AZURE_STYLE)
} finally {
releaseSharedMutationLock()
}
@@ -367,3 +371,190 @@ test('disables local toolless retry for non-Ollama local endpoints', () => {
}),
).toBe(false)
})
test('modelRequiresResponsesApi matches gpt-5.4/5.5/5.6 (excl. mini/nano) only', () => {
for (const model of [
'gpt-5.4',
'gpt-5.5',
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
'GPT-5.6-SOL',
// patch releases of a verified family stay routed
'gpt-5.4.1',
]) {
expect(modelRequiresResponsesApi(model)).toBe(true)
}
for (const model of [
'gpt-4.1',
'gpt-5',
'gpt-5-mini',
'gpt-5.4-mini',
'gpt-5.4-nano',
'gpt-5.5-mini',
'gpt-5.5-nano',
'gpt-5.6-mini-high',
'gpt-5.4-2026-01-01-mini',
'gpt-5.6.1-nano',
'gpt-5.10',
'gpt-5.41',
// unverified future minors are deliberately not auto-routed
'gpt-5.7',
'gpt-5.8-preview',
'gpt-5.9',
'o3',
'claude-opus-4-8',
]) {
expect(modelRequiresResponsesApi(model)).toBe(false)
}
})
test('keeps gpt-5.4-mini on chat completions on the OpenAI base', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.4-mini'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
resolvedModel: 'gpt-5.4-mini',
})
})
test('auto-routes gpt-5.6 to responses on the default OpenAI base', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'responses',
resolvedModel: 'gpt-5.6-sol',
baseUrl: 'https://api.openai.com/v1',
})
})
test('auto-routes gpt-5.6 to responses on regional OpenAI subdomains', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://eu.api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'responses',
resolvedModel: 'gpt-5.6-sol',
baseUrl: 'https://eu.api.openai.com/v1',
})
})
test('explicit chat_completions overrides the gpt-5.6 responses auto-route', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
process.env.OPENAI_API_FORMAT = 'chat_completions'
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
resolvedModel: 'gpt-5.6-sol',
})
})
test('leaves gpt-4-class models on chat completions for the OpenAI base', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
resolvedModel: 'gpt-4o',
})
})
test('does not auto-route gpt-5.6 on an arbitrary non-OpenAI gateway base', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://gateway.example/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
baseUrl: 'https://gateway.example/v1',
})
})
test('auto-routes gpt-5.6 to responses on an Azure OpenAI v1 base', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-terra'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'responses',
resolvedModel: 'gpt-5.6-terra',
})
})
test('OPENAI_AZURE_STYLE extends the gpt-5.6 responses auto-route to non-azure.com hosts', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://apim.contoso.example/azure-openai'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
process.env.OPENAI_AZURE_STYLE = '1'
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'responses',
resolvedModel: 'gpt-5.6-sol',
})
})
test('without OPENAI_AZURE_STYLE the same non-azure.com host stays on chat completions', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://apim.contoso.example/azure-openai'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
resolvedModel: 'gpt-5.6-sol',
})
})
test('does not auto-route an Azure-hosted custom gateway based on its resource name', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://openai-proxy.web.azure.com/v1'
process.env.OPENAI_MODEL = 'gpt-5.6-sol'
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_API_FORMAT
expect(resolveProviderRequest()).toMatchObject({
transport: 'chat_completions',
resolvedModel: 'gpt-5.6-sol',
})
})
test('isAzureStyleBaseUrl honors the OPENAI_AZURE_STYLE override before hostname detection', () => {
const overrideEnv = { OPENAI_AZURE_STYLE: '1' } as NodeJS.ProcessEnv
const plainEnv = {} as NodeJS.ProcessEnv
expect(isAzureStyleBaseUrl('https://apim.contoso.example/azure-openai', overrideEnv)).toBe(true)
expect(isAzureStyleBaseUrl('https://apim.contoso.example/azure-openai', plainEnv)).toBe(false)
// Override precedes URL parsing, so even a malformed base is Azure-style.
expect(isAzureStyleBaseUrl('not a url', overrideEnv)).toBe(true)
expect(isAzureStyleBaseUrl('not a url', plainEnv)).toBe(false)
})
test('isAzureStyleBaseUrl matches Azure OpenAI service hostnames only', () => {
const plainEnv = {} as NodeJS.ProcessEnv
expect(isAzureStyleBaseUrl('https://myres.openai.azure.com/openai/v1', plainEnv)).toBe(true)
expect(isAzureStyleBaseUrl('https://myres.cognitiveservices.azure.com', plainEnv)).toBe(true)
expect(isAzureStyleBaseUrl('https://myres.services.ai.azure.com/models', plainEnv)).toBe(true)
expect(isAzureStyleBaseUrl('https://myres.inference.ml.azure.com', plainEnv)).toBe(true)
// Azure-hosted custom gateways are not Azure OpenAI endpoints merely because
// their resource name contains an Azure marker.
expect(isAzureStyleBaseUrl('https://openai-proxy.web.azure.com/v1', plainEnv)).toBe(false)
// .azure.com alone is not enough without a supported service suffix.
expect(isAzureStyleBaseUrl('https://myapp.web.azure.com', plainEnv)).toBe(false)
})
+74
View File
@@ -415,6 +415,67 @@ function shouldUseGithubResponsesApi(model: string): boolean {
return true
}
// GPT-5.4/5.5/5.6 (incl. sol/terra/luna suffixes) reject function tools +
// reasoning_effort on /v1/chat/completions and must use /v1/responses. An
// agent CLI always sends tools, so plain OpenAI/Azure users can't otherwise
// reach these models. Matches gpt-5.4/5.5/5.6 with any non-mini/nano
// suffix. -mini/-nano variants are excluded as unverified — they keep
// chat/completions, and the OPENAI_API_FORMAT / profile apiFormat override
// covers them if they turn out to need /responses. Two-digit minors
// (gpt-5.10+) are deliberately unmatched: auto-routing unverified future
// models is the exact risk this predicate exists to avoid. Bare gpt-5,
// gpt-5-mini, gpt-4.x, o-series, and claude-* stay on chat/completions.
export function modelRequiresResponsesApi(model: string): boolean {
const normalized = model.trim().toLowerCase().split('?', 1)[0] ?? ''
return /^gpt-5\.[4-6](?!\d)/.test(normalized) &&
!/(?:^|[-.])(?:mini|nano)(?:[-.]|$)/.test(normalized)
}
// The responses auto-route only fires for the OpenAI first-party surface
// (the default base, api.openai.com, and its OpenAI-controlled subdomains
// like the eu./us. regional endpoints) and Azure OpenAI hosts, where
// /v1/responses is known to exist. Arbitrary OpenAI-compatible gateways
// (OpenRouter-style proxies) often lack it, so those keep chat/completions
// unless the user opts in via OPENAI_API_FORMAT / apiFormat.
function isDefaultOrDirectOpenAIBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl || baseUrl === DEFAULT_OPENAI_BASE_URL) return true
try {
const hostname = new URL(baseUrl).hostname.toLowerCase()
return hostname === 'api.openai.com' || hostname.endsWith('.api.openai.com')
} catch {
return false
}
}
// Azure-style endpoint detection shared by the responses auto-route gate and
// the shim's URL/auth handling. OPENAI_AZURE_STYLE=1 forces Azure handling
// for endpoints whose hostname would not otherwise match (APIM-fronted,
// private link); hostname-based otherwise (not raw URL) to prevent bypass
// via path segments like https://evil.com/cognitiveservices.azure.com/.
export function isAzureStyleBaseUrl(
baseUrl: string | undefined,
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
if (isEnvTruthy(processEnv.OPENAI_AZURE_STYLE)) return true
if (!baseUrl) return false
try {
const hostname = new URL(baseUrl).hostname
return hostname.endsWith('.openai.azure.com') ||
hostname.endsWith('.cognitiveservices.azure.com') ||
hostname.endsWith('.services.ai.azure.com') ||
hostname.endsWith('.inference.ml.azure.com')
} catch {
return false
}
}
export function baseUrlSupportsResponsesAutoRoute(
baseUrl: string | undefined,
processEnv: NodeJS.ProcessEnv,
): boolean {
return isDefaultOrDirectOpenAIBaseUrl(baseUrl) || isAzureStyleBaseUrl(baseUrl, processEnv)
}
export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
@@ -967,11 +1028,24 @@ export function resolveProviderRequest(options?: {
isGithubMode
? undefined
: parseOpenAICompatibleApiFormat(runtimeShimContext?.openaiShimConfig.requiredApiFormat)
// Precedence: explicit env/profile apiFormat (incl. chat_completions, the
// escape hatch) > catalog requiredApiFormat > this model+base predicate >
// shim default. The predicate fires only when nothing above resolved it, so
// an explicit format always wins over it.
const autoResponsesApiFormat =
!isGithubMode &&
explicitApiFormat === undefined &&
requiredApiFormat === undefined &&
modelRequiresResponsesApi(resolvedModel) &&
baseUrlSupportsResponsesAutoRoute(finalBaseUrl, processEnv)
? ('responses' as const)
: undefined
const requestedApiFormat =
requiredApiFormat &&
(explicitApiFormat === undefined || explicitApiFormat === 'chat_completions')
? requiredApiFormat
: explicitApiFormat ??
autoResponsesApiFormat ??
parseOpenAICompatibleApiFormat(runtimeShimContext?.openaiShimConfig.defaultApiFormat)
const supportsRequestedApiFormat =
(requestedApiFormat !== 'responses' && requestedApiFormat !== 'responses_compat') ||
+1
View File
@@ -227,6 +227,7 @@ export type ProviderProfile = {
model: string
apiKey?: string
apiFormat?: OpenAICompatibleApiFormat
azureStyle?: boolean
authHeader?: string
authScheme?: OpenAICompatibleAuthScheme
authHeaderValue?: string
+109
View File
@@ -147,6 +147,115 @@ test('gpt-5.4 on the OpenAI provider still supports effort selection', async ()
])
})
test('gpt-5.6 on an Azure custom-route base carries its default high effort from metadata', async () => {
// Azure (and regional *.api.openai.com) bases resolve to route 'custom',
// whose catalog is empty; the openai-catalog fallback must supply gpt-5.6's
// advertised default 'high' instead of the legacy undefined. FAILS pre-fix
// (getDefaultEffortForModel returns undefined on route 'custom').
const snapshot = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
}
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_AZURE_STYLE
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://myres.openai.azure.com/openai/v1'
process.env.OPENAI_API_KEY = 'test-key'
try {
const { getDefaultEffortForModel, getAvailableEffortLevels } =
await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
expect(getDefaultEffortForModel('gpt-5.6-sol')).toBe('high')
expect(getAvailableEffortLevels('gpt-5.6-sol')).toContain('xhigh')
} finally {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
})
test('gpt-5.6 on a regional OpenAI base carries its default high effort from metadata', async () => {
// eu.api.openai.com is an OpenAI-controlled surface (endsWith '.api.openai.com')
// that still resolves to route 'custom'; the gated fallback must fire.
const snapshot = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
}
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_AZURE_STYLE
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://eu.api.openai.com/v1'
process.env.OPENAI_API_KEY = 'test-key'
try {
const { getDefaultEffortForModel } = await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
expect(getDefaultEffortForModel('gpt-5.6-sol')).toBe('high')
} finally {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
})
test('gpt-5.6 on an arbitrary OpenAI-compatible gateway does NOT get an injected default effort', async () => {
// A gateway base resolves to route 'custom' too, but is not a verified
// OpenAI/Azure surface — the fallback must NOT fire, so gpt-5.6 stays on
// legacy controls (no injected reasoning_effort default). FAILS pre-fix
// (the ungated round-3 fallback returned 'high').
const snapshot = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE,
}
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_AZURE_STYLE
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://gateway.example/v1'
process.env.OPENAI_API_KEY = 'test-key'
try {
const { getDefaultEffortForModel } = await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
expect(getDefaultEffortForModel('gpt-5.6-sol')).not.toBe('high')
expect(getDefaultEffortForModel('gpt-5.6-sol')).toBeUndefined()
} finally {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
})
test('gpt-5.3-codex-spark stays without effort controls', async () => {
const { getAvailableEffortLevels, modelSupportsEffort } =
await importFreshEffortModule({
+26 -5
View File
@@ -6,7 +6,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/grow
import { getAPIProvider } from './model/providers.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
import { getAntModelOverrideConfig, resolveAntModel } from './model/antModels.js'
import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
import { baseUrlSupportsResponsesAutoRoute, supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
import {
ensureIntegrationsLoaded,
getCatalogEntriesForRoute,
@@ -77,6 +77,8 @@ export type ReasoningControlContext = OpenAIShimReasoningSupportContext & {
catalogEntries?: readonly ModelCatalogEntry[]
modelDescriptors?: Readonly<Record<string, Pick<ModelDescriptor, 'capabilities' | 'reasoning'>>>
openaiShimConfig?: Partial<OpenAIShimTransportConfig>
baseUrl?: string
processEnv?: NodeJS.ProcessEnv
}
const DEFAULT_REASONING_LEVELS: EffortLevel[] = ['low', 'medium', 'high']
@@ -319,14 +321,33 @@ function resolveCatalogReasoningMetadata(
ensureIntegrationsLoaded()
const normalizedModel = model.trim().split('?', 1)[0]!.trim().toLowerCase()
const entries = context?.catalogEntries ?? getCatalogEntriesForRoute(routeId)
const entry = entries.find(catalogEntry =>
const matchesModel = (catalogEntry: ModelCatalogEntry): boolean =>
catalogEntry.apiName.trim().toLowerCase() === normalizedModel ||
catalogEntry.id.trim().toLowerCase() === normalizedModel ||
(catalogEntry.aliases ?? []).some(alias =>
alias.trim().split('?', 1)[0]?.trim().toLowerCase() === normalizedModel,
),
)
)
const entries = context?.catalogEntries ?? getCatalogEntriesForRoute(routeId)
let entry = entries.find(matchesModel)
const fallbackBaseUrl =
context?.baseUrl ?? context?.processEnv?.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? process.env.OPENAI_API_BASE
if (
!entry &&
routeId === 'custom' &&
baseUrlSupportsResponsesAutoRoute(fallbackBaseUrl, context?.processEnv ?? process.env)
) {
// Azure and regional/first-party OpenAI surfaces resolve to route 'custom'
// (their host is not a registered route; see resolveActiveRouteIdFromEnv),
// whose catalog is empty. Consult the openai vendor catalog by model name so
// reasoning models (gpt-5.6) carry their advertised metadata (default 'high',
// xhigh). Gate on baseUrlSupportsResponsesAutoRoute so this only fires on the
// same verified OpenAI/Azure surfaces the Responses auto-route uses, NOT
// arbitrary OpenAI-compatible gateways that also resolve to route 'custom' —
// those keep their pre-PR chat_completions behavior with no injected
// reasoning_effort default.
entry = getCatalogEntriesForRoute('openai').find(matchesModel)
}
if (!entry) {
return undefined
+12
View File
@@ -20,6 +20,7 @@ const TEST_ENV_KEYS = [
'CODEX_HOME',
'OPENAI_API_KEYS',
'OPENAI_API_KEY',
'OPENAI_AZURE_STYLE',
'OPENAI_BASE_URL',
'OPENAI_MODEL',
'OPENCLAUDE_OLLAMA_NUM_CTX',
@@ -286,6 +287,17 @@ describe('loadEnvFile', () => {
})
})
it('loads documented Azure-style handling flag values', () => {
const filePath = writeTempEnvFile('OPENAI_AZURE_STYLE=1')
const loaded = loadEnvFile(filePath)
expect(process.env.OPENAI_AZURE_STYLE).toBe('1')
expect(loaded).toEqual({
OPENAI_AZURE_STYLE: '1',
})
})
it('loads documented Ollama request context window values', () => {
const filePath = writeTempEnvFile('OPENCLAUDE_OLLAMA_NUM_CTX=32768')
+1
View File
@@ -97,6 +97,7 @@ const ALLOWED_ENV_FILE_KEYS = new Set([
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_HEADER_VALUE',
'OPENAI_AUTH_SCHEME',
'OPENAI_AZURE_STYLE',
'OPENAI_BASE_URL',
'OPENAI_MODEL',
'TAVILY_API_KEY',
+10
View File
@@ -27,6 +27,7 @@ const ENV_KEYS = [
'OPENAI_API_KEY',
'OPENAI_MODEL',
'OPENAI_API_FORMAT',
'OPENAI_AZURE_STYLE',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
@@ -74,6 +75,7 @@ const RESET_KEYS = [
'OPENAI_API_KEY',
'OPENAI_MODEL',
'OPENAI_API_FORMAT',
'OPENAI_AZURE_STYLE',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
@@ -450,6 +452,14 @@ describe('applyProviderFlag - ollama', () => {
expect(process.env.OPENAI_MODEL).toBe('llama3.2')
})
test('clears Azure-only routing mode', () => {
process.env.OPENAI_AZURE_STYLE = '1'
applyProviderFlag('ollama', [])
expect(process.env.OPENAI_AZURE_STYLE).toBeUndefined()
})
test('does not override existing OPENAI_BASE_URL when user set a custom one', () => {
process.env.OPENAI_BASE_URL = 'http://my-ollama:11434/v1'
applyProviderFlag('ollama', [])
+9
View File
@@ -344,6 +344,13 @@ export function applyProviderFlag(
const model = parseModelFlag(args)
const { defaultBaseUrl, defaultModel } = getRouteDefaults(provider)
// Azure-style routing changes both request paths and authentication. It is
// only meaningful for an explicit OpenAI/Azure configuration, so never let
// it follow a provider switch to another OpenAI-compatible endpoint.
if (provider !== 'openai') {
delete process.env.OPENAI_AZURE_STYLE
}
switch (provider) {
case 'anthropic': {
// Default — clear any custom native proxy contract so this explicit
@@ -389,6 +396,7 @@ export function applyProviderFlag(
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
@@ -459,6 +467,7 @@ export function applyProviderFlag(
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_API_FORMAT
delete process.env.OPENAI_AZURE_STYLE
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_AUTH_SCHEME
delete process.env.OPENAI_AUTH_HEADER_VALUE
+16 -1
View File
@@ -678,13 +678,14 @@ test('buildStartupEnvFromProfile preserves env-only NEAR AI setup without a save
assert.equal(isDefaultStartupProviderEnv(env), false)
})
test('openai launch preserves shell responses format and custom auth overrides', async () => {
test('openai launch does not apply persisted Azure mode to a shell-selected base', 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_AZURE_STYLE: '1',
OPENAI_AUTH_HEADER: 'X-Persisted-Key',
OPENAI_AUTH_SCHEME: 'raw',
OPENAI_AUTH_HEADER_VALUE: 'persisted-secret',
@@ -705,6 +706,7 @@ test('openai launch preserves shell responses format and custom auth overrides',
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_AZURE_STYLE, undefined)
assert.equal(env.OPENAI_AUTH_HEADER, 'api-key')
assert.equal(env.OPENAI_AUTH_SCHEME, 'raw')
assert.equal(env.OPENAI_AUTH_HEADER_VALUE, 'shell-secret')
@@ -2086,6 +2088,19 @@ test('openai profiles keep shell base and model when shell format is responses',
assert.equal(env?.OPENAI_API_KEY, 'sk-live')
})
test('openai profiles persist Azure-style routing from the shell environment', () => {
const env = buildOpenAIProfileEnv({
goal: 'balanced',
processEnv: {
OPENAI_BASE_URL: 'https://azure.example/openai/v1',
OPENAI_API_KEY: 'azure-key',
OPENAI_AZURE_STYLE: '1',
},
})
assert.equal(env?.OPENAI_AZURE_STYLE, '1')
})
test('openai profiles use the first model from a semicolon-separated list', () => {
const env = buildOpenAIProfileEnv({
goal: 'balanced',
+19
View File
@@ -72,6 +72,7 @@ const PROFILE_ENV_KEYS = [
'OPENAI_API_BASE',
'OPENAI_MODEL',
'OPENAI_API_FORMAT',
'OPENAI_AZURE_STYLE',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
@@ -157,6 +158,7 @@ export type ProfileEnv = {
OPENAI_API_BASE?: string
OPENAI_MODEL?: string
OPENAI_API_FORMAT?: 'chat_completions' | 'responses' | 'responses_compat'
OPENAI_AZURE_STYLE?: string
OPENAI_AUTH_HEADER?: string
OPENAI_AUTH_SCHEME?: 'bearer' | 'raw'
OPENAI_AUTH_HEADER_VALUE?: string
@@ -786,6 +788,7 @@ export function buildOpenAIProfileEnv(options: {
baseUrl?: string | null
apiKey?: string | null
apiFormat?: 'chat_completions' | 'responses' | 'responses_compat' | null
azureStyle?: string | null
authHeader?: string | null
authScheme?: 'bearer' | 'raw' | null
authHeaderValue?: string | null
@@ -871,6 +874,9 @@ export function buildOpenAIProfileEnv(options: {
OPENAI_BASE_URL: resolvedBaseUrl,
OPENAI_MODEL: normalizedModel,
...(options.apiFormat ? { OPENAI_API_FORMAT: options.apiFormat } : {}),
...(isEnvTruthy(options.azureStyle ?? processEnv.OPENAI_AZURE_STYLE)
? { OPENAI_AZURE_STYLE: '1' }
: {}),
...(options.authHeader ? { OPENAI_AUTH_HEADER: options.authHeader } : {}),
...(options.authScheme ? { OPENAI_AUTH_SCHEME: options.authScheme } : {}),
...(authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: authHeaderValue } : {}),
@@ -1409,6 +1415,7 @@ export async function buildLaunchEnv(options: {
persistedEnv,
)
const persistedOpenAIApiFormat = persistedEnv.OPENAI_API_FORMAT
const persistedOpenAIAzureStyle = persistedEnv.OPENAI_AZURE_STYLE
const persistedOpenAIAuthHeader = persistedEnv.OPENAI_AUTH_HEADER
const persistedOpenAIAuthScheme = persistedEnv.OPENAI_AUTH_SCHEME
const persistedOpenAIAuthHeaderValue = sanitizeApiKey(
@@ -1882,6 +1889,18 @@ export async function buildLaunchEnv(options: {
} else {
delete env.OPENAI_API_FORMAT
}
const usePersistedAzureStyle =
processEnv.OPENAI_AZURE_STYLE === undefined &&
usePersistedOpenAIConfig &&
env.OPENAI_BASE_URL === persistedOpenAIBaseUrl
if (
isEnvTruthy(processEnv.OPENAI_AZURE_STYLE) ||
(usePersistedAzureStyle && isEnvTruthy(persistedOpenAIAzureStyle))
) {
env.OPENAI_AZURE_STYLE = '1'
} else {
delete env.OPENAI_AZURE_STYLE
}
const openAIAuthHeader =
processEnv.OPENAI_AUTH_HEADER ||
(usePersistedOpenAIConfig ? persistedOpenAIAuthHeader : undefined)
+19
View File
@@ -31,6 +31,7 @@ const RESTORED_KEYS = [
'OPENAI_API_BASE',
'OPENAI_MODEL',
'OPENAI_API_FORMAT',
'OPENAI_AZURE_STYLE',
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
@@ -289,6 +290,22 @@ function buildCloudflareProfile(overrides: Partial<ProviderProfile> = {}): Provi
}
describe('applyProviderProfileToProcessEnv', () => {
test('applies Azure-style routing from a saved OpenAI-compatible profile', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
applyProviderProfileToProcessEnv(
buildProfile({
baseUrl: 'https://apim.contoso.example/azure-openai',
model: 'gpt-5.6-sol',
apiKey: 'azure-key',
azureStyle: true,
}),
)
expect(process.env.OPENAI_AZURE_STYLE).toBe('1')
})
test('openai profile clears competing gemini/github flags', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
@@ -697,6 +714,7 @@ describe('applyProviderProfileToProcessEnv', () => {
test('minimax profile ignores advanced OpenAI-compatible auth settings', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
process.env.OPENAI_AZURE_STYLE = '1'
applyProviderProfileToProcessEnv(
buildProfile({
@@ -720,6 +738,7 @@ describe('applyProviderProfileToProcessEnv', () => {
expect(process.env.MINIMAX_API_KEY).toBe('minimax-live-key')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
expect(process.env.OPENAI_AZURE_STYLE).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined()
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined()
+15
View File
@@ -71,6 +71,7 @@ export type ProviderProfileInput = {
model: string
apiKey?: string
apiFormat?: ProviderProfile['apiFormat']
azureStyle?: ProviderProfile['azureStyle']
authHeader?: ProviderProfile['authHeader']
authScheme?: ProviderProfile['authScheme']
authHeaderValue?: ProviderProfile['authHeaderValue']
@@ -296,6 +297,7 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
const baseUrl = normalizeBaseUrl(profile.baseUrl)
const model = trimValue(profile.model)
const apiFormat = parseOpenAICompatibleApiFormat(profile.apiFormat)
const azureStyle = profile.azureStyle === true
const authHeader = sanitizeAuthHeader(profile.authHeader)
const authScheme = sanitizeAuthScheme(profile.authScheme)
const authHeaderValue = trimOrUndefined(profile.authHeaderValue)
@@ -329,6 +331,9 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
if (supportsApiFormat && apiFormat) {
sanitized.apiFormat = apiFormat
}
if (azureStyle) {
sanitized.azureStyle = true
}
if (supportsAuthHeaders && authHeader) {
sanitized.authHeader = authHeader
sanitized.authScheme = authScheme ?? (
@@ -377,6 +382,7 @@ function toProfile(
model: input.model,
apiKey: input.apiKey,
apiFormat: input.apiFormat,
azureStyle: input.azureStyle,
authHeader: input.authHeader,
authScheme: input.authScheme,
authHeaderValue: input.authHeaderValue,
@@ -726,6 +732,10 @@ function isProcessEnvAlignedWithProfile(
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
sameOptionalEnvValue(processEnv.OPENAI_MODEL, primaryModel) &&
sameOptionalEnvValue(processEnv.OPENAI_API_FORMAT, profile.apiFormat) &&
sameOptionalEnvValue(
processEnv.OPENAI_AZURE_STYLE,
profile.azureStyle ? '1' : undefined,
) &&
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER, profile.authHeader) &&
sameOptionalEnvValue(processEnv.OPENAI_AUTH_SCHEME, profile.authScheme) &&
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER_VALUE, profile.authHeaderValue) &&
@@ -924,6 +934,9 @@ export function applyProviderProfileToProcessEnv(
if (supportsApiFormat && profile.apiFormat) {
openAIProfileEnv.OPENAI_API_FORMAT = profile.apiFormat
}
if (profile.azureStyle) {
openAIProfileEnv.OPENAI_AZURE_STYLE = '1'
}
if (supportsAuthHeaders && profile.authHeader) {
openAIProfileEnv.OPENAI_AUTH_HEADER = profile.authHeader
openAIProfileEnv.OPENAI_AUTH_SCHEME =
@@ -1293,6 +1306,7 @@ function buildOpenAICompatibleStartupEnv(
baseUrl: activeProfile.baseUrl,
apiKey: activeProfile.apiKey,
apiFormat: activeProfile.apiFormat,
azureStyle: activeProfile.azureStyle ? '1' : undefined,
authHeader: activeProfile.authHeader,
authScheme: activeProfile.authScheme,
authHeaderValue: activeProfile.authHeaderValue,
@@ -1337,6 +1351,7 @@ function buildOpenAICompatibleStartupEnv(
OPENAI_BASE_URL: activeProfile.baseUrl,
OPENAI_MODEL: getPrimaryModel(activeProfile.model),
...(activeProfile.apiFormat ? { OPENAI_API_FORMAT: activeProfile.apiFormat } : {}),
...(activeProfile.azureStyle ? { OPENAI_AZURE_STYLE: '1' } : {}),
...(activeProfile.authHeader ? { OPENAI_AUTH_HEADER: activeProfile.authHeader } : {}),
...(activeProfile.authScheme ? { OPENAI_AUTH_SCHEME: activeProfile.authScheme } : {}),
...(activeProfile.authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: activeProfile.authHeaderValue } : {}),