fix: forward reasoning effort on the BYOK Messages API path

The useMessagesApi branch of OpenAIEndpoint.createRequestBody never called
_applyReasoningEffort, so a custom endpoint declaring supportsReasoningEffort
never received the selected effort. Forward it like the Responses and Chat
Completions branches, and add a 'messages' reasoningEffortFormat that writes
the Anthropic Messages shape output_config.effort (the default on /messages,
overridable via reasoningEffortFormat). The scrub merges into output_config
instead of replacing it so sibling fields (e.g. structured output format)
survive.

Fixes #325121
This commit is contained in:
Dave
2026-07-09 20:06:48 +02:00
parent 800753c844
commit ac0fb39943
7 changed files with 72 additions and 16 deletions
+9 -6
View File
@@ -1967,9 +1967,10 @@
"type": "string",
"enum": [
"chat-completions",
"responses"
"responses",
"messages"
],
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. When unset the format follows the URL: `/responses` → nested, otherwise top-level."
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. `messages` sends the Anthropic Messages `output_config.effort` field. When unset the format follows the URL: `/responses` → nested, `/messages` → `output_config.effort`, otherwise top-level."
},
"requestHeaders": {
"type": "object",
@@ -2153,9 +2154,10 @@
"type": "string",
"enum": [
"chat-completions",
"responses"
"responses",
"messages"
],
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. When unset the format follows the URL: `/responses` → nested, otherwise top-level."
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. `messages` sends the Anthropic Messages `output_config.effort` field. When unset the format follows the URL: `/responses` → nested, `/messages` → `output_config.effort`, otherwise top-level."
},
"requestHeaders": {
"type": "object",
@@ -2306,9 +2308,10 @@
"type": "string",
"enum": [
"chat-completions",
"responses"
"responses",
"messages"
],
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. When unset the format follows the URL: `/responses` → nested, otherwise top-level."
"markdownDescription": "Body shape used to forward the reasoning effort to the model. `chat-completions` sends a top-level `reasoning_effort` string. `responses` sends a nested `reasoning.effort` object. `messages` sends the Anthropic Messages `output_config.effort` field. When unset the format follows the URL: `/responses` → nested, `/messages` → `output_config.effort`, otherwise top-level."
},
"requestHeaders": {
"type": "object",
@@ -75,9 +75,10 @@ export interface BYOKModelCapabilities {
* Override the body shape used to forward the reasoning effort to the model.
* - `'chat-completions'`: top-level `reasoning_effort` (default for `/chat/completions`).
* - `'responses'`: nested `reasoning.effort` (default for `/responses`).
* If unset the format is inferred from whether the endpoint uses the Responses API.
* - `'messages'`: `output_config.effort` (default for `/messages`).
* If unset the format is inferred from the API path the endpoint uses.
*/
reasoningEffortFormat?: 'chat-completions' | 'responses';
reasoningEffortFormat?: 'chat-completions' | 'responses' | 'messages';
}
export interface BYOKModelRegistry {
@@ -268,7 +268,9 @@ export class OpenAIEndpoint extends ChatEndpoint {
return this._applyConfiguredModelOptions(body, options);
} else if (this.useMessagesApi) {
// Delegate to base ChatEndpoint for Messages API dispatch
return this._applyConfiguredModelOptions(super.createRequestBody(options), options);
const body = super.createRequestBody(options);
this._applyReasoningEffort(body, options);
return this._applyConfiguredModelOptions(body, options);
} else {
// Handle Chat Completions: provide callback for thinking data processing
const supportsThinking = !!this.modelMetadata.capabilities.supports.thinking;
@@ -320,7 +322,8 @@ export class OpenAIEndpoint extends ChatEndpoint {
/**
* Forwards the per-request reasoning effort to the model body in the shape the endpoint expects.
* Default shape mirrors the API path (`Responses` \u2192 nested `reasoning.effort`, `Chat Completions` \u2192 top-level `reasoning_effort`).
* Default shape mirrors the API path (`Responses` \u2192 nested `reasoning.effort`, `Messages` \u2192 `output_config.effort`,
* `Chat Completions` \u2192 top-level `reasoning_effort`).
* `IChatModelInformation.reasoningEffortFormat` overrides the default so users hosting OpenAI-compatible servers
* with diverging conventions (e.g. nested `reasoning.effort` on `/chat/completions`) can opt in deterministically.
*/
@@ -330,9 +333,9 @@ export class OpenAIEndpoint extends ChatEndpoint {
return;
}
const format = this.modelMetadata.reasoningEffortFormat
?? (this.useResponsesApi ? 'responses' : 'chat-completions');
?? (this.useResponsesApi ? 'responses' : this.useMessagesApi ? 'messages' : 'chat-completions');
const override = this._configurationService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride);
const requested = override || options.modelCapabilities?.reasoningEffort || body.reasoning?.effort || body.reasoning_effort;
const requested = override || options.modelCapabilities?.reasoningEffort || body.reasoning?.effort || body.reasoning_effort || body.output_config?.effort;
const effort = requested && supports.includes(requested) ? requested : undefined;
// Scrub any pre-populated effort first so unsupported values (e.g. the hard-coded `medium` default
// from `createResponsesRequestBody`) cannot leak through, then write the resolved value into the
@@ -342,9 +345,16 @@ export class OpenAIEndpoint extends ChatEndpoint {
body.reasoning = Object.keys(rest).length > 0 ? rest : undefined;
}
body.reasoning_effort = undefined;
if (body.output_config) {
// Drop only the effort so other output_config fields (e.g. structured output format) survive
const { effort: _drop, ...rest } = body.output_config;
body.output_config = Object.keys(rest).length > 0 ? rest : undefined;
}
if (effort) {
if (format === 'responses') {
body.reasoning = { ...body.reasoning, effort };
} else if (format === 'messages') {
body.output_config = { ...body.output_config, effort };
} else {
body.reasoning_effort = effort;
}
@@ -527,6 +527,48 @@ describe('OpenAIEndpoint - Reasoning Properties', () => {
expect(body.reasoning?.effort).toBeUndefined();
});
it('places `output_config.effort` on the Messages API path when the model supports the requested level', () => {
const endpoint = instaService.createInstance(OpenAIEndpoint,
buildModel({ supported_endpoints: [ModelSupportedEndpoint.Messages] }),
'test-api-key',
'https://api.anthropic.com/v1/messages');
const body = endpoint.createRequestBody(buildOptions('high'));
expect(body.output_config).toEqual({ effort: 'high' });
expect(body.reasoning_effort).toBeUndefined();
expect(body.reasoning).toBeUndefined();
});
it('emits top-level `reasoning_effort` instead of `output_config.effort` when `reasoningEffortFormat` is `chat-completions` on a Messages URL', () => {
const endpoint = instaService.createInstance(OpenAIEndpoint,
buildModel({
reasoningEffortFormat: 'chat-completions',
supported_endpoints: [ModelSupportedEndpoint.Messages],
}),
'test-api-key',
'https://api.anthropic.com/v1/messages');
const body = endpoint.createRequestBody(buildOptions('high'));
expect(body.reasoning_effort).toBe('high');
expect(body.output_config).toBeUndefined();
});
it('scrubs `output_config.effort` while preserving other `output_config` fields', () => {
// `output_config` also carries structured-output fields (e.g. `format`); only the effort may be rewritten
const endpoint = instaService.createInstance(OpenAIEndpoint,
buildModel({ supported_endpoints: [ModelSupportedEndpoint.Messages] }),
'test-api-key',
'https://api.anthropic.com/v1/messages');
const apply = (endpoint as unknown as { _applyReasoningEffort: (body: IEndpointBody, options: ICreateEndpointBodyOptions) => void })._applyReasoningEffort.bind(endpoint);
const body: IEndpointBody = { output_config: { effort: 'unsupported-level', format: { type: 'json_schema', schema: {} } } as IEndpointBody['output_config'] };
apply(body, buildOptions('high'));
expect(body.output_config).toEqual({ format: { type: 'json_schema', schema: {} }, effort: 'high' });
});
it('does not emit a reasoning field when the model declares no reasoning support', () => {
const endpoint = instaService.createInstance(OpenAIEndpoint,
{
@@ -104,7 +104,7 @@ interface _CustomEndpointModelConfig {
modelOptions?: IChatModelRequestOptions;
zeroDataRetentionEnabled?: boolean;
supportsReasoningEffort?: string[];
reasoningEffortFormat?: 'chat-completions' | 'responses';
reasoningEffortFormat?: 'chat-completions' | 'responses' | 'messages';
}
export interface CustomEndpointModelConfig extends _CustomEndpointModelConfig {
@@ -66,7 +66,7 @@ interface _CustomOAIModelConfig {
requestHeaders?: Record<string, string>;
zeroDataRetentionEnabled?: boolean;
supportsReasoningEffort?: string[];
reasoningEffortFormat?: 'chat-completions' | 'responses';
reasoningEffortFormat?: 'chat-completions' | 'responses' | 'messages';
}
export interface CustomOAIModelConfig extends _CustomOAIModelConfig {
@@ -138,9 +138,9 @@ export type IChatModelInformation = IModelAPIResponse & {
/**
* BYOK-only override that forces the body shape used when forwarding the reasoning effort to the model.
* Honored by `OpenAIEndpoint`. Unset — the body shape follows the API path (Responses API → nested `reasoning.effort`,
* Chat Completions → top-level `reasoning_effort`).
* Anthropic Messages API → `output_config.effort`, Chat Completions → top-level `reasoning_effort`).
*/
reasoningEffortFormat?: 'chat-completions' | 'responses';
reasoningEffortFormat?: 'chat-completions' | 'responses' | 'messages';
};
export function isChatModelInformation(model: IModelAPIResponse): model is IChatModelInformation {