From c1b420e480cc4ad43886d9af6ee5cef57c11fd73 Mon Sep 17 00:00:00 2001 From: 404oops Date: Thu, 27 Aug 2026 16:39:12 +0200 Subject: [PATCH] fix(ai): gate the Mistral remap, unify the reasoning join, stop mutating caller messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acts on a triple-check audit of this branch. Gate the Mistral dialect remap. The camelCase→snake_case rewrite and the chunked-content flattening were firing for every Mistral call regardless of `normalize` or the cutoff, deleting `finishReason` and `message.toolCalls` out from under any caller reading them. Both now sit behind the policy resolution the driver already used, extracted as `shouldPresentAsOpenAI` so the provider and the driver cannot drift. The streaming chunk-array split stays ungated: handing an array to `addText` is a plain bug, and streamed chunks are provider-uniform by design. The conformance matrix now passes `normalize: true`, which is the contract it was always testing. Unify the reasoning join. Three code paths produced two separators while one doc sentence described them all: the coercer joined thinking segments with '', the Responses handler and Mistral with '\n\n'. The coercer now matches, and chatresponse.md's claim is true for every path it covers. Text blocks still join with '' — Anthropic splits prose mid-sentence across them. finish_reason is an open set. chat.md's normalize bullet and the SDK ChatMessage typedef still declared a closed four-value set, contradicting the documented pass-through of unmapped vendor reasons and the coercer that implements it. Stop mutating caller messages. Both reasoning-replay input paths deleted output-only fields from the caller's own message objects, which the driver reuses across fallback attempts. Both strip a copy now; tests pass a frozen message through each. Drop three dead things the type cleanup left: the no-op ChatProvider checkModeration stub (no subclasses, no callers — its removal restores a pre-existing baselined TS2420), the redundant second normalizeReasoningContent call in BytePlus and ZAI, and the coercer's bare-string branch that no provider reaches. A bare string now passes through by reference instead of being coerced. Co-Authored-By: Claude Opus 5 (1M context) --- .../drivers/ai-chat/ChatCompletionDriver.ts | 29 +++++------ .../drivers/ai-chat/providers/ChatProvider.ts | 3 -- .../providers/byteplus/BytePlusProvider.ts | 3 -- .../providers/claude/ClaudeProvider.test.ts | 39 +++++++++++++++ .../providers/claude/ClaudeProvider.ts | 14 +++++- .../mistral/MistralAiProvider.test.ts | 40 ++++++++++++++++ .../providers/mistral/MistralAiProvider.ts | 28 +++++++++-- .../providers/providerConsistency.test.ts | 6 +++ .../ai-chat/providers/zai/ZAIProvider.ts | 1 - .../drivers/ai-chat/utils/OpenAIUtil.js | 3 ++ .../drivers/ai-chat/utils/OpenAIUtil.test.ts | 23 +++++++++ .../ai-chat/utils/normalizeToOpenAI.test.ts | 22 +++++---- .../ai-chat/utils/normalizeToOpenAI.ts | 48 +++++++++++++------ src/docs/src/AI/chat.md | 4 +- src/puter-js/src/modules/ai/types.js | 3 +- 15 files changed, 208 insertions(+), 58 deletions(-) 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]