Skip to content

Comprehensive Normalization. - #3655

Open
404oops wants to merge 14 commits into
mainfrom
FK/normalize-openai-format
Open

Comprehensive Normalization.#3655
404oops wants to merge 14 commits into
mainfrom
FK/normalize-openai-format

Conversation

@404oops

@404oops 404oops commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Comprehensive Normalization.

This PR makes Puter's chat API return one response shape — the OpenAI choices[0] format — across all vendors, on an opt-in/opt-out basis. Callers get a new tri-state normalize option (per call and SDK-wide via puter.ai.normalize); models released on or after 2026-09-01 are normalized by default, while older models keep their vendor-native shape unless the caller asks otherwise. Normalization is lossless for reasoning models: Anthropic thinking-block signatures and OpenAI Responses reasoning items are preserved on a new message.reasoning_details field and can be replayed verbatim to continue an extended-thinking tool-use turn. Alongside the gated work, a small set of unconditional reasoning-field equalizations ships for existing models (documented in chat.md), and the provider catalogs get ~53 new model entries and ~23 new aliases. A 23-provider consistency test matrix pins the equalized contract.

Changes by area

Response normalization core

  • src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts (new) — the whole policy in one module: OPENAI_SHAPE_CUTOFF = '2026-09-01', isPostCutoffRelease (timestamp compare, tolerant of month-precision dates, missing/unparseable ⇒ pre-cutoff), needsOpenAICoercion (Anthropic envelope or content-block array), shouldPresentAsOpenAI (the single precedence rule), and normalizeResultToOpenAI — an idempotent coercer that joins text blocks into string content, converts tool_usetool_calls (arguments stringified), joins thinking into reasoning (blank-line separated), preserves thinking/redacted_thinking verbatim in reasoning_details, maps Anthropic stop_reason → OpenAI finish_reason, and passes unmapped vendor reasons (e.g. pause_turn) through verbatim. Already-OpenAI-shaped results return by reference.
  • ChatCompletionDriver.ts — applies the coercer to non-streaming message results per shouldPresentAsOpenAI(args, model.release_date), where model is the model that actually served the request (fallback/reroute included). Stamps normalized: true only on OpenAI-shaped output. ⚠️ One unconditional change here: the legacy response.normalize path (Anthropic-block normalization) no longer stamps normalized: true — the field now exclusively means "OpenAI shape".
  • PuterAIController.ts — all four wire-compat routes (/openai/chat/completions, completions, responses, /anthropic/messages) pin normalize: false, so the release-date cutoff can never change what those routes' own translators receive.
  • types.tsICompleteArguments.normalize?: boolean documented as the tri-state.

Per-provider equalization

  • MistralAiProvider.ts — (1) Gated on the policy: non-streaming remap of the SDK's camelCase dialect — finishReason → mapped finish_reason (unmapped values pass through), toolCalls → OpenAI tool_calls with stringified arguments, and magistral chunked content arrays split into string content + reasoning. Without the flag / pre-cutoff, callers keep seeing the SDK-native keys. (2) Unconditional: the streaming chunk splitter — chunked delta.content is split into text and reasoning deltas regardless of policy (previously the raw array reached addText and stream consumers got stringified objects; streamed chunk types must not depend on a response-format flag). (3) New opt-in custom.prompt_mode → SDK promptMode passthrough.
  • OpenAIUtil.js handle_completion_outputnormalizeReasoningContent(ret) now runs for every chat-completions provider, unconditionally: reasoning_content (DeepSeek wire convention) is renamed to reasoning and the vendor key deleted on all non-streaming responses. This removes a field from existing models and is not behind the normalize policy — the one field removal, called out in the docs.
  • OpenAIUtil.js handle_completion_output_responses_api — three unconditional changes to existing OpenAI/Azure Responses-API models: finish_reason is now 'tool_calls' on tool turns (was hardcoded 'stop'); reasoning: null is replaced by string-or-absent (populated from reasoning summaries); reasoning_details (item id, encrypted_content, summary) is added for replay.
  • Streaming (Responses API)create_chat_stream_handler_responses_api now routes response.reasoning_summary_text.delta events to the reasoning channel (blank line between summary parts). Unconditional and additive: those deltas were previously dropped.

Reasoning round-trip / replay

  • ClaudeProvider.ts — inbound messages carrying round-tripped artifacts are handled: thinking/redacted_thinking blocks from reasoning_details are spliced back ahead of the content array (Anthropic requires leading thinking blocks with intact signatures), and output-only fields (reasoning, refusal, reasoning_details) are stripped — on a copy, because the driver reuses one messages array across fallback attempts. The copy-on-write invariant is pinned by tests at both the provider level (frozen objects, double-send) and the driver level (fallback attempts share the array by reference).
  • OpenAIUtil.js process_input_messages_responses_api — the mirror for the Responses API: reasoning_details items expand into standalone reasoning input items preceding their message, output-only fields (reasoning, refusal, normalized) stripped via destructuring rebind (caller objects untouched).

