mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-11 15:55:44 +00:00
fix(ai): gate the Mistral remap, unify the reasoning join, stop mutating caller messages
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,4 @@ export class ChatProvider implements IChatProvider {
|
||||
async complete(_arg: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
checkModeration(_text: string): void {
|
||||
// No moderation by default; providers override when they support it.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expect('reasoning_details' in sent).toBe(false);
|
||||
expect(
|
||||
(sent.content as Array<Record<string, unknown>>)[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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
@@ -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<string, unknown> } & Record<string, unknown>;
|
||||
|
||||
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({
|
||||
|
||||
@@ -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<IChatCompleteResult> {
|
||||
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<
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -174,7 +174,6 @@ export class ZAIProvider implements IChatProvider {
|
||||
completion,
|
||||
});
|
||||
|
||||
OpenAIUtil.normalizeReasoningContent(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
|
||||
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<Record<string, unknown>> = [
|
||||
{ role: 'user', content: 'hi' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
// 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<string, string> = {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user