diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 14d9bdc1ecc..8656d2bac52 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -364,8 +364,9 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( message: OpenAIChatRequestMessage, - configuredField?: string, - options: LoweringOptions = {}, + configuredField: string | undefined, + requireReasoning: boolean, + options: LoweringOptions, ) { const content: TextPart[] = [] const reasoning: ReasoningPart[] = [] @@ -392,15 +393,17 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible) const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails)) const field = (() => { - if (configuredField !== undefined) return configuredField - if (reasoning.length === 0) return undefined + if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined)) + return configuredField + if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined if (observedField !== undefined) return observedField if (nativeReasoning !== undefined) return "reasoning_content" - if (!fullyStructured) return "reasoning_content" + if (!fullyStructured || requireReasoning) return "reasoning_content" })() const reasoningText = (() => { - if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text - if (reasoning.length === 0) return nativeReasoning + if (configuredField !== undefined) + return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text + if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined) return text })() const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined) @@ -454,11 +457,13 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* ( const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( message: OpenAIChatRequestMessage, - reasoningField?: string, - options: LoweringOptions = {}, + reasoningField: string | undefined, + requireReasoning: boolean, + options: LoweringOptions, ) { if (message.role === "user") return [yield* lowerUserMessage(message, options)] - if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)] + if (message.role === "assistant") + return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)] return (yield* lowerToolMessages(message, options)).messages }) @@ -480,6 +485,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] const modelID = request.model.id.toLowerCase() + const requireReasoning = + request.model.compatibility?.requireReasoning ?? + (request.model.compatibility?.reasoningField !== undefined || + request.model.provider === "deepseek" || + request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") || + modelID.includes("deepseek")) + const reasoningField = request.model.compatibility?.reasoningField const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) const lowering = { ...options, @@ -554,7 +566,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: continue } flushImages() - messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering))) + messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering))) } flushImages() return messages diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index e6aae721d84..4ed8e31f2b1 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -153,6 +153,8 @@ export class LanguageModelCompatibility extends Schema.Class { }), ) + it.effect("preserves observed reasoning fields when reasoning is required", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }), + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "thinking", + providerMetadata: { openai: { reasoningField: "reasoning_text" } }, + }, + { type: "text", text: "Hello" }, + ]), + Message.assistant("Done"), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: "Hello", reasoning_text: "thinking" }, + { role: "assistant", content: "Done", reasoning_content: "" }, + ]) + }), + ) + + it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: LanguageModel.update(model, { + compatibility: { reasoningField: "reasoning_text", requireReasoning: false }, + }), + messages: [ + Message.assistant([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]), + Message.assistant("Done"), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: "Hello", reasoning_text: "thinking" }, + { role: "assistant", content: "Done" }, + ]) + }), + ) + it.effect("rejects reasoning fields that conflict with assistant message fields", () => Effect.gen(function* () { const error = yield* compileRequest( diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 7f801ca6759..52ceddd3cad 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -317,6 +317,56 @@ describe("OpenAI-compatible Chat route", () => { }), ) + it.effect("requires reasoning for DeepSeek models, providers, and endpoints unless explicitly overridden", () => + Effect.gen(function* () { + const cases = [ + { id: "DeepSeek-V3", provider: "custom", baseURL: "https://api.custom.test/v1", required: true }, + { id: "custom-model", provider: "deepseek", baseURL: "https://api.custom.test/v1", required: true }, + { id: "custom-model", provider: "custom", baseURL: "https://API.DeepSeek.COM/v1", required: true }, + { id: "ordinary-model", provider: "custom", baseURL: "https://api.custom.test/v1", required: false }, + { + id: "ordinary-model", + provider: "custom", + baseURL: "https://api.custom.test/v1", + compatibility: { requireReasoning: true, reasoningField: "reasoning" }, + required: true, + field: "reasoning", + }, + { + id: "deepseek-chat", + provider: "deepseek", + baseURL: "https://api.deepseek.com/v1", + compatibility: { requireReasoning: false }, + required: false, + }, + ] as const + + yield* Effect.forEach(cases, (item) => + Effect.gen(function* () { + const selected = OpenAICompatibleChat.route + .with({ provider: item.provider, endpoint: { baseURL: item.baseURL } }) + .model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined }) + const prepared = yield* compileRequest( + LLM.request({ + model: selected, + messages: [ + Message.assistant("Hello"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }), + ], + }), + ) + const field = "field" in item ? item.field : "reasoning_content" + + for (const message of prepared.body.messages.filter((message) => message.role === "assistant")) { + if (item.required) expect(message).toHaveProperty(field, "") + else expect(message).not.toHaveProperty(field) + } + }), + ) + }), + ) + it.effect("posts to the configured compatible endpoint and parses text usage", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 50a8624c5fe..6d8ca4a5917 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1319,6 +1319,7 @@ export type ToolContent1 = ToolTextContent | ToolFileContent1 export type ModelCompatibility = { reasoningField?: ModelReasoningField + requireReasoning?: boolean maxTokensField?: ModelMaxTokensField requireFinishReason?: boolean requireAssistantAfterTool?: boolean diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 63a6692f1f0..a122efe6c22 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -391,6 +391,7 @@ describe("ModelResolver", () => { model(Provider.aisdk("@ai-sdk/openai-compatible"), { compatibility: { reasoningField: "vendor_reasoning", + requireReasoning: true, maxTokensField: "max_completion_tokens", requireFinishReason: false, requireAssistantAfterTool: true, @@ -417,6 +418,7 @@ describe("ModelResolver", () => { expect(headers.authorization).toBe("Bearer settings-secret") expect(resolved.route.id).toBe("openai-compatible-chat") expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") + expect(resolved.compatibility?.requireReasoning).toBe(true) expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens") expect(resolved.compatibility?.requireFinishReason).toBe(false) expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 4177cd8159a..a6cbf4aac04 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -55,6 +55,8 @@ export type MaxTokensField = typeof MaxTokensField.Type export interface Compatibility extends Schema.Schema.Type {} export const Compatibility = Schema.Struct({ reasoningField: ReasoningField.pipe(optional), + /** Require every assistant message to include its reasoning field, even when empty. */ + requireReasoning: Schema.Boolean.pipe(optional), maxTokensField: MaxTokensField.pipe(optional), requireFinishReason: Schema.Boolean.pipe(optional), requireAssistantAfterTool: Schema.Boolean.pipe(optional), diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 344020762c2..97d075563c0 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -40,12 +40,14 @@ describe("Model.Compatibility", () => { expect( decode({ reasoningField: "vendor_reasoning", + requireReasoning: true, maxTokensField: "max_completion_tokens", requireFinishReason: false, requireAssistantAfterTool: true, }), ).toEqual({ reasoningField: "vendor_reasoning", + requireReasoning: true, maxTokensField: "max_completion_tokens", requireFinishReason: false, requireAssistantAfterTool: true,