SDK surface (puter-js)

  • modules/ai/index.jsputer.ai.normalize = true default. Note the asymmetry: SDK-wide true means "the release-date policy" (nothing sent on the wire), while per-call true means "force OpenAI shape". Only SDK-wide false rides the wire (normalize: false on every call). Per-call always wins.
  • modules/ai/chat.js — the forwarding logic above; modules/ai/types.js — JSDoc for normalize, finish_reason, normalized, reasoning, reasoning_details, refusal.

Catalog backfill (billing-bearing — all unconditional)

  • Alibaba (alibaba/models.ts, +~1,000 lines): 46 new entries — Qwen Plus/Max/Flash dated snapshots, Qwen3 2507 open-weight refreshes, coder, VL, Omni, MT translation, plus 8 third-party models hosted on Model Studio (GLM-5.1/5.2/5.2-fast-preview, DeepSeek V3.2/V4 Flash/Pro + dated variants, Kimi K2.7 Code) at Alibaba's own rates. 4 dated-snapshot aliases added to existing entries.
  • OpenAI: 12 dated-snapshot aliases on existing entries + 2 new entries (chat-latest, gpt-4o-2024-11-20).
  • Gemini: rolling gemini-{flash,flash-lite,pro}-latest aliases (×2 spellings each) pinned to the newest catalog entry, + 2 free Gemma 4 entries.
  • DeepSeek: deepseek-v4-flash-vision-exp. Mistral: zai-glm-5-2 (Mistral-hosted) + a mistral-medium-3.5 spelling alias. Z.AI: glm-5.3-flash at list price (50% promo deliberately not encoded).

Backfill rules enforced: nothing vendor-deprecated (15 already-written OpenAI entries were culled against the deprecations page), nothing without a price confirmed on the vendor's official pricing page, nothing absent from the vendor's live /models listing, and nothing that fails a live routing probe (one entry removed on that rule).

Docs

  • chat.md — a full "Response Normalization" section: the flag, the cutoff, the SDK-wide switch, the served-model caveat, the OpenRouter live-release-date caveat, streaming unaffected, replay guidance, and a before/after table for the four unconditional reasoning-field changes. chatresponse.mdcontent string-or-array, finish_reason mapping table (open set), normalized, reasoning, reasoning_details, usage. The Claude cache-control example (docs + playground) switched to normalize: true + string content.

Tests

New: providerConsistency.test.ts (23 providers × text/tool/reasoning against mocked upstreams — the equalized-contract matrix), normalizeToOpenAI.test.ts (21 cases), plus additions to the driver (12, including full precedence coverage and the fallback shared-array invariant), Claude (4), Mistral (6), OpenAIUtil (9), puter-js unit (7), and one live API-suite test. Controller tests grew normalize: false assertions.

Type/lint cleanup

Annotation-only changes across ~12 files (casts, callback params, return types, context.ts typing the pre-existing driverName key). Two slightly more than formatting: ICompleteArguments.text/verbosity retyped from 'concise' | 'detailed' to 'low' | 'medium' | 'high' (matches what vendors accept; compile-time only), and the xAI STT Blob construction now copies the buffer into a Uint8Array (type fix; runtime copy, bytes identical).

Behavior contract after this PR

Precedence for non-streaming results, resolved in shouldPresentAsOpenAI (used identically by driver and Mistral):

  1. Per-call normalize: true ⇒ OpenAI shape, any model. normalize: false ⇒ provider-native, any model.
  2. Else, legacy response.normalize (internal) ⇒ Anthropic-block normalization, suppressing the OpenAI presentation (and no longer stamping normalized).
  3. Else, release-date cutoff: served model's release_date >= 2026-09-01 ⇒ OpenAI shape; earlier, missing, or unparseable ⇒ native. The served model decides — a fallback or content-block reroute to a date-less model yields native shape even if the requested model was post-cutoff.

SDK: puter.ai.normalize defaults to true = "the policy" (nothing on the wire); only false is transmitted; per-call overrides both ways.

Normalized responses: string-or-null message.content, message.tool_calls, refusal: null, optional reasoning / reasoning_details, mapped finish_reason (end_turn/stop_sequencestop, max_tokenslength, tool_usetool_calls, refusalcontent_filter, unmapped values verbatim — treat as an open set), normalized: true, usage untouched (key names remain provider-specific by design). Native responses are byte-for-byte what the provider path returned.

Streaming is never touched by normalize (the driver returns stream results before the coercion branch, test-pinned); the two unconditional streaming changes (Mistral chunk split, Responses reasoning-summary routing) exist precisely so chunk types don't depend on the flag.

Provider spec audit

The precondition for this work was to establish which providers already speak
the OpenAI format and which are out of spec. Result: 20 of 23 needed no change
at all, because they funnel through OpenAIUtil.handle_completion_output and
already return choices[0]-shaped results.

