From 53fa6de8e7d7845f305e449d87efa2413b187d36 Mon Sep 17 00:00:00 2001 From: scriptxyz <11991772+scriptxyz@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:41:37 +1100 Subject: [PATCH] [Fix] Send max output tokens when the OpenAI Compatible checkbox shows ticked The "Include max output tokens" checkbox treated an unset value as checked, but the request builder only sent max_completion_tokens for an explicit true. Profiles that never toggled the box showed it ticked and sent no limit. Add DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS and read it in both places, so only an explicit false opts out. Skip the field when no limit is configured instead of assigning undefined. Kimi Code has no such control, so it keeps the previous opt-in behaviour. Co-Authored-By: Claude Opus 5.5 --- packages/types/src/provider-settings.ts | 1 + packages/types/src/provider-settings/index.ts | 6 ++- .../types/src/provider-settings/openai.ts | 7 +++ src/api/providers/__tests__/kimi-code.spec.ts | 16 +++++++ src/api/providers/__tests__/openai.spec.ts | 47 +++++++++++-------- src/api/providers/kimi-code.ts | 3 ++ src/api/providers/openai.ts | 10 ++-- .../settings/providers/OpenAICompatible.tsx | 3 +- 8 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 36009290cb..4dc4d1bcb9 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { providerDefinitionList, type ProviderDefinition } from "./provider-settings/index.js" import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/common.js" export { + DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, OPEN_AI_CODEX_SERVICE_TIER_KEY, parseOpenAiExtraBody, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 77d7ec001a..6f5f2fc414 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -38,7 +38,11 @@ import { basetenProviderDefinition } from "./baseten.js" import type { ProviderDefinition } from "./common.js" export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js" -export { parseOpenAiExtraBody, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS } from "./openai.js" +export { + parseOpenAiExtraBody, + DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS, + DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, +} from "./openai.js" export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js" export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js" export { diff --git a/packages/types/src/provider-settings/openai.ts b/packages/types/src/provider-settings/openai.ts index 19ef88cee4..1ffd4796f5 100644 --- a/packages/types/src/provider-settings/openai.ts +++ b/packages/types/src/provider-settings/openai.ts @@ -14,6 +14,13 @@ export const OPEN_AI_MODEL_ID_FIELD = "openAiModelId" */ export const DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS = true +/** + * Default for the OpenAI Compatible "Include max output tokens" checkbox. + * The settings UI and the request builder must both read this so an unset + * value is shown and sent the same way. + */ +export const DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS = true + const OPENAI_EXTRA_BODY_RESERVED_KEYS = [ // Prototype-pollution defenses; remaining keys are request-owned, including tool-call protocol controls. "__proto__", diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index c8e1130b30..a190ef3e7b 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -46,6 +46,22 @@ describe("KimiCodeHandler", () => { expect(handler.getModel().id).toBe("kimi-for-coding") }) + it.each([ + { includeMaxTokens: undefined, expected: false }, + { includeMaxTokens: false, expected: false }, + { includeMaxTokens: true, expected: true }, + ])( + "keeps max output tokens opt-in when includeMaxTokens is $includeMaxTokens", + ({ includeMaxTokens, expected }) => { + const handler = new KimiCodeHandler({ + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "key", + includeMaxTokens, + }) + expect(handler["options"].includeMaxTokens).toBe(expected) + }, + ) + it("uses API key when auth method is api-key", async () => { const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "my-api-key" }) const gen = handler.createMessage("system", [{ role: "user", content: "test" }]) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 7c45f22180..09a5f5fe57 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -687,25 +687,34 @@ describe("OpenAiHandler", () => { expect(callArgs.max_completion_tokens).toBeUndefined() }) - it("should not include max_tokens when includeMaxTokens is undefined", async () => { - const optionsWithUndefinedMaxTokens: ApiHandlerOptions = { - ...mockOptions, - // includeMaxTokens is not set, should not include max_tokens - openAiCustomModelInfo: { - contextWindow: 128_000, - maxTokens: 4096, - supportsPromptCache: false, - }, - } - const handlerWithDefaultMaxTokens = new OpenAiHandler(optionsWithUndefinedMaxTokens) - const stream = handlerWithDefaultMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call - await collectStream(stream) - // Assert the mockCreate was called without max_tokens - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBeUndefined() - }) + it.each([ + { label: "unset (UI shows it checked)", includeMaxTokens: undefined, expected: 4096 }, + { label: "true", includeMaxTokens: true, expected: 4096 }, + { label: "false", includeMaxTokens: false, expected: undefined }, + ])( + "sends max_completion_tokens matching the checkbox when includeMaxTokens is $label", + async ({ includeMaxTokens, expected }) => { + const customModelInfo = { contextWindow: 128_000, maxTokens: 4096, supportsPromptCache: false } + const streamingHandler = new OpenAiHandler({ + ...mockOptions, + includeMaxTokens, + openAiCustomModelInfo: customModelInfo, + }) + await collectStream(streamingHandler.createMessage(systemPrompt, messages)) + const nonStreamingHandler = new OpenAiHandler({ + ...mockOptions, + includeMaxTokens, + openAiStreamingEnabled: false, + openAiCustomModelInfo: customModelInfo, + }) + await collectStream(nonStreamingHandler.createMessage(systemPrompt, messages)) + + expect(mockCreate).toHaveBeenCalledTimes(2) + for (const [callArgs] of mockCreate.mock.calls) { + expect(callArgs.max_completion_tokens).toBe(expected) + } + }, + ) it("should use user-configured modelMaxTokens instead of model default maxTokens", async () => { const optionsWithUserMaxTokens: ApiHandlerOptions = { diff --git a/src/api/providers/kimi-code.ts b/src/api/providers/kimi-code.ts index 54e48b29c0..5167b8fa59 100644 --- a/src/api/providers/kimi-code.ts +++ b/src/api/providers/kimi-code.ts @@ -47,6 +47,9 @@ export class KimiCodeHandler extends OpenAiHandler { openAiApiKey: options.kimiCodeApiKey ?? NOT_PROVIDED, openAiModelId: options.apiModelId ?? kimiCodeDefaultModelId, openAiStreamingEnabled: true, + // Kimi Code has no "Include max output tokens" control, so a value left + // in the profile by OpenAI Compatible must not opt it in by default. + includeMaxTokens: options.includeMaxTokens === true, }) this.kimiOptions = options } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 98ea18df8a..4d7cf9536a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -9,6 +9,7 @@ import { isAzureOpenAiBaseUrl, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE, + DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, OPENAI_AZURE_AI_INFERENCE_PATH, parseOpenAiExtraBody, @@ -596,11 +597,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl | OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, modelInfo: ModelInfo, ): void { - // Only add max_completion_tokens if includeMaxTokens is true - if (this.options.includeMaxTokens === true) { + // Unset means the default (checked in the UI); only an explicit false opts out + if (this.options.includeMaxTokens ?? DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS) { // Use user-configured modelMaxTokens if available, otherwise fall back to model's default maxTokens // Using max_completion_tokens as max_tokens is deprecated - requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens + const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens + if (maxTokens) { + requestOptions.max_completion_tokens = maxTokens + } } } diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index efb839a7fc..8ea71e1eeb 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -12,6 +12,7 @@ import { azureOpenAiDefaultApiVersion, isAzureOpenAiBaseUrl, openAiModelInfoSaneDefaults, + DEFAULT_OPEN_AI_INCLUDE_MAX_TOKENS, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, OpenAiModelsMessageType, parseOpenAiExtraBody, @@ -182,7 +183,7 @@ export const OpenAICompatible = ({
{t("settings:includeMaxOutputTokens")}