diff --git a/src/chrome/src/providers/azure-openai.js b/src/chrome/src/providers/azure-openai.js index 045c31896..ec78b421a 100644 --- a/src/chrome/src/providers/azure-openai.js +++ b/src/chrome/src/providers/azure-openai.js @@ -1,5 +1,6 @@ import { BaseLLMProvider } from './base.js'; import { fetchWithFallback } from './fetch-with-fallback.js'; +import { configuredMaxTokensField } from './provider-compatibility.js'; /** * Azure OpenAI provider (deployment-based OpenAI-compatible API). @@ -62,11 +63,24 @@ export class AzureOpenAIProvider extends BaseLLMProvider { } _addMaxTokens(body, options) { - // Azure OpenAI follows the legacy OpenAI contract here. + // The Compatibility panel's "max tokens field" is the explicit + // per-deployment switch. `_addConfiguredMaxTokens` already honors + // `compat.maxTokensField` (and `config.maxTokensField`) through + // `configuredMaxTokensField`, so a reasoning deployment set to + // `max_completion_tokens` in Settings sends the new field with no + // name-guessing here. this._addConfiguredMaxTokens(body, options, 'max_tokens'); } _addTemperature(body, options) { + // Azure deployment names do not reveal the model behind them (prod-chat + // may back o3-mini; o365-assistant may back gpt-35-turbo), so never guess + // the contract from the name. A reasoning deployment opts in by setting + // the Compatibility "max tokens field" to `max_completion_tokens` (which + // also drops temperature — reasoning models reject any non-default), or + // by enabling `omitTemperature` directly. + if (this.config.omitTemperature) return; + if (configuredMaxTokensField(this.config, 'max_tokens') === 'max_completion_tokens') return; body.temperature = options.temperature ?? 0.7; } diff --git a/src/firefox/src/providers/azure-openai.js b/src/firefox/src/providers/azure-openai.js index 79d6708d2..065f9ca0e 100644 --- a/src/firefox/src/providers/azure-openai.js +++ b/src/firefox/src/providers/azure-openai.js @@ -1,5 +1,6 @@ import { BaseLLMProvider } from './base.js'; import { fetchWithTimeout } from './fetch-timeout.js'; +import { configuredMaxTokensField } from './provider-compatibility.js'; /** * Azure OpenAI provider (deployment-based OpenAI-compatible API). @@ -60,10 +61,24 @@ export class AzureOpenAIProvider extends BaseLLMProvider { } _addMaxTokens(body, options) { + // The Compatibility panel's "max tokens field" is the explicit + // per-deployment switch. `_addConfiguredMaxTokens` already honors + // `compat.maxTokensField` (and `config.maxTokensField`) through + // `configuredMaxTokensField`, so a reasoning deployment set to + // `max_completion_tokens` in Settings sends the new field with no + // name-guessing here. this._addConfiguredMaxTokens(body, options, 'max_tokens'); } _addTemperature(body, options) { + // Azure deployment names do not reveal the model behind them (prod-chat + // may back o3-mini; o365-assistant may back gpt-35-turbo), so never guess + // the contract from the name. A reasoning deployment opts in by setting + // the Compatibility "max tokens field" to `max_completion_tokens` (which + // also drops temperature — reasoning models reject any non-default), or + // by enabling `omitTemperature` directly. + if (this.config.omitTemperature) return; + if (configuredMaxTokensField(this.config, 'max_tokens') === 'max_completion_tokens') return; body.temperature = options.temperature ?? 0.7; } diff --git a/test/run.js b/test/run.js index cbab07c8a..34411a817 100644 --- a/test/run.js +++ b/test/run.js @@ -49153,6 +49153,65 @@ test('Azure OpenAI Ask streams require DONE and distinguish terminal API errors' } }); +test('Azure OpenAI does not guess the wire contract from the deployment name', () => { + for (const Provider of [AzureOpenAIProviderCh, AzureOpenAIProviderFx]) { + // Deployment names don't reveal the model behind them: o1-named + // deployments stay legacy, and a name that merely starts with o3/o4 + // must not flip a gpt-35-turbo deployment to the new contract. + for (const deployment of ['o1', 'o1-mini', 'o3-mini', 'o4-mini', 'gpt-5', 'gpt-4.1', 'o365-assistant', 'prod-chat']) { + const provider = new Provider({ + providerName: 'azure-openai', + baseUrl: 'https://example.openai.azure.com', + model: deployment, + apiVersion: '2025-09-01', + apiKey: 'test-key', + }); + const body = provider._buildRequestBody( + [{ role: 'user', content: 'hello' }], + { maxTokens: 2048 }, + ); + assert.equal(body.max_tokens, 2048, `${Provider.name}/${deployment}: default contract keeps max_tokens`); + assert.equal(body.max_completion_tokens, undefined, `${Provider.name}/${deployment}: no name-guessing into the new contract`); + assert.equal(body.temperature, 0.7, `${Provider.name}/${deployment}: default contract keeps temperature`); + } + } +}); + +test('Azure OpenAI switches to max_completion_tokens and omits temperature via the explicit compat field', () => { + for (const Provider of [AzureOpenAIProviderCh, AzureOpenAIProviderFx]) { + const provider = new Provider({ + providerName: 'azure-openai', + baseUrl: 'https://example.openai.azure.com', + model: 'prod-chat', + apiVersion: '2025-09-01', + apiKey: 'test-key', + compat: { maxTokensField: 'max_completion_tokens' }, + }); + const body = provider._buildRequestBody( + [{ role: 'user', content: 'hello' }], + { maxTokens: 2048 }, + ); + assert.equal(body.max_completion_tokens, 2048, `${Provider.name}: explicit field must use max_completion_tokens`); + assert.equal(body.max_tokens, undefined, `${Provider.name}: legacy field must be absent`); + assert.equal(body.temperature, undefined, `${Provider.name}: reasoning contract omits temperature`); + } +}); + +test('Azure OpenAI omitTemperature config suppresses temperature on any deployment', () => { + for (const Provider of [AzureOpenAIProviderCh, AzureOpenAIProviderFx]) { + const provider = new Provider({ + providerName: 'azure-openai', + baseUrl: 'https://example.openai.azure.com', + model: 'custom-reasoning-deployment', + apiVersion: '2025-09-01', + apiKey: 'test-key', + omitTemperature: true, + }); + const body = provider._buildRequestBody([{ role: 'user', content: 'hello' }]); + assert.equal(body.temperature, undefined, `${Provider.name}: omitTemperature must be respected`); + } +}); + test('Ask stream aggregation exposes text live but withholds tool calls until response.completed', async () => { for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { let releaseCompleted;