The table has 21 rows for the 23 entries the conformance matrix drives: openai and
azure each register two providers, one per wire API.

Provider Wire API Endpoint Verdict
openai Chat Completions + Responses SDK default (api.openai.com/v1) native OpenAI
azure Chat Completions + Responses per-deployment config.apiURL native OpenAI
meta Chat Completions https://api.meta.ai/v1 vendor's own OpenAI-compatible layer — conforms, no change needed
gemini Chat Completions https://generativelanguage.googleapis.com/v1beta/openai/ Google's OpenAI compat endpoint — conforms
alibaba Chat Completions https://dashscope-intl.aliyuncs.com/compatible-mode/v1 conforms
byteplus Chat Completions https://ark.ap-southeast.bytepluses.com/api/v3 conforms
deepseek Chat Completions https://api.deepseek.com conforms; reasoning_content renamed centrally
groq Chat Completions groq-sdk default (api.groq.com/openai/v1) conforms
hoonify Chat Completions https://api.hoonify.ai/v1 conforms
infron Chat Completions https://llm.onerouter.pro/v1 conforms
minimax Chat Completions https://api.minimax.io/v1 conforms
moonshot Chat Completions https://api.moonshot.ai/v1 conforms
neuralwatt Chat Completions https://api.neuralwatt.com/v1 conforms
ollama Chat Completions http://localhost:11434/v1 (configurable) OpenAI compat endpoint — conforms
openrouter Chat Completions https://openrouter.ai/api/v1 conforms; note its release_date is live-derived (see above)
together Chat Completions together-ai SDK default (api.together.xyz/v1) conforms
xai Chat Completions https://api.x.ai/v1 conforms
zai Chat Completions https://api.z.ai/api/paas/v4 conforms
mistral Mistral SDK @mistralai/mistralai default (api.mistral.ai/v1) out of spec: camelCase dialect (remapped behind the policy gate) + chunked reasoning content (split into text/reasoning chunks, ungated)
claude Anthropic Messages @anthropic-ai/sdk default (api.anthropic.com/v1) out of spec: content blocks + stop_reason — the coercer's whole reason for existing
fake-chat internal fixture none Anthropic-shaped by design (drives the e2e normalize test)

Meta was the suspected edge case going in. It is not one: Meta
ships its own OpenAI-compatible layer at api.meta.ai/v1 and the provider
drives it through the standard OpenAI SDK, which is why Meta appears nowhere in
this diff. Anthropic's own OpenAI-compat beta endpoint was evaluated and
rejected for Claude — routing through it would cost prompt-caching control,
compaction, and the cache-token usage detail metering bills on. Hence a central
coercer rather than per-provider endpoint swaps.

Risk notes

  • Unconditional changes to existing models (the review-critical list): (1) reasoning_contentreasoning rename on all non-streaming chat-completions responses — a field removal; (2) Responses-API finish_reason now tool_calls on tool turns; (3) Responses-API reasoning null → absent-or-string ('reasoning' in msg changes); (4) Responses-API reasoning_details added; (5) Mistral streaming chunk split; (6) Responses streaming reasoning-summary deltas now emitted; (7) legacy response.normalize path no longer sets normalized: true. All are documented and bug-fix-shaped, and each was explicitly approved during review rather than shipped silently — but none is behind the policy, so sign off on them consciously.
  • Cross-provider id collisions in the catalog (verified against compareModelPreference): the Alibaba-hosted third-party entries reuse the direct vendors' ids, so they merge into shared routing buckets — which is this repo's deliberate vendor+reseller fallback design. Concretely: deepseek-v4-flash/-pro and kimi-k2.7-code keep the direct vendor primary on the cost tiebreak (Alibaba becomes a more expensive fallback route); glm-5.2 ties Z.AI exactly (same price, same id length), so the serving provider falls to provider-registration order. Billing-neutral either way — both routes serve the same upstream model at the same price — but reviewers should confirm the fallback-bucket effect is wanted.
  • Pricing-bearing entries (~53): tiered Alibaba models are encoded at the base (≤32K-input) tier — large-context calls under-bill; DeepSeek time-of-day models are pinned at the busy rate (off-peak over-bills, deliberate); Z.AI glm-5.3-flash ignores the current 50% promo (over-bills, deliberate); qwen3-30b-a3b thinking-mode output rate not modeled (under-bills); Gemma 4 entries are $0 per Google's pricing page.
  • Alias/date oddities to double-check against vendor docs: gpt-5.4-nano release 2026-03-19 vs alias ...-2026-03-17 (pre-existing quirk); qwen3.6-flash release 2026-04-27 vs alias ...-2026-04-16; qwen3.7-plus release 2026-06-02 vs alias ...-2026-05-26 (snapshot-name-vs-GA-date is plausible, but worth confirming). New OpenAI entry with bare id chat-latest — verified live: the upstream API accepts exactly that id.
  • Rolling aliases pinned to fixed entries: gemini-*-latest and qwen-plus-latest are hot-swapped upstream; Puter's pinning goes stale (and mis-prices) the day the vendor moves them — a maintenance obligation, acknowledged in comments.
  • OpenRouter caveat (documented, not new code): OpenRouter release dates come from its live listing, so a model newly listed there on/after the cutoff flips to normalized-by-default without any Puter change.
  • Docs asymmetry: SDK-wide normalize = true means "policy", per-call true means "force" — correct and tested, but easy for users to misread.
  • Vendor-deprecation flags left for maintainers: pre-existing o1, o3-mini, o4-mini entries are now vendor-deprecated (shutdown 2026-10-23) — untouched here since they predate this work; the pre-existing deepseek-v4-flash/-pro prices no longer match DeepSeek's current pricing page (billing drift found during review, not introduced or changed here); gemma-4 emits its own <thought> markup inline in content (model behavior).

