Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions src/chrome/src/providers/openai.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseLLMProvider } from './base.js';
import { fetchWithFallback } from './fetch-with-fallback.js';
import {
isNewOpenAIContractConfig,
isOfficialOpenAIConfig,
shouldUseOpenAIResponsesApi,
supportsOpenAIAskStreaming,
Expand Down Expand Up @@ -160,18 +161,15 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
}

/**
* Newer OpenAI models (gpt-5, gpt-4.1+, o1, o3, o4) have a different API
* contract from the gpt-4o-and-earlier line:
* - reject `max_tokens`, require `max_completion_tokens` instead
* - reject any `temperature` other than the default (1)
* Local OpenAI-compatible servers and OpenRouter still use
* the legacy contract. Detect by model name + provider type.
* Newer OpenAI models (gpt-5 and the o-series) reject `max_tokens` and any
* non-default `temperature`, requiring `max_completion_tokens`. Detected by
* model id via the shared `isNewOpenAIContractModel` helper (also used by
* the settings Compatibility panel so the display and the wire contract
* stay in sync). Local OpenAI-compatible servers and LM Studio keep the
* legacy contract.
*/
_isNewOpenAIContract() {
const m = (this.config.model || '').toLowerCase();
if (this.config.category === 'local') return false;
if (this.config.providerName === 'lmstudio') return false;
return /^(gpt-5|gpt-4\.1|o1|o3|o4)/.test(m);
return isNewOpenAIContractConfig(this.config);
}

