diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ebb48b041b..2f71061973 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 "./providers/index.js" import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./providers/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/providers/openai.ts b/packages/types/src/providers/openai.ts index cb50a7d40a..8490df4f2a 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -996,6 +996,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 a425ecdc62..ebe754aaed 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 = ({