Known gaps

  • reasoning_details is not portable across vendors. The artifacts are
    provider-specific (an Anthropic signature means nothing to OpenAI), so replay
    only works against the model that produced them. Documented, not enforced — a
    fallback reroute mid-conversation cannot replay a reasoning turn.
  • Unrelated fix carried along: the xAI speech-to-text provider now copies
    each upload buffer into a Uint8Array before wrapping it in a Blob. Node's
    Buffer does not satisfy the DOM BlobPart signature, so the old code was a
    type error and a latent bug; the fix costs one extra in-memory copy per audio
    upload. Not part of the normalization work — flagged so reviewers aren't
    surprised by a non-chat driver in the diff.
  • ChatProvider no longer stubs checkModeration. A no-op stub added
    during the type cleanup has been removed: it had no subclasses and no
    production callers, and it was suppressing a genuine design smell — the base
    class does not implement IChatProvider. That pre-existing TS2420 is back
    and is covered by origin/main's baseline entry, so the gate passes.
  • tools/typecheck-baseline.json is untouched by this branch. An earlier
    commit had removed 31 entries (49 error instances) as a side effect of fixing
    the AI-code type errors they suppressed; that removal has been reverted. The
    errors stay fixed in the source, so npm run typecheck prints "48 baselined
    error(s) fixed" and exits 0. Regenerating the ledger is the maintainer's call.
  • A reviewer decision was left open, not settled. renameReasoningContent
    deletes message.reasoning_content even when message.reasoning was already
    present, so a provider sending both keys loses the vendor one. That looks like
    data loss, and changing it was requested during review — but
    BytePlusProvider.test.ts and ZAIProvider.test.ts, both pre-existing on
    main, pin the current behavior deliberately, with a fixture value named
    'should-be-dropped'. Honouring the request would mean rewriting two tests
    this branch does not own and changing BytePlus/ZAI behavior nobody asked to
    change, so it was left alone. A reviewer should decide: keep the drop as those
    tests specify, or change it and update them.
  • The copy-on-write guarantee is narrower than it sounds. Both reasoning-replay
    input paths now strip output-only fields from a copy, so a replayed message's
    thinking signature survives a fallback retry. But the driver still normalizes
    every inbound message in place before any provider runs
    (normalize_single_message in utils/Messages.js, pre-existing), rewriting
    string content into [{type:'text'}] blocks on the caller's own objects. The
    fix protects the reasoning artifacts, not whole-message immutability, and the
    new driver-level test says so explicitly.
  • Beyond the original request: the shipped Claude cache-control playground
    example (src/docs/src/playground/examples/ai-claude-cache-control.html) was
    switched to normalize: true with string content. Nobody asked for it; it
    was changed because its content[0].text reading goes stale the moment
    post-cutoff Claude models normalize by default. Flagged so the diff's
    user-facing surface is not a surprise.
  • Doc claims corrected late in review, listed because they were wrong in an
    earlier push of this branch: content is string-or-null on normalized
    responses (tool-only turns have no text), reasoning_details is not scoped to
    normalized responses, finish_reason is an open set, and the release-date
    trigger depends on the serving provider's own dates.
  • Cosmetic: repo-wide ESLint reports 37 errors / 61 warnings across 20 files (5 of them stale copies under .claude/worktrees/), concentrated in src/backend/stores. Zero land in any file this branch touches; every file in this diff is clean.

Test results

All runs on the final rebased tree (14 commits on top of origin/main), fresh builds first, exit codes read directly — no output filtering.

Automated suites

Suite Result Exit
src/puter-js build (webpack) compiled 0
npm run build:workerLib compiled 0
Backend (npm run test:backend, 274 files) 6,598 passed, 26 skipped 0
puter.js API suite (npm run test:puterjs — same tests on node, browser/Playwright, and workerd/miniflare, against the built bundle) 1,889 passed, 146 skipped 0
GUI (vitest --config src/gui/vitest.config.js) 418 passed 0
Typecheck gate (npm run typecheck, read-only) no new errors (35 known, baselined; tools/typecheck-baseline.json byte-identical to main) 0
puter.js declarations (npm run check:puterjs:types) generate + type-check cleanly 0
ESLint over src/backend/drivers/ai-chat 0 errors (13 pre-existing any warnings) 0