_addMaxTokens(body, options) {
Expand Down
30 changes: 30 additions & 0 deletions src/chrome/src/providers/provider-compatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@ export function shouldUseOpenAIResponsesApi(config = {}) {
|| /^gpt-5(?:\.(?:2|4|5))?-pro(?:$|-\d{4}-\d{2}-\d{2}$)/.test(model);
}

/**
* Whether a model id uses the newer OpenAI wire contract (max_completion_tokens,
* no non-default temperature) — the gpt-5 line and the o-series. gpt-4.1 is
* deliberately excluded: it accepts both parameter sets, so it stays on the
* legacy contract and keeps explicit temperatures. OpenAI's Responses-only
* Pro families also stay legacy when routed through a Chat Completions
* provider: those routed endpoints advertise `max_tokens`, while direct
* OpenAI calls are selected as Responses before this helper is consulted.
* OpenRouter's routed allowlist is intentionally narrow: only GPT-5.6 Terra
* variants use max_completion_tokens there; o-series, Pro, batch, and image
* routes remain on max_tokens.
*/
export function isNewOpenAIContractModel(model) {
const m = String(model || '').toLowerCase();
if (/(?:^|\/)gpt-5(?:\.(?:2|4|5))?-pro(?:$|[-_.\/:])/.test(m)) return false;
return /(?:^|\/)(?:gpt-5|o1|o3|o4)(?:$|[-_.\/])/.test(m);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not classify the entire routed GPT-5 namespace as max_completion_tokens

The trailing delimiter prevents look-alike matches, but it still treats every GPT-5 suffix as the same wire contract. For example, this returns true for openai/gpt-5.5-pro (and openai/gpt-5.2-pro). OpenRouter's current model metadata and model page advertise max_tokens, but not max_completion_tokens, for GPT-5.5 Pro: https://openrouter.ai/openai/gpt-5.5-pro/api

Because OpenRouter stays on Chat Completions here, this branch sends max_completion_tokens and drops max_tokens; the configured output cap can therefore be ignored or rejected. The shared settings helper also reports the same incorrect automatic field.

Please classify the actual routed model families instead of the whole gpt-5 prefix (or rely on an explicit provider compatibility choice), and add regression coverage for at least openai/gpt-5.5-pro and openai/gpt-5.2-pro alongside the positive openai/gpt-5.6-terra case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch now includes the maintainer-directed fix 3292cd7f (preserved through the non-rewriting merge f1df7bdf). Routed GPT-5 Pro families (openai/gpt-5.5-pro, openai/gpt-5.2-pro, including dated/batch suffixes) remain on max_tokens; the positive openai/gpt-5.6-terra case remains on max_completion_tokens. The Chrome/Firefox shared helper and regression table cover both cases, while direct OpenAI Responses routing remains unchanged. Verified after the merge: node test/run.js 1772 passed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up d9000664 also closes the final review-pass finding: provider compatibility is now config-aware. The shared isNewOpenAIContractConfig keeps local/LM Studio and non-OpenRouter slash-prefixed model ids on legacy fields, while OpenRouter retains the maintainer-approved Pro exceptions and Terra/o-series behavior. Both provider request construction and Settings call this shared predicate, with Chrome/Firefox regression coverage. node test/run.js: 1773 passed.

}

export function isNewOpenAIContractConfig(config = {}) {
const providerName = String(config.providerName || '').trim().toLowerCase();
if (config.category === 'local' || providerName === 'lmstudio') return false;
if (providerName === 'openrouter') {
return /(?:^|\/)gpt-5\.6-terra(?:$|[-_.\/:])/.test(String(config.model || '').toLowerCase());
}
// Only OpenRouter is covered by the routed-model contract table. Other
// compatible endpoints may use slash-prefixed ids with legacy fields.
if (String(config.model || '').includes('/') && providerName !== 'openrouter') return false;
return isNewOpenAIContractModel(config.model);
}

export function supportsOpenAIAskStreaming(config = {}) {
if (!isOfficialOpenAIConfig(config)) return false;

Expand Down
7 changes: 2 additions & 5 deletions src/chrome/src/ui/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '../agent/capsolver-config.js';
import {
detectedCompatibilityPreset,
isNewOpenAIContractConfig,
normalizeOpenAICompatibleBaseUrl,
normalizeProviderCompatibility,
parseProviderExtraBodyJson,
Expand Down Expand Up @@ -2304,11 +2305,7 @@ function prettyCompatibilityValue(value) {

function automaticTokenField(config) {
if (shouldUseOpenAIResponsesApi(config)) return 'max_output_tokens';
const model = String(config.model || '').toLowerCase();
const isNewOfficialContract = config.type === 'openai'
&& config.category !== 'local'
&& config.providerName !== 'lmstudio'
&& /^(gpt-5|gpt-4\.1|o1|o3|o4)/.test(model);
const isNewOfficialContract = config.type === 'openai' && isNewOpenAIContractConfig(config);
return isNewOfficialContract ? 'max_completion_tokens' : 'max_tokens';
}

Expand Down
18 changes: 8 additions & 10 deletions src/firefox/src/providers/openai.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseLLMProvider } from './base.js';
import { fetchWithTimeout } from './fetch-timeout.js';
import {
isNewOpenAIContractConfig,
isOfficialOpenAIConfig,
shouldUseOpenAIResponsesApi,
supportsOpenAIAskStreaming,
Expand Down Expand Up @@ -160,18 +161,15 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
}

/**
* Newer OpenAI models (gpt-5, gpt-4.1+, o1, o3, o4) have a different API
* contract from the gpt-4o-and-earlier line:
* - reject `max_tokens`, require `max_completion_tokens` instead
* - reject any `temperature` other than the default (1)
* Local OpenAI-compatible servers and OpenRouter still use
* the legacy contract. Detect by model name + provider type.
* Newer OpenAI models (gpt-5 and the o-series) reject `max_tokens` and any
* non-default `temperature`, requiring `max_completion_tokens`. Detected by
* model id via the shared `isNewOpenAIContractModel` helper (also used by
* the settings Compatibility panel so the display and the wire contract
* stay in sync). Local OpenAI-compatible servers and LM Studio keep the
* legacy contract.
*/
_isNewOpenAIContract() {
const m = (this.config.model || '').toLowerCase();
if (this.config.category === 'local') return false;
if (this.config.providerName === 'lmstudio') return false;
return /^(gpt-5|gpt-4\.1|o1|o3|o4)/.test(m);
return isNewOpenAIContractConfig(this.config);
}

_addMaxTokens(body, options) {
Expand Down
30 changes: 30 additions & 0 deletions src/firefox/src/providers/provider-compatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@ export function shouldUseOpenAIResponsesApi(config = {}) {
|| /^gpt-5(?:\.(?:2|4|5))?-pro(?:$|-\d{4}-\d{2}-\d{2}$)/.test(model);
}

/**
* Whether a model id uses the newer OpenAI wire contract (max_completion_tokens,
* no non-default temperature) — the gpt-5 line and the o-series. gpt-4.1 is
* deliberately excluded: it accepts both parameter sets, so it stays on the
* legacy contract and keeps explicit temperatures. OpenAI's Responses-only
* Pro families also stay legacy when routed through a Chat Completions
* provider: those routed endpoints advertise `max_tokens`, while direct
* OpenAI calls are selected as Responses before this helper is consulted.
* OpenRouter's routed allowlist is intentionally narrow: only GPT-5.6 Terra
* variants use max_completion_tokens there; o-series, Pro, batch, and image
* routes remain on max_tokens.
*/
export function isNewOpenAIContractModel(model) {
const m = String(model || '').toLowerCase();
if (/(?:^|\/)gpt-5(?:\.(?:2|4|5))?-pro(?:$|[-_.\/:])/.test(m)) return false;
return /(?:^|\/)(?:gpt-5|o1|o3|o4)(?:$|[-_.\/])/.test(m);
}

export function isNewOpenAIContractConfig(config = {}) {
const providerName = String(config.providerName || '').trim().toLowerCase();
if (config.category === 'local' || providerName === 'lmstudio') return false;
if (providerName === 'openrouter') {
return /(?:^|\/)gpt-5\.6-terra(?:$|[-_.\/:])/.test(String(config.model || '').toLowerCase());
}
// Only OpenRouter is covered by the routed-model contract table. Other
// compatible endpoints may use slash-prefixed ids with legacy fields.
if (String(config.model || '').includes('/') && providerName !== 'openrouter') return false;
return isNewOpenAIContractModel(config.model);
}

export function supportsOpenAIAskStreaming(config = {}) {
if (!isOfficialOpenAIConfig(config)) return false;

Expand Down
7 changes: 2 additions & 5 deletions src/firefox/src/ui/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '../agent/capsolver-config.js';
import {
detectedCompatibilityPreset,
isNewOpenAIContractConfig,
normalizeOpenAICompatibleBaseUrl,
normalizeProviderCompatibility,
parseProviderExtraBodyJson,
Expand Down Expand Up @@ -1931,11 +1932,7 @@ function prettyCompatibilityValue(value) {

function automaticTokenField(config) {
if (shouldUseOpenAIResponsesApi(config)) return 'max_output_tokens';
const model = String(config.model || '').toLowerCase();
const isNewOfficialContract = config.type === 'openai'
&& config.category !== 'local'
&& config.providerName !== 'lmstudio'
&& /^(gpt-5|gpt-4\.1|o1|o3|o4)/.test(model);
const isNewOfficialContract = config.type === 'openai' && isNewOpenAIContractConfig(config);
return isNewOfficialContract ? 'max_completion_tokens' : 'max_tokens';
}

Expand Down
139 changes: 138 additions & 1 deletion test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -48496,7 +48496,12 @@ test('GPT-5.6 Responses request preserves reasoning and converts messages and to
baseUrl: 'https://openrouter.ai/api/v1',
model: 'openai/gpt-5.6-terra',
})._addMaxTokens(compatibleProviderBody, { maxTokens: 5 });
assert.equal(compatibleProviderBody.max_tokens, 5, 'compatible providers should keep their requested token cap');
assert.equal(
compatibleProviderBody.max_completion_tokens,
5,
'routed gpt-5.6-terra rejects max_tokens, so compatible providers must keep the new-contract token cap',
);
assert.equal(compatibleProviderBody.max_tokens, undefined, 'routed gpt-5.6-terra must not receive max_tokens');
}
});

Expand Down Expand Up @@ -50193,6 +50198,138 @@ test('OpenAI-compatible local providers always use legacy request token fields',
}
});

test('router-prefixed OpenAI reasoning ids use the advertised Chat Completions contract', () => {
const messages = [{ role: 'user', content: 'hello' }];
const newContractModels = ['openai/gpt-5.6-terra', 'openai/gpt-5.6-terra:batch', 'openai/gpt-5.6-terra:image'];
const legacyContractModels = [
'openai/o1',
'openai/o3-mini',
'openai/o4-mini:image',
'openai/gpt-5-pro',
'openai/gpt-5.2-pro',
'openai/gpt-5.4-pro',
'openai/gpt-5.5-pro',
'openai/gpt-5.5-pro:batch',
'openai/gpt-4o',
'openai/gpt-4.1',
'gpt-4.1',
'openrouter/deepseek-v3',
'openrouter/mistral-large',
'o365-assistant',
];
for (const compatibility of [ProviderCompatibilityCh, ProviderCompatibilityFx]) {
for (const model of newContractModels) {
assert.equal(compatibility.isNewOpenAIContractConfig({ providerName: 'openrouter', model }), true, `${model} should use the new contract`);
}
for (const model of legacyContractModels) {
assert.equal(compatibility.isNewOpenAIContractConfig({ providerName: 'openrouter', model }), false, `${model} should keep the legacy contract`);
}
}

for (const Provider of [OpenAIProviderCh, OpenAIProviderFx]) {
for (const model of newContractModels) {
const provider = new Provider({
providerName: 'openrouter',
baseUrl: 'https://openrouter.ai/api/v1',
model,
});
assert.equal(provider._isNewOpenAIContract(), true, `${model} should use the new contract`);
const body = provider._buildChatCompletionsBody(messages, { maxTokens: 123, temperature: 0.2 }, false);
assert.equal(body.max_completion_tokens, 123, `${model} should use max_completion_tokens`);
assert.equal(body.max_tokens, undefined, `${model} must not send max_tokens`);
assert.equal(body.temperature, undefined, `${model} must omit temperature`);
}

for (const model of legacyContractModels) {
const provider = new Provider({
providerName: 'openrouter',
baseUrl: 'https://openrouter.ai/api/v1',
model,
});
assert.equal(provider._isNewOpenAIContract(), false, `${model} should keep the legacy contract`);
const body = provider._buildChatCompletionsBody(messages, { maxTokens: 123 }, false);
assert.equal(body.max_tokens, 123, `${model} should use max_tokens`);
assert.equal(body.max_completion_tokens, undefined, `${model} must not send max_completion_tokens`);
assert.equal(body.temperature, 0.7, `${model} should keep the default temperature`);
}

// gpt-4.1 accepts both parameter sets; it must stay legacy so explicit
// temperatures (deterministic planner/compaction paths) are not dropped.
const gpt41 = new Provider({
providerName: 'openai',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
});
assert.equal(gpt41._isNewOpenAIContract(), false, 'gpt-4.1 must keep the legacy contract');
const gpt41Body = gpt41._buildChatCompletionsBody(messages, { maxTokens: 123, temperature: 0 }, false);
assert.equal(gpt41Body.max_tokens, 123, 'gpt-4.1 should use max_tokens');
assert.equal(gpt41Body.temperature, 0, 'gpt-4.1 must preserve an explicit temperature');

const lmstudio = new Provider({
providerName: 'lmstudio',
category: 'local',
baseUrl: 'http://localhost:1234/v1',
model: 'openai/o1',
});
assert.equal(lmstudio._isNewOpenAIContract(), false, 'lmstudio must keep the legacy contract even for reasoning ids');
const lmstudioBody = lmstudio._buildChatCompletionsBody(messages, { maxTokens: 123 }, false);
assert.equal(lmstudioBody.max_tokens, 123, 'lmstudio should use max_tokens');
assert.equal(lmstudioBody.temperature, 0.7, 'lmstudio should keep the default temperature');

// The guard must be case-insensitive: a hand-built or duplicated config
// with providerName 'LMStudio' still gets the legacy contract.
const lmstudioMixedCase = new Provider({
providerName: 'LMStudio',
category: 'cloud',
baseUrl: 'http://localhost:1234/v1',
model: 'openai/o1',
});
assert.equal(lmstudioMixedCase._isNewOpenAIContract(), false, 'LM Studio guard must be case-insensitive');

const official = new Provider({
providerName: 'openai',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.6-terra',
});
assert.equal(official._isNewOpenAIContract(), true, 'unprefixed gpt-5.6-terra should still use the new contract');
const officialBody = official._buildChatCompletionsBody(messages, { maxTokens: 123 }, false);
assert.equal(officialBody.max_completion_tokens, 123, 'gpt-5.6-terra should use max_completion_tokens');
assert.equal(officialBody.temperature, undefined, 'gpt-5.6-terra must omit temperature');
}
});

test('OpenAI contract config keeps non-OpenRouter slash ids on legacy fields', () => {
for (const [label, compatibility, settingsRel] of [
['chrome', ProviderCompatibilityCh, 'src/chrome/src/ui/settings.js'],
['firefox', ProviderCompatibilityFx, 'src/firefox/src/ui/settings.js'],
]) {
assert.equal(
compatibility.isNewOpenAIContractConfig({ providerName: 'openrouter', model: 'openai/gpt-5.6-terra' }),
true,
`${label}: OpenRouter GPT-5.6 Terra should use the new contract`,
);
for (const model of ['openai/o1', 'openai/o3-mini', 'openai/gpt-5.5-pro', 'openai/gpt-5.2-pro']) {
assert.equal(
compatibility.isNewOpenAIContractConfig({ providerName: 'openrouter', model }),
false,
`${label}: ${model} should keep OpenRouter's legacy contract`,
);
}
assert.equal(
compatibility.isNewOpenAIContractConfig({ providerName: 'custom-proxy', model: 'vendor/o3-mini' }),
false,
`${label}: unrelated slash-prefixed providers must keep legacy fields`,
);
assert.equal(
compatibility.isNewOpenAIContractConfig({ providerName: 'lmstudio', category: 'local', model: 'openai/o3' }),
false,
`${label}: local providers must keep legacy fields`,
);
const settings = fs.readFileSync(path.join(ROOT, settingsRel), 'utf8');
assert.match(settings, /function automaticTokenField\(config\)[\s\S]*isNewOpenAIContractConfig\(config\)/, `${label}: Settings must use the shared config predicate`);
}
});

test('provider compatibility defaults preserve legacy chat request bodies', () => {
const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hello' }];
for (const Provider of [OpenAIProviderCh, OpenAIProviderFx]) {
Expand Down
Loading