diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 92ba9cc37..8f1dc8a2b 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -73,8 +73,8 @@ import { normalizeModelKey, } from './utils/modelRouting.js'; import { - isPostCutoffRelease, normalizeResultToOpenAI, + shouldPresentAsOpenAI, } from './utils/normalizeToOpenAI.js'; import { costKeys, isFreeModel } from './utils/pricing.js'; import { @@ -685,29 +685,22 @@ export class ChatCompletionDriver extends PuterDriver { if ('message' in res && res.message) { // `'message' in res` doesn't narrow the result union for TS. const messageRes = res as IChatMessageResult; - if (args.normalize === true) { + if (shouldPresentAsOpenAI(args, model.release_date)) { return { ...normalizeResultToOpenAI(messageRes), normalized: true, via_ai_chat_service: true, }; } - if (args.normalize !== false) { - if (args.response?.normalize) { - return { - ...messageRes, - message: normalize_single_message(messageRes.message), - normalized: true, - via_ai_chat_service: true, - }; - } - if (isPostCutoffRelease(model.release_date)) { - return { - ...normalizeResultToOpenAI(messageRes), - normalized: true, - via_ai_chat_service: true, - }; - } + // The legacy flag normalizes the other way — to Anthropic blocks — + // and only when the new flag is absent. + if (args.normalize !== false && args.response?.normalize) { + return { + ...messageRes, + message: normalize_single_message(messageRes.message), + normalized: true, + via_ai_chat_service: true, + }; } } diff --git a/src/backend/drivers/ai-chat/providers/ChatProvider.ts b/src/backend/drivers/ai-chat/providers/ChatProvider.ts index 9f24cab55..9c83dee28 100644 --- a/src/backend/drivers/ai-chat/providers/ChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/ChatProvider.ts @@ -41,7 +41,4 @@ export class ChatProvider implements IChatProvider { async complete(_arg: ICompleteArguments): Promise { throw new Error('Method not implemented.'); } - checkModeration(_text: string): void { - // No moderation by default; providers override when they support it. - } } diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts index 974fa8f4d..b6f2a9921 100644 --- a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts @@ -160,9 +160,6 @@ export class BytePlusProvider implements IChatProvider { completion, }); - // Ark's deep-reasoning models return `reasoning_content` (DeepSeek - // wire convention); expose it under `reasoning` like other providers. - OpenAIUtil.normalizeReasoningContent(result); return result; } diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index ac5add4fa..c513f67ae 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -427,6 +427,45 @@ describe('ClaudeProvider.complete request shape', () => { expect('refusal' in assistant).toBe(false); }); + it('leaves the caller\'s message objects intact', async () => { + // The driver reuses the same messages array across fallback attempts, + // so stripping the output-only fields has to happen on a copy. + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + const callerMessage = Object.freeze({ + role: 'assistant', + content: 'here you go', + reasoning: 'step one', + refusal: null, + reasoning_details: Object.freeze([ + Object.freeze({ + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }), + ]), + }); + const before = JSON.parse(JSON.stringify(callerMessage)); + + // A frozen message would throw on `delete` in strict mode. + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [callerMessage as never], + }), + ); + + expect(callerMessage).toEqual(before); + // ...and the provider still sent the spliced content upstream. + const [args] = messagesCreateMock.mock.calls[0]!; + const sent = args.messages[0] as Record; + expect('reasoning_details' in sent).toBe(false); + expect( + (sent.content as Array>)[0], + ).toMatchObject({ type: 'thinking', signature: 'sig_1' }); + }); + it('strips output-only reasoning fields even with no reasoning_details', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index 1f589694f..e5ab3c1cd 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -147,8 +147,18 @@ export class ClaudeProvider implements IChatProvider { // `reasoning`/`refusal` are output-only fields Anthropic rejects // outright, and a caller replaying a normalized message carries them. // eslint-disable-next-line @typescript-eslint/no-explicit-any - messages = messages.map((message: any) => { - const details = message.reasoning_details; + messages = messages.map((original: any) => { + const details = original.reasoning_details; + if ( + details === undefined && + original.reasoning === undefined && + original.refusal === undefined + ) { + return original; + } + // Copy before stripping: the driver reuses this same array across + // fallback attempts, and these objects belong to the caller. + const message = { ...original }; delete message.reasoning_details; delete message.reasoning; delete message.refusal; diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts index 6e9cab15c..9a62e5860 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts @@ -496,6 +496,7 @@ describe('MistralAIProvider.complete non-stream output', () => { provider.complete({ model: 'magistral-small-latest', messages: [{ role: 'user', content: 'think' }], + normalize: true, }), )) as { message: Record }; @@ -528,6 +529,45 @@ describe('MistralAIProvider.complete non-stream output', () => { expect('reasoning' in result.message).toBe(false); }); + it('leaves the SDK dialect untouched when normalization does not apply', async () => { + // The remap changes what this provider returns, so it is gated on the + // same policy the driver's coercer uses. Without `normalize`, and with + // a pre-cutoff model, a caller reading the SDK's native keys keeps + // seeing them — nothing is deleted out from under it. + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: 'hi there', + toolCalls: [ + { + id: 'call_1', + function: { + name: 'get_weather', + arguments: { city: 'Paris' }, + }, + }, + ], + }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'mistral-small-2603', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record } & Record; + + expect('toolCalls' in result.message).toBe(true); + expect('tool_calls' in result.message).toBe(false); + }); + it('preserves OpenAI-shaped tool_calls on the assistant response', async () => { const { provider } = makeProvider(); completeMock.mockResolvedValueOnce({ diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index 08a29fd9e..904af4d1d 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -29,13 +29,16 @@ import type { import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { shouldPresentAsOpenAI } from '../../utils/normalizeToOpenAI.js'; /** * Mistral's reasoning models (`magistral-*`) return `content` as a chunk array * rather than a string, with the thinking text nested one level deeper inside * `thinking` chunks. Split it into the string content + `reasoning` string * every other provider produces. Text nested in a chunk is joined; a `thinking` - * chunk's own chunks are flattened the same way. + * chunk's own chunks are flattened the same way, and separate thinking chunks + * are separated by a blank line — the same separator the Responses summary + * handler and the Anthropic coercer use. */ const flattenChunkText = (value: unknown): string => { if (typeof value === 'string') return value; @@ -147,6 +150,8 @@ export class MistralAIProvider implements IChatProvider { tools, max_tokens, temperature, + normalize, + response, }: ICompleteArguments): Promise { messages = await OpenAIUtil.process_input_messages(messages); messages = this.#coerceImageUrls(messages); @@ -178,9 +183,24 @@ export class MistralAIProvider implements IChatProvider { }); // The Mistral SDK speaks camelCase (`finishReason`, `toolCalls`, - // object-typed `arguments`); remap each choice to the OpenAI wire - // shape so the result matches every other provider's. - if (!stream) { + // object-typed `arguments`) and its reasoning models return chunked + // `content`; remap each choice to the OpenAI wire shape so the result + // matches every other provider's. + // + // This changes what the provider returns, so it sits behind the same + // policy resolution the driver's coercer uses rather than firing on + // every Mistral call — a caller reading the SDK's native + // `finishReason`/`toolCalls` keys keeps seeing them unless it asked + // for the equalized shape. (The streaming half is not gated: feeding a + // chunk array to `addText` is a plain bug, and streamed chunks are + // provider-uniform by design.) + if ( + !stream && + shouldPresentAsOpenAI( + { normalize, response }, + selectedModel.release_date, + ) + ) { const choices = (completion as ChatCompletionResponse).choices ?? []; for (const choice of choices as unknown as Record< diff --git a/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts index 6fd2ea54f..cd873ba99 100644 --- a/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts +++ b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts @@ -565,11 +565,17 @@ const run = async (pc: ProviderCase, kind: 'text' | 'tool' | 'reasoning') => { ); } armUpstream(pc.dialect, kind); + // `normalize: true` is the contract under test: what a caller who asked + // for the OpenAI shape receives. Providers whose dialect remap is gated on + // the policy (Mistral) need it set, and for every other provider it is a + // no-op — so stating it makes the matrix's premise explicit instead of + // relying on providers equalizing unconditionally. const res = (await withTestActor(() => provider.complete({ messages: [{ role: 'user', content: 'hi' }], model, stream: false, + normalize: true, } as never), )) as IChatMessageResult; // The same pass ChatCompletionDriver applies with `normalize: true`. diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts index 9d983d792..910b3aa7e 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts @@ -174,7 +174,6 @@ export class ZAIProvider implements IChatProvider { completion, }); - OpenAIUtil.normalizeReasoningContent(result); return result; } diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index 7cb1800f5..036639511 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -107,6 +107,9 @@ export const process_input_messages_responses_api = async (messages) => { msg.refusal !== undefined || msg.normalized !== undefined ) { + // Rebind to a stripped copy rather than deleting: the driver + // reuses this same array across fallback attempts, and these + // objects belong to the caller. const { reasoning_details: _details, reasoning: _reasoning, diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts index fe5a057fd..658bbdca9 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts @@ -333,6 +333,29 @@ describe('process_input_messages_responses_api', () => { expect('refusal' in out[1]!).toBe(false); }); + it('does not mutate the caller\'s message objects', async () => { + const callerMessage = Object.freeze({ + role: 'assistant', + content: 'earlier reply', + reasoning: 'thought', + refusal: null, + normalized: true, + reasoning_details: Object.freeze([ + Object.freeze({ type: 'reasoning', id: 'rs_1' }), + ]), + }); + const before = JSON.parse(JSON.stringify(callerMessage)); + + const out = (await process_input_messages_responses_api([ + callerMessage, + ] as never)) as Array>; + + expect(callerMessage).toEqual(before); + // The stripped copy is what goes upstream. + expect('reasoning_details' in out[1]!).toBe(false); + expect('normalized' in out[1]!).toBe(false); + }); + it('leaves messages without reasoning artifacts alone', async () => { const messages: Array> = [ { role: 'user', content: 'hi' }, diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts index 5324b9e1f..d8c0e5a89 100644 --- a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts @@ -82,7 +82,10 @@ describe('needsOpenAICoercion', () => { content: [{ type: 'text', text: 'x' }], }), ).toBe(true); - expect(needsOpenAICoercion('bare string')).toBe(true); + // A bare string is not flagged: no provider produces one, so it + // passes through by reference rather than through a coercion path + // nothing exercises. + expect(needsOpenAICoercion('bare string')).toBe(false); }); it('passes OpenAI-shaped messages through', () => { @@ -155,17 +158,16 @@ describe('normalizeResultToOpenAI', () => { expect(out.finish_reason).toBe('stop'); }); - it('wraps a bare-string message', () => { - const out = normalizeResultToOpenAI({ + it('passes a bare-string message through untouched', () => { + // No provider in the repo returns a bare string message. Rather than + // carry a coercion path nothing exercises, the predicate ignores + // strings and the result comes back by reference. + const res = { message: 'plain', usage: { input_tokens: 1, output_tokens: 1 }, finish_reason: 'stop', - }); - expect(out.message).toEqual({ - role: 'assistant', - content: 'plain', - refusal: null, - }); + }; + expect(normalizeResultToOpenAI(res)).toBe(res); }); it.each([ @@ -251,7 +253,7 @@ describe('normalizeResultToOpenAI', () => { { type: 'text', text: 'answer' }, ]), ); - expect(out.message.reasoning).toBe('step one. step two.'); + expect(out.message.reasoning).toBe('step one. \n\nstep two.'); expect(out.message.content).toBe('answer'); }); diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts index 3629cb61d..cca04be78 100644 --- a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts @@ -58,7 +58,6 @@ export const isPostCutoffRelease = (release_date?: string): boolean => { * like Gemini's `images` or the Responses API's `reasoning`. */ export const needsOpenAICoercion = (message: unknown): boolean => { - if (typeof message === 'string') return true; if (!message || typeof message !== 'object') return false; const m = message as Record; // The Anthropic SDK's message envelope self-identifies. @@ -68,6 +67,34 @@ export const needsOpenAICoercion = (message: unknown): boolean => { return Array.isArray(m.content); }; +/** + * Resolve whether a call's non-streaming result should be presented in the + * OpenAI shape. This is the single definition of the precedence rule: an + * explicit per-call `normalize` wins in both directions, the legacy + * `response.normalize` (internal block-format normalization) applies only when + * the new flag is absent, and otherwise the release-date cutoff decides. + * + * Both the driver (which coerces the result) and any provider that has to + * choose between emitting its vendor's dialect or the equalized one call this, + * so the two can never drift apart. A provider passes its own served model's + * `release_date` — which is the served model by definition, since the provider + * is the one serving it. + */ +export const shouldPresentAsOpenAI = ( + args: { + normalize?: boolean | undefined; + response?: { normalize?: boolean | undefined } | undefined; + }, + release_date?: string, +): boolean => { + if (args.normalize === true) return true; + if (args.normalize === false) return false; + // The legacy flag normalizes in the *opposite* direction (to Anthropic + // blocks), so it suppresses the OpenAI presentation rather than enabling it. + if (args.response?.normalize) return false; + return isPostCutoffRelease(release_date); +}; + const STOP_REASON_TO_FINISH_REASON: Record = { end_turn: 'stop', stop_sequence: 'stop', @@ -122,18 +149,6 @@ export const normalizeResultToOpenAI = ( ): IChatMessageResult => { if (!needsOpenAICoercion(res.message)) return res; - if (typeof res.message === 'string') { - return { - ...res, - message: { - role: 'assistant', - content: res.message, - refusal: null, - }, - finish_reason: res.finish_reason ?? 'stop', - }; - } - const native = res.message as Record; const blocks = Array.isArray(native.content) ? (native.content as unknown[]) @@ -187,8 +202,13 @@ export const normalizeResultToOpenAI = ( // Null content alongside tool_calls mirrors OpenAI's own convention for // tool-only turns. const content = textParts.length > 0 ? textParts.join('') : null; + // Separate thinking blocks are separate segments of reasoning, so they + // get a blank line between them — matching the Responses summary handler + // and the Mistral chunk splitter, and what chatresponse.md documents. + // (Text blocks above still join with '' because Anthropic splits prose + // mid-sentence across blocks.) const reasoning = - reasoningParts.length > 0 ? reasoningParts.join('') : undefined; + reasoningParts.length > 0 ? reasoningParts.join('\n\n') : undefined; return { ...res, diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 76598de99..85bac5deb 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -35,7 +35,7 @@ An object containing the following properties: - `tools` (Array) (Optional) - Function definitions the AI can call. See [Function Calling](#function-calling) for details. - `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Lower values give faster responses with less reasoning. OpenAI models and Meta's Muse Spark models only; Muse Spark always reasons, so `none` is ignored for it. - `verbosity` / `text.verbosity` (String) (Optional) - Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only. -- `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter`. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, `puter.ai.normalize` applies if you assigned it; otherwise **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and older models keep their current behavior. Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization). +- `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter` — or the vendor's own stop reason, passed through unchanged when it has no OpenAI equivalent. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, `puter.ai.normalize` applies if you assigned it; otherwise **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and older models keep their current behavior. Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization). - `compaction` (Boolean | Object) (Optional) - Opt into inline context compaction for long conversations. Pass `true` to enable it with provider defaults, or `{ trigger_tokens: number }` to set the token threshold at which earlier context is summarized. When the model compacts, you receive a `compaction` chunk while streaming (or a `compaction` field on the result when not streaming) containing an opaque `encrypted_content` summary. Resend that item in `messages` on the next turn in place of the summarized history. The compaction chunk shape is identical across providers, so the same code works whether `model` is an OpenAI or Anthropic model. See [Compaction](#compaction). #### `testMode` (Boolean) (Optional) @@ -127,7 +127,7 @@ You can control this per call with the `normalize` option: // Force the OpenAI format on any model, old or new: const response = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: true }); console.log(response.message.content); // always a string -console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" +console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" | vendor value // Force the vendor-native format, even on a post-cutoff model: const native = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: false }); diff --git a/src/puter-js/src/modules/ai/types.js b/src/puter-js/src/modules/ai/types.js index a527066af..e07f62154 100644 --- a/src/puter-js/src/modules/ai/types.js +++ b/src/puter-js/src/modules/ai/types.js @@ -108,7 +108,8 @@ * @typedef {Object} ChatResponse * @property {ChatMessage} [message] * @property {string} [finish_reason] Why generation stopped: `stop`, `length`, `tool_calls`, or - * `content_filter`. + * `content_filter` — or the vendor's own stop reason (e.g. Anthropic's `pause_turn`), passed + * through unchanged when it has no OpenAI equivalent. Treat it as an open set. * @property {boolean} [normalized] Present and `true` when the response format was normalized * server-side (see the `normalize` option on [ChatOptions]). * @property {unknown} [choices]