New coverage added by this branch: ~90 tests — the 23-provider × text/tool/reasoning conformance matrix (providerConsistency.test.ts, driven with normalize: true so the equalized contract is what's asserted), 25 coercer unit tests, 13 driver precedence/cutoff tests, reasoning round-trip tests on both input paths (thinking-signature preservation sabotage-verified: the test fails if the copy-on-write is reverted), a driver-level two-attempt fallback test that exercises the real fallback loop, Mistral chunk-splitting tests (both paths, both normalize values asserted identical for streams), Responses-API reasoning tests, SDK flag-precedence tests, and an e2e normalize: true case that runs in all three API-suite runners.

Live end-to-end verification (local Docker deployment, real provider APIs)

The branch was built into the self-hosted Docker image and exercised over HTTP through Caddy against real vendor keys:

Normalization matrix — 25/25 meaningful checks passed:

  • Claude: native blocks with flag unset; normalize: true → string content / finish_reason: stop / normalized: true / refusal: null; normalize: false → native; tools → tool_calls with stringified arguments, finish_reason: tool_calls, content: null on the tool-only turn; extended thinking → reasoning string + reasoning_details with signatures; replaying the normalized message verbatim was accepted by the live Anthropic API and answered correctly — the round-trip contract holds against the real vendor.
  • Mistral: policy gate verified live (native keeps finishReason; force-on maps, cleans camelCase, stamps normalized).
  • OpenAI: idempotent under force-on; normalized: true stamp only when asked.
  • Magistral (Mistral reasoning model): the live API currently inlines reasoning as prose (no ThinkChunk content, no markers) — separable-thinking handling is unit-pinned for when Mistral serves the chunked shape, and the new custom.prompt_mode passthrough was verified delivered end-to-end (Mistral's own 3051 mode-not-enabled error returns through the stack).

Catalog backfill — 58/58 surviving additions answered a live prompt through the deployment (all 46 Alibaba entries, both gemma-4 models, chat-latest, gpt-4o-2024-11-20, zai-glm-5-2, deepseek-v4-flash-vision-exp, glm-5.3-flash, plus spot-checked snapshot/rolling aliases). The one entry that failed live routing (qwen3-vl-flash-2025-10-15, upstream 400 twice) was removed. A post-edit re-audit of every keyed provider's /models endpoint shows zero stale catalog entries across all 11 verifiable catalogs.

Numbers

  • 48 files changed, +4,143 / −101.
  • ~53 new catalog entries (46 Alibaba — incl. 8 third-party-hosted — 2 OpenAI, 2 Gemini/Gemma, 1 DeepSeek, 1 Mistral-hosted, 1 Z.AI) and ~23 new aliases on existing entries (12 OpenAI dated, 4 Alibaba dated, 6 Gemini rolling, 1 Mistral spelling).
  • ~63 new test blocks across 8 files, plus normalize: false assertions added to 4 existing controller tests; the provider-consistency matrix expands to ~67 runs over 23 providers.
  • 2 new source modules (normalizeToOpenAI.ts at 227 lines, plus its 309-line test); largest single file change is alibaba/models.ts (+1,031).

404oops and others added 14 commits August 28, 2026 01:08
Introduces a new `normalize` option for chat completions, plus release-date based default normalization (post-2026-09-01) to coerce provider-native outputs into a consistent OpenAI-style shape. Adds shared normalization utilities, extensive driver/provider consistency tests, and controller safeguards that pin provider-native output where route-specific translators are used. Also wires the option through puter.js (`chat` options and `puter.ai.normalize` default), updates AI/chat response docs and examples, and resolves related TypeScript typing issues reflected in the typecheck baseline.
Adds the upstream Hoonify provider (feat #3499, merged after this
branch was cut) to the cross-provider output conformance suite. It
speaks the OpenAI chat-completions dialect and conforms out of the box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s through

Closes the reasoning gaps left open by the normalization work.

Reasoning replay. The coercer dropped Anthropic thinking-block signatures and
the Responses handler dropped reasoning item ids/encrypted_content, so a
normalized reasoning turn could not be replayed — Anthropic rejects an
extended-thinking tool-use continuation whose thinking blocks lost their
signature. Both now ride `message.reasoning_details` verbatim, and both input
paths accept them back: ClaudeProvider splices the blocks ahead of the content
(Anthropic requires them to lead), and the Responses input processor expands
them into standalone `reasoning` items. Output-only fields a replayed message
carries (`reasoning`, `refusal`, `normalized`) are stripped on both paths,
since neither upstream accepts them. The docs caveat recommending
`normalize: false` for agentic Claude loops is gone; it is no longer true.

Unmapped stop reasons. chatresponse.md promised a vendor `finish_reason` with
no OpenAI analog "passes through unchanged" — true for the Mistral remap, false
for the Anthropic coercer, which discarded it. Anthropic's `pause_turn` means
"continue this turn", so flattening it to `stop` destroyed the signal. The
coercer now passes unmapped values through verbatim, matching both the doc and
the Mistral path, and the docs gain the full Anthropic stop-reason table.

Reasoning summaries. Multi-part summaries joined with '' instead of a blank
line, and the streaming Responses path emitted no reasoning at all;
`response.reasoning_summary_text.delta` now feeds the same `reasoning` stream
channel the chat-completions handler uses.

Types. `text?: string & { verbosity?: ... }` was an uninhabitable intersection
(providers read `text?.verbosity` as an object), and the verbosity enum was
`'concise' | 'detailed'` where OpenAI accepts `'low' | 'medium' | 'high'`.
Adds `reasoning`, `reasoning_details`, and `refusal` to the SDK ChatMessage
typedef.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rules from the post-normalization review: don't silently change what existing
models return, verify doc claims against every code path they cover, record
self-disclosed defects in the PR draft, finish with a fresh build + suites, and
check git stash before concluding an edit was lost. Plus the standing
typecheck-baseline no-go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`magistral-*` returns `message.content` as a ContentChunk[] rather than a
string, with the thinking text nested one level deeper inside `thinking`
chunks. The camelCase remap did not touch it, so a non-streamed magistral
response reached the caller as an array with no `reasoning` — the one case
left where a provider did not produce the equalized shape this branch
promises. Streaming had the matching bug: the chunk array was handed to
addText, which would have stringified it into the text stream.

Both paths now split chunked content into a string `content` plus a
`reasoning` string, joining multiple thinking chunks with a blank line as the
Responses handler and the Anthropic coercer do. The streaming fix rides the
existing Mistral-only `chunk_but_like_actually` hook, so no new deviation is
introduced.

The conformance matrix had no Mistral reasoning fixture, which is why it
missed this; it now has one carrying chunked content, verified to fail
without the flattening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit 6b4f5b0 dropped 31 entries (49 error instances) from
tools/typecheck-baseline.json as a side effect of fixing the AI-code type
errors they suppressed. The file is a ledger the maintainer owns, and shrinking
it inside a feature PR ships a CI-gate change nobody asked for.

Restoring is free: tools/typecheck.mjs fails only on *regressions*
(count > baseline), so stale entries are non-fatal — the gate prints "49
baselined error(s) fixed. Run npm run typecheck:update to lock that in." and
exits 0. Nothing else in the repo reads the file, vitest included, and
tsconfig.build.json's noCheck:true means emit is unaffected. The errors stay
fixed in the source either way; only the ledger's own bookkeeping is deferred
to whenever the maintainer chooses to regenerate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing 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>
…rrect four doc claims

Acts on a second triple-check audit.

`normalized` means one thing. The driver stamped it on both the OpenAI-shape
path and the legacy `response.normalize` path, which converts toward Anthropic
blocks — so a caller could get `normalized: true` alongside array content, and
the flag told them nothing they could branch on. The legacy branch no longer
sets it. That branch is reachable only by a direct driver call with
`response.normalize` and no `normalize`; the four wire routes pin
`normalize: false`, which skips it.

Mistral streaming, split by concern. Flattening a reasoning model's chunked
`delta.content` to a string is a correctness floor and stays ungated — the
shared handler passes the value straight to `addText`, so an array reaches the
caller as stringified objects. Splitting the thinking text out into a
`reasoning` delta is the dialect change and now sits behind the policy gate
like the non-streaming remap. On the native path the thinking text is kept
inline rather than dropped.

Mistral `finishReason` is deleted only once its value carried over. The delete
ran unconditionally, so a non-string `finishReason` with no `finish_reason`
left the choice with no finish reason at all.

Four doc claims corrected against the code paths they cover: `content` is
string-or-null on normalized responses (tool-only turns carry no text, and the
`// always a string` example comment was wrong); `reasoning_details` is not
scoped to normalized responses, since Responses models emit it either way; and
the release-date rule depends on the serving provider's own dates — OpenRouter
derives them from its live API, so models newly listed there from 2026-09-01
normalize by default.

Adds the test the copy-on-write fix was actually for: one messages array sent
through two sequential calls, asserting the caller's array is untouched and
both attempts carried the thinking signature. That is the fallback hazard; the
harness wires one provider per model, so the fallback loop itself cannot be
driven from a provider test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…conditionally

Third triple-check round. Ten findings were put through independent skeptics
first; six did not survive — pre-existing on main, inert, or resting on a false
premise — and are not acted on here.

Mistral streamed thinking now goes to `reasoning` on every path. The previous
commit gated the split, which made this the only place in the repo where
chain-of-thought reached the visible text channel, and made Mistral the only
provider whose streamed chunk *types* depend on a response-format flag. Every
other reasoning path routes thinking to `reasoning` unconditionally —
ClaudeProvider's thinking_delta, the DeepSeek/OpenRouter rename, and this
branch's own Responses summary-delta handler. Removing the gate restores that
uniformity and makes the documented promise that streaming is unaffected by
normalization true again; the two opposing tests collapse into one that runs the
same fixture with and without `normalize` and asserts identical event streams.

Docs stop claiming older models are unchanged. Four reasoning fields were made
consistent across all models, ungated, and one of them removes a field: on
non-streaming responses `message.reasoning_content` is now `message.reasoning`.
chat.md gains a table naming all four so a caller reading `reasoning_content`
learns why it disappeared, instead of reading that nothing changed for them.

Adds the driver-level fallback test. Writing it surfaced that the invariant it
was meant to assert is false and always was: the driver rewrites string
`content` into text blocks in place on the caller's own messages
(`normalize_single_message`, pre-existing) before any provider runs. The test
now asserts what is true and load-bearing — both attempts receive the same array
reference, and the reasoning artifacts survive attempt 1 so attempt 2 can still
replay them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lback test

The comment claimed the fallback loop cannot be driven from a test — false
since ChatCompletionDriver.test.ts gained a two-attempt fallback test that
drives the actual loop. Say where that test lives instead.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
…-date policy

Resolves the flag's semantics per the requester: the SDK-wide flag is
two-state and outward-facing — `true` (the default) means the release-date
policy applies, so models released on or after 2026-09-01 are normalized and
older models keep their vendor-native shape; `false` disables normalization
for every call. The tri-state lives only on the per-call `normalize` option:
unset defers to the SDK-wide flag, `true` forces the OpenAI shape regardless
of release date, `false` forces the vendor-native shape.

On the wire nothing changes: the default sends no `normalize` key (the
server's policy resolution decides), and only an explicit SDK-wide `false`
or a per-call value is transmitted. Consequences worth noting: assigning
`true` now restores the date policy rather than force-normalizing everything
(previously there was no boolean that meant "give me the policy back"), and
SDK-wide force-all is no longer expressible — force-all is per-call only, as
specified.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
… thinking

Live-probing magistral-small-latest showed the model inlines its reasoning as
answer prose in a flat string — no ThinkChunk content, no markers, nothing a
client can separate. Mistral's chunked thinking shape is requested via
`prompt_mode: 'reasoning'`, which the provider previously dropped on the
floor: there was no way to even ask for it.

`custom.prompt_mode` now forwards to the SDK's `promptMode`, following the
BytePlus custom-params precedent. Opt-in rather than a default because the
API rejects the mode where the account/model lacks it ('Reasoning prompt
mode is not enabled for this model', code 3051) — verified end-to-end: the
3051 travels back through the stack, which also proves the parameter is
delivered. The moment Mistral enables the mode, the ThinkChunk content flows
into the existing splitter and comes out as `message.reasoning` and
`reasoning` stream chunks with no further changes.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
A live audit of every keyed provider's /models endpoint against the hardcoded
catalogs found no stale entries but large gaps. This backfills them under four
rules: nothing vendor-deprecated, nothing without a price confirmed on the
vendor's official pricing page (each entry's source was recorded during
review), nothing absent from the live /models listing, and nothing that fails
a live routing probe.

Added: 46 Alibaba entries (qwen3/3.5/3.7/3.8 families, VL/omni/MT lines, and
Model Studio's hosted GLM/DeepSeek/Kimi third-party models) plus 9 dated
aliases; OpenAI chat-latest and gpt-4o-2024-11-20 plus 16 snapshot aliases;
Gemini gemma-4-31b-it and gemma-4-26b-a4b-it (vendor-documented free tier)
plus rolling -latest aliases; Mistral-hosted zai-glm-5-2 and a
mistral-medium-3.5 alias; deepseek-v4-flash-vision-exp; glm-5.3-flash.

Culled by the rules: 15 vendor-deprecated OpenAI entries (the 3.5/4/4-turbo
legacy line, gpt-4o-2024-05-13, o1-pro, four chat-latest predecessors, the
5.x codex line — deprecations page, most shut down 2026-10-23) and dated
aliases onto the deprecated o1/o3-mini/o4-mini; qwen3-vl-flash-2025-10-15
(live routing probe returned upstream 400 twice). Tiered Alibaba prices are
encoded at the base tier and busy-hour rates where time-of-day priced, noted
in comments.

Every surviving addition was verified end-to-end through a local deployment:
58/58 answered a live prompt, including all 46 Alibaba entries and every
spot-checked alias.

Not changed, flagged for maintainers: pre-existing o1, o3-mini and o4-mini
entries are now vendor-deprecated (shutdown 2026-10-23); the pre-existing
deepseek-v4-flash/-pro prices no longer match DeepSeek's current pricing
page; gemma-4 emits its own <thought> markup inline in content.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 27, 2026 23:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
🔵 Lines 60.84%
⬆️ +0.01%
3960 / 6508
🔵 Statements 59.56%
⬆️ +0.01%
4172 / 7004
🔵 Functions 61.35%
🟰 ±0%
759 / 1237
🔵 Branches 53.62%
⬆️ +0.02%
2559 / 4772
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/ai/chat.js 87.8%
⬇️ -1.38%
79.54%
⬇️ -0.46%
60%
🟰 ±0%
92.3%
⬇️ -1.98%
226, 240, 245, 258, 259
src/puter-js/src/modules/ai/index.js 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #204 for commit 5c3d433 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.72%
⬆️ +0.02%
24873 / 26537
🔵 Statements 91.97%
⬆️ +0.01%
26872 / 29217
🔵 Functions 89.99%
⬆️ +0.06%
4343 / 4826
🔵 Branches 80.59%
⬆️ +0.02%
18220 / 22606
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/controllers/puterai/PuterAIController.ts 97.89%
🟰 ±0%
91.76%
🟰 ±0%
100%
🟰 ±0%
98.67%
🟰 ±0%
1106-1107, 1198-1200, 1296, 1301, 1332-1334, 1675, 1715, 1753, 1813
src/backend/core/context.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/ChatCompletionDriver.ts 96.34%
⬆️ +0.04%
89.52%
⬇️ -0.15%
97.43%
🟰 ±0%
97.66%
⬆️ +0.03%
204-207, 330, 360, 374, 416-418, 447-451, 485, 500, 530, 590, 732, 756, 762, 769, 1427
src/backend/drivers/ai-chat/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/FakeChatProvider.ts 90%
⬆️ +3.34%
76%
🟰 ±0%
77.77%
⬆️ +11.11%
89.65%
⬆️ +3.45%
32, 76, 135
src/backend/drivers/ai-chat/providers/alibaba/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts 100%
🟰 ±0%
87.03%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts 100%
🟰 ±0%
93.18%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts 100%
🟰 ±0%
90.9%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts 79.18%
⬆️ +0.58%
64.96%
⬆️ +0.71%
81.25%
⬆️ +1.25%
79.8%
⬆️ +0.86%
56-59, 65, 83, 86-87, 111-123, 127-131, 152-155, 237-242, 299-301, 330-335, 343-345, 351-354, 358, 369, 373-377, 476, 480
src/backend/drivers/ai-chat/providers/deepseek/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/gemini/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts 94.05%
⬇️ -5.95%
81.11%
⬇️ -14.34%
100%
🟰 ±0%
97.77%
⬇️ -2.23%
8, 9, 11, 21-22, 183
src/backend/drivers/ai-chat/providers/mistral/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts 100%
🟰 ±0%
88.09%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts 92.15%
🟰 ±0%
77.77%
🟰 ±0%
92.3%
🟰 ±0%
91.83%
🟰 ±0%
71-78, 89
src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts 95.83%
🟰 ±0%
78.4%
🟰 ±0%
91.66%
🟰 ±0%
95.74%
🟰 ±0%
80, 96
src/backend/drivers/ai-chat/providers/openai/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts 95.83%
🟰 ±0%
81.66%
⬆️ +3.33%
100%
🟰 ±0%
97.18%
🟰 ±0%
11, 169, 172
src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts 100%
🟰 ±0%
91.66%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts 100%
🟰 ±0%
91.48%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/providers/zai/models.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/utils/OpenAIUtil.js 91.8%
⬆️ +0.02%
80.6%
⬆️ +0.88%
88.23%
⬆️ +2.03%
93.47%
⬆️ +0.59%
124, 166, 176-184, 206, 209, 331, 332, 338-340, 372, 387-389, 468-472, 554, 657, 676
src/backend/drivers/ai-chat/utils/compaction.js 100%
🟰 ±0%
96.42%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts 98.21% 87.27% 100% 100% 163
src/backend/drivers/ai-ocr/OCRDriver.ts 91.48%
🟰 ±0%
74.38%
🟰 ±0%
94.44%
🟰 ±0%
93.07%
🟰 ±0%
68-71, 101-103, 108-110, 113-115, 138-139, 186-189, 269, 279
src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts 95.5%
🟰 ±0%
84.04%
🟰 ±0%
100%
🟰 ±0%
95.4%
🟰 ±0%
120-124, 132-134, 161-163, 172-174
src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts 100%
🟰 ±0%
91.48%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts 98.43%
🟰 ±0%
94.33%
🟰 ±0%
87.5%
🟰 ±0%
100%
🟰 ±0%
201
src/backend/drivers/ai-tts/TTSDriver.ts 91.07%
🟰 ±0%
90.36%
🟰 ±0%
100%
🟰 ±0%
92.59%
🟰 ±0%
98, 118, 163-167, 240, 261-264, 283-286, 307-310, 333-336, 355-358, 377-380
Generated in workflow #861 for commit 5c3d433 by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants