mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+18
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Vendored
+11
-1
@@ -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' },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -227,6 +227,7 @@ export type ProviderProfile = {
|
||||
model: string
|
||||
apiKey?: string
|
||||
apiFormat?: OpenAICompatibleApiFormat
|
||||
azureStyle?: boolean
|
||||
authHeader?: string
|
||||
authScheme?: OpenAICompatibleAuthScheme
|
||||
authHeaderValue?: string
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user