Skip to content

Commit 0f17a38

Browse files
tombeckenhamclaudeautofix-ci[bot]AlemTuzlak
authored
refactor(openai-base): rename, adopt openai SDK, decouple ai-openrouter (TanStack#545)
* refactor: migrate ai-groq + ai-openrouter onto @tanstack/openai-base (TanStack#543) Adds protected `callChatCompletion`, `callChatCompletionStream`, `extractReasoning`, and `transformStructuredOutput` hooks to `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the shared stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates. ai-groq drops `groq-sdk` in favour of the OpenAI SDK pointed at api.groq.com/openai/v1; ai-openrouter keeps `@openrouter/sdk` via hook overrides. ai-ollama remains on BaseTextAdapter (native API has a different wire format). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * fix(openai-base, ai-openrouter, ai): silent failures in chat-completions migration Addresses regressions and pre-existing silent failures surfaced by reviewing TanStack#545: - `@tanstack/ai`: `toRunErrorPayload` normalizes `AbortError` / `APIUserAbortError` / `RequestAbortedError` to `{ code: 'aborted' }` so consumers can discriminate user-initiated cancellation without matching provider-specific message strings. - `@tanstack/openai-base`: `structuredOutput` throws a distinct "response contained no content" error instead of cascading into a misleading JSON-parse error on an empty string; the post-loop tool-args drain now logs malformed JSON via `logger.errors` so truncated streams don't silently invoke tools with `{}`. - `@tanstack/ai-openrouter`: `stream_options.include_usage` is camelCased to `includeUsage` (Zod was silently stripping it, leaving `RUN_FINISHED.usage` always undefined on streaming); mid-stream `chunk.error.code` is stringified so provider codes (401/429/500) survive `toRunErrorPayload`; assistant `toolCalls[].function.arguments` is stringified to match the SDK's `string` contract; `convertMessage` now mirrors the base's fail-loud guards (empty user content, unsupported content parts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-openrouter, openai-base): OpenRouter Responses (beta) adapter Adds OpenRouterResponsesTextAdapter on top of @tanstack/openai-base's responses-text base, mirroring the chat-completions migration in TanStack#543. - openai-base: protected `callResponse` / `callResponseStream` hooks on OpenAICompatibleResponsesTextAdapter parallel to the existing `callChatCompletion*` hooks, so providers whose SDK has a different call shape can override without forking processStreamChunks. Re-exports the OpenAI Responses SDK types subclasses need. - ai-openrouter: new OpenRouterResponsesTextAdapter routing through `client.beta.responses.send({ responsesRequest })`. Emits the SDK's camelCase TS shape directly via overrides of convertMessagesToInput / convertContentPartToInput / mapOptionsToRequest, annotated with `Pick<ResponsesRequest, ...>` so future SDK field renames break the build instead of silently producing Zod-stripped wire payloads. Bridges inbound stream events camel -> snake so the base's processStreamChunks reads documented fields unchanged. - Function tools only in v1; webSearchTool() throws with a clear error pointing at the chat-completions adapter. - Folds in the silent-failure lessons from 0171b18 (stringified error codes, stringified tool-call arguments, fail-loud on empty user content). - E2E: new `openrouter-responses` provider slot in feature-support / test-matrix / providers / types / api.summarize, reusing aimock's native `/v1/responses` handler. - 10 new unit tests covering request mapping (snake -> camel for top-level fields, function-call camelCasing in input[], variant suffix), stream-event bridge (text deltas, function-call lifecycle, response.failed, top-level error code stringification), webSearchTool() rejection, and SDK constructor wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ai-groq): remove dead unused message-param types Removes `validateTextProviderOptions` (no-op stub never called) and the chain of `ChatCompletion*MessageParam` / `ChatCompletionContentPart*` / `ChatCompletionMessageToolCall` types that were only referenced by it. Unblocks the root `test:knip` CI check. None of the removed exports are re-exported from the package's public `src/index.ts`, so this is internal-only cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai-openrouter): pass UNKNOWN-fallback events through verbatim The OpenRouter SDK's stream-event schema is built with Speakeasy's discriminated-union helper, which on a per-variant parse failure falls back to `{ raw, type: 'UNKNOWN', isUnknown: true }` rather than throwing. This happens whenever an upstream omits an "optional-looking" required field — notably `sequence_number` and `logprobs` on text/reasoning delta events, which aimock-served fixtures don't include. Before this fix the adapter's switch hit the default branch for UNKNOWN events and emitted them with no usable `type`, so the base's processStreamChunks ignored them silently — the run terminated as `RUN_FINISHED { finishReason: 'stop' }` with no content. The `raw` payload preserved on the fallback is the original wire-shape event in snake_case, which is exactly what processStreamChunks reads. Re-emit it verbatim. Real-OpenRouter responses still flow through the existing camel -> snake bridge because their events include the required fields and parse cleanly. Unblocks the openrouter-responses E2E suite: 11 affected tests now pass locally against aimock; before this commit they all timed out empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(adapters): remove asChunk casts, enforce satisfies StreamChunk Replaces ~200 sites of `asChunk({ type: 'X', ... })` (a `Record<string, unknown> as unknown as StreamChunk` cast) with `({ type: EventType.X, ... }) satisfies StreamChunk` so the type system validates AG-UI event shape at every emission. The cast was bypassing TypeScript's string-enum nominal typing and masking a cluster of spec deviations now fixed: - RUN_STARTED / RUN_FINISHED in openai-base (chat-completions + responses) and all three summarize adapters were missing the AG-UI-required `threadId`. Threading `options.threadId ?? generateId(this.name)` through `aguiState` (matching the existing Gemini/Anthropic pattern) fixes it. - RUN_ERROR emissions carried a non-existent `runId` field and the deprecated nested `error: { message, code }` form instead of AG-UI's top-level `message`/`code`. Both forms now coexist (deprecated kept for back-compat) and `runId` is dropped — verified no consumer reads it (chat-client.ts:404 only reads runId on RUN_FINISHED). - STEP_STARTED / STEP_FINISHED in responses-text.ts were passing only the deprecated `stepId` alias; AG-UI requires `stepName`. Now passes both. - `finishReason` in chat-completions-text.ts was typed as `string`, dropping below the AG-UI vocabulary. Widened `RunFinishedEvent.finishReason` in `@tanstack/ai` to include OpenAI's `'function_call'` so it narrows cleanly. responses-text.ts maps Responses-API `'max_output_tokens'` → `'length'` and passes `'content_filter'` through. - Per-event timestamps. AG-UI spec: "Optional timestamp indicating when the event was created." Previously a single `const timestamp = Date.now()` was captured at run start and reused on every emission across the eight adapters; each chunk now uses `Date.now()` inline. `@tanstack/ai/tests/test-utils.ts` `ev.*` builders are typed to return precise event members via `satisfies StreamChunk`; the loose `chunk(type, fields)` factory is preserved as a documented escape hatch for tests that deliberately construct off-spec fixtures. ai-client tests no longer declare a local `asChunk`. ai-groq's `processStreamChunks` override signature is updated to include the new `threadId` field on `aguiState`. Out of scope, flagged for follow-up: - Framework tests (ai-react / ai-svelte / ai-vue) with inline string-literal chunk arrays — their test directories aren't currently type-checked, so they compile despite being off-spec. - Summarize adapters omit TEXT_MESSAGE_START / TEXT_MESSAGE_END around content emissions (separate AG-UI lifecycle gap). Verified: pnpm -r test:types, test:lib, test:eslint, test:build all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai-openrouter): preserve assistant/tool message content fidelity The chat adapter's convertMessage JSON-stringified Array<ContentPart> assistant content (so a multi-part assistant turn would round-trip as the literal JSON of the parts instead of joined text) and emitted `content: undefined` for tool-call-only assistants where the OpenAI Chat Completions contract documents `null`. Use the base's extractTextContent + emit `null` for the tool-call-only case so the override matches the chat-completions base. The Responses adapter's convertMessagesToInput tool branch had the same shape — JSON.stringify(message.content) fed the raw ContentPart shape into function_call_output.output for structured tool results. Use extractTextContent there too. Regression tests assert (a) array-shaped assistant content extracts to joined text rather than JSON, and (b) tool-call-only assistant content emits `null` rather than `undefined`. * fix(ai-groq): correct ChatCompletionNamedToolChoice shape The interface declared a single capitalized `Function` key with no `type` discriminator. The OpenAI / Groq Chat Completions wire format for a named tool_choice is `{ type: 'function', function: { name } }`. Construct a literal against the old type and the SDK's Zod schema would either reject it or treat tool_choice as unset. No production code constructs this type literally yet — only the `ChatCompletionToolChoiceOption` union in the same file uses it — so fixing the shape now is a no-op at runtime but locks the type to the correct contract going forward. * test(ai-groq): reset pendingMockCreate between tests The module-level pendingMockCreate is only cleared inside applyPendingMock when a factory call consumes it. Tests in the first describe block instantiate the adapter without calling setupMockSdkClient first, so a leaked value from a prior test would inject a stale mock into a later adapter. Reset in beforeEach for deterministic ordering regardless of test-runner permutation. * test(e2e): route OpenRouter summarize through createOpenRouterSummarize The feature-support matrix advertises summarize / summarize-stream for both `openrouter` and `openrouter-responses`, but the factories silently substituted `createOpenaiSummarize` against the OpenAI base URL — exercising the OpenAI adapter while reporting OpenRouter coverage. Wire `createOpenRouterSummarize` (a thin wrapper over the OpenRouter chat adapter, used for both rows since the summarize endpoint is chat-completions-only) against the LLMOCK base so the matrix's claim is actually verified. * chore(ai-openrouter): declare zod as peer dependency Sibling adapters (`ai-openai`, `ai-groq`, `ai-grok`) all declare zod as a peerDependency so a consumer that passes a Zod tool schema gets a single zod instance shared with this adapter. Without the peerDep, strict installs (pnpm `strict-peer-dependencies`, yarn berry pnp) can end up with two zod copies — one transitive via `@openrouter/sdk` or `@tanstack/ai`, one direct — and `instanceof ZodType` checks then fail across the boundary. * fix(ai-groq): drop spurious timestamp field from processStreamChunks override The Groq subclass declared its aguiState parameter with an extra `timestamp: number` field that does not exist on the base class's aguiState type. TypeScript's bivariant method-parameter checks let the wider type pass typecheck, but at runtime the body never reads `timestamp` and the field is never populated by the base, so any caller (or future override) that relied on the declared shape would observe `undefined`. Realign the override's parameter type with the base. * fix(ai-openrouter): stringify error.code on response.failed events The chunk-level 'error' branch in adaptOpenRouterResponsesStreamEvents already stringifies provider codes so they survive toRunErrorPayload's string-only code filter, but the parallel response.failed / response.incomplete path went through toSnakeResponseResult which forwarded `r.error.code` raw. A provider that returned a numeric code (401/429/500/…) on a terminal failure event would lose it on the way through to RUN_ERROR. Mirror the chunk-level stringification inside toSnakeResponseResult and add a regression test for response.failed with a numeric error.code. * fix(ai-openrouter): default image data URI mime type to octet-stream When a base64 image source has no mimeType the override produced a literal `data:undefined;base64,...` URI that the upstream rejects as invalid. The chat-completions base defaults to `application/octet-stream` for exactly this case; mirror the same defaulting in the OpenRouter convertContentPart override. Regression test asserts the data URI no longer contains the literal `undefined`. * fix(openai-base): stop processing chunks after top-level error event The Responses adapter's processStreamChunks marked `runFinishedEmitted` on a top-level chunk.type === 'error' to prevent the synthetic terminal block from firing, but it did not return from the for-await loop. Any subsequent chunks the upstream delivered after a terminal error event (a stray output_text.delta, an output_item.done, etc.) would continue to emit lifecycle events past RUN_ERROR, violating the 'RUN_ERROR is terminal' contract. Mirror the response.failed / response.incomplete branches above: return after yielding RUN_ERROR. Regression test covers the case where the upstream continues delivering chunks after a top-level error event and asserts no further chunks reach the consumer. * fix(openai-base, ai-openrouter): route Responses structuredOutput through transformStructuredOutput hook The Responses base hard-coded transformNullsToUndefined on parsed structured-output JSON, leaving no hook for subclasses to opt out. The changeset's promise of 'transformStructuredOutput for subclasses (like OpenRouter) that preserve nulls in structured output instead of converting them to undefined' was therefore only fulfilled on the chat-completions surface — the matching Responses adapter would silently strip nulls regardless of provider intent. Add the transformStructuredOutput protected hook on OpenAICompatibleResponsesTextAdapter mirroring the chat-completions base's design, and override it as a no-op on OpenRouterResponsesTextAdapter so OpenRouter callers see null sentinels round-trip identically across the two adapter surfaces. Regression test asserts a structuredOutput response containing `nickname: null` round-trips as null (not undefined) through the Responses adapter. * fix(ai-openrouter): extract text from array-shaped tool message content The chat-completions adapter's convertMessage tool branch still JSON-stringified Array<ContentPart> tool message content, so a tool result delivered as structured parts (e.g. [{type:'text', content: '"temp":'}, {type:'text', content:'72'}]) reached the model as the literal JSON of the parts rather than the joined textual result. The parallel responses adapter override was fixed earlier; this mirrors the same fix on the chat-completions path so both surfaces handle structured tool content identically. Regression test feeds a structured tool result and asserts the wire payload's tool message content is the joined text without any '"type":"text"' leakage. * chore(ai-groq): declare @tanstack/ai as workspace devDependency Every sibling adapter (ai-openai, ai-grok, ai-openrouter, ai-anthropic, ai-gemini, ai-fal, ai-ollama) explicitly lists `@tanstack/ai: workspace:*` under devDependencies in addition to declaring it as a peer. ai-groq omitted the devDep entry, so resolution worked only via pnpm's autoInstallPeers behaviour — toggling that off (strict installs, some yarn berry configs) would silently break ai-groq while every other adapter kept working. Add the dev dep for parity. * fix(ai-openrouter): route audio URLs to text fallback on chat-completions The chat-completions OpenRouter adapter's convertContentPart for audio unconditionally emitted `{ type: 'input_audio', inputAudio: { data, format: 'mp3' } }` — but `data` is supposed to be base64. A URL-sourced audio part therefore shipped the literal URL string into the base64 slot, which the upstream rejects (or worse, treats as garbage audio bytes). The Responses adapter already handles this by routing URL audio through `input_file` (where the URL belongs); chat-completions has no `input_file` shape on this surface, so mirror the existing document fallback: emit a text reference noting the URL. Callers needing real audio URL support should use the Responses adapter. * docs(ai-groq): correct message-types header — Groq SDK was dropped The header comment claimed these types "mirror the Groq SDK types", but the migration dropped the groq-sdk dependency entirely in favour of pointing the OpenAI SDK at Groq's /openai/v1 base URL. The file is now the source of truth for Groq-specific wire fields (compound tools, citation/service-tier provider options, …), not a mirror of an external SDK. Update the header to reflect the post-migration role. * fix(ai-openrouter): reject inline document data on chat-completions The chat-completions convertContentPart 'document' branch unconditionally returned `{ type: 'text', text: `[Document: ${part.source.value}]` }`. For URL sources that's a reasonable degradation. For data sources, `part.source.value` is the raw base64 payload — a multi-megabyte document would be inlined into the prompt verbatim, blowing the context window and leaking the document content as plaintext bytes. Branch on `part.source.type`: URL sources keep the text-reference fallback, data sources throw with a clear error pointing the caller at the Responses adapter (which has proper `input_file` support for inline document data). Mirrors the audio URL/data branching added in the prior round. * refactor: rename @tanstack/openai-base → @tanstack/openai-compatible The "base" name implied this package tracked OpenAI's product roadmap. In reality it implements two OpenAI-shaped wire-format protocols (Chat Completions, Responses) that multiple providers ship — OpenRouter, Groq, Grok, vLLM, SGLang, Together. "OpenAI-compatible" is the industry term for this family (cf. Vercel's @ai-sdk/openai-compatible, LiteLLM, BentoML, Lightning AI). OpenRouter's beta Responses endpoint routes to Claude, Gemini, and other underlying models, confirming that /v1/responses (like /v1/chat/completions) is a multi-provider wire format rather than OpenAI-only — so the Responses adapter stays alongside Chat Completions in the renamed package. Pure rename, no behavior change. Class names (OpenAICompatibleChatCompletionsTextAdapter, OpenAICompatibleResponsesTextAdapter, …) and protected hook contracts are unchanged. Consumer packages (ai-openai, ai-openrouter, ai-groq, ai-grok) only update internal import paths; public API is unchanged. @tanstack/openai-base@0.2.x remains published on npm for any pinned lockfile references but will receive no further updates. A README in the renamed package documents the protocol-vs-product contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor: rename @tanstack/openai-compatible → @tanstack/ai-openai-compatible Match the `ai-*` prefix convention used by every other package in the AI subnamespace (ai-utils, ai-openai, ai-anthropic, ai-client, ai-react, …). `@tanstack/` is a flat namespace shared across all TanStack products (Query, Router, Table, Form, AI, …), so `@tanstack/openai-compatible` alone gives no signal about which TanStack product it belongs to. Pure rename of the rename in 06d3d8c; no behavior change. Directory `packages/typescript/openai-compatible` → `packages/typescript/ai-openai-compatible`, package.json `name` field, consumer dependency declarations, TypeScript imports, README, CHANGELOG header, both changesets all updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ai-openai-compatible, ai-openrouter): explain the protocol-vs-product framing Rewrite the `@tanstack/ai-openai-compatible` README to lead with the thinking — OpenAI authored two wire formats (Chat Completions and Responses) that many vendors implement, so the package contains the shared logic for talking to any server that speaks one of those wire formats, not "the base for OpenAI." Add a side-by-side table for "what goes here vs in @tanstack/ai-openai" and a contributor rule of thumb ("a field belongs here only if at least two compatible providers support it"). Expand the leading docstrings on both OpenRouter text adapters to explicitly answer "why does this extend from @tanstack/ai-openai-compatible?" — OpenRouter implements OpenAI's wire formats verbatim (Chat Completions natively, Responses as a beta routing layer that fans out to Claude/Gemini/etc.), so the shared base lets us inherit ~1k LOC of stream accumulation, partial-JSON buffering, AG-UI lifecycle, and structured-output coercion rather than duplicating it. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * docs(adapters/openrouter): add Chat Completions vs Responses (beta) section The OpenRouter adapter package now ships two adapters (`openRouterText` for /v1/chat/completions and `openRouterResponsesText` for /v1/responses beta). Document the difference for consumers: both route to any underlying model in the catalogue (Anthropic, Google, Meta, …); the wire format describes the client → OpenRouter call, not which provider answers. Add a side-by-side table, a basic example for the Responses adapter, and beta caveats (no branded server-tools yet; prefer the chat-completions adapter if in doubt). No mention of the internal shared-base package — that's an implementation detail consumers don't need to track. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(ai-openai-compatible): narrow to chat/responses; decouple from openai SDK Make `OpenAICompatibleChatCompletionsTextAdapter` and `OpenAICompatibleResponsesTextAdapter` abstract. Subclasses now own SDK client construction and implement the `callChatCompletion*` / `callResponse*` hooks. The base never imports `openai` at runtime — only as types — so `dist/esm/*.js` is openai-free and the package's `openai` dep moves to optional `peerDependencies` + `devDependencies`. Delete image/tts/transcription/video bases (single-user; only ai-openai extended them, so they're now standalone classes there). Move summarize to `@tanstack/ai` core as `ChatStreamSummarizeAdapter` — it's protocol-agnostic, wraps any `ChatStreamCapable`. Provider-specific `*SummarizeAdapter` classes deleted, replaced by thin factory functions returning `ChatStreamSummarizeAdapter` directly. ai-grok duplicates its image adapter standalone (~150 LOC; shared base wasn't worth the indirection for thin SDK wrappers). Also fix the round-3 double `TOOL_CALL_END` regression in the Responses adapter: `function_call_arguments.done` now gates on `!metadata.ended` so the output_item.done backfill path can't emit a duplicate close. Regression test added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor(ai): rename chat-stream-wrapper to chat-stream-summarize * refactor(summarize): unify provider summarize adapters on chat-stream wrapper - Migrate anthropic, gemini, ollama, openrouter summarize adapters to thin factories over ChatStreamSummarizeAdapter, matching the openai/grok pattern. Drops ~600 lines of duplicated streaming/error/usage handling. - Thread modelOptions from SummarizationOptions through the activity layer and into the wrapped text adapter's chatStream so provider-specific knobs (cache control, plugins, safety settings, tuning params) reach the wire. - Add InferTextProviderOptions<TAdapter> helper to extract per-model provider options from a text adapter's ~types. - Drop bespoke XSummarizeProviderOptions interfaces from all 6 providers; provider summarize types now resolve to the text adapter's per-model options shape, giving accurate IntelliSense for modelOptions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor(ai-openai-compatible): vendor wire types; drop openai dep Move the OpenAI wire-format types from `import type ... from 'openai'` to local `src/types/{chat-completions,responses,tools}.ts` — hand-written minimal interfaces covering only the fields the base reads/writes. - Drop `openai` from `@tanstack/ai-openai-compatible`'s peerDependencies, peerDependenciesMeta, and devDependencies. - Drop `openai` from `@tanstack/ai-openrouter`'s devDependencies (it was only there to satisfy type leakage from `@tanstack/ai-openai-compatible`). - Update `@tanstack/ai-openai`'s text adapter overrides to use the local protocol types and cast at the SDK boundary, keeping variance compat with the now-local base. Net effect: `@tanstack/ai-openai-compatible`'s emitted .d.ts contains zero `from 'openai'` references. End-users installing `@tanstack/ai-openrouter` (or any future protocol-compatible adapter that doesn't itself use the openai SDK at runtime) no longer pull `openai` into their dependency tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor(openai-base): rename, adopt openai SDK, decouple ai-openrouter - Rename @tanstack/ai-openai-compatible → @tanstack/openai-base. Vendored wire types deleted; the base now imports from openai/resources/* directly. - Drop the abstract callChatCompletion* / callResponse* hooks. Base takes a pre-built OpenAI client in its constructor and calls the SDK itself; subclasses (ai-openai, ai-grok, ai-groq) just pass a configured client. - Decouple @tanstack/ai-openrouter from openai-base entirely. The two adapters now extend BaseTextAdapter directly, inline their stream processors, and read OpenRouter's camelCase types natively — dropping ~300 LOC of snake↔camel reshaping that existed only to satisfy the inherited base. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * Corrected package versions * refactor(adapters): address PR review — jsdoc, casts, zod, finishReason Apply review feedback from PR TanStack#545: - Restore JSDoc removed during the openai-base media/summarize refactor (26 blocks across ai-openai, ai-grok, ai-anthropic, ai-gemini, ai-openrouter adapters). Only restore where the documented symbol still exists post-refactor; skip JSDoc tied to removed classes / provider- options interfaces. - Drop `as` casts on stream chunks in ai-openrouter (responses-text.ts output_item.{added,done} handlers, response.completed handler) by typing `NormalizedStreamEvent.item` as the SDK's `OutputItems` discriminated union and `.response` as `Partial<OpenResponsesResult>`. Discriminated- union narrowing now works without bypass. - Drop request-builder casts in ai-openrouter/{text,responses-text}.ts: `as InputsItem`, `as ChatMessages`, `as ChatContentItems`, `as ResponsesRequest['tools' | 'text' | 'input']`, `as Omit<ChatRequest, 'stream'>`, `as Record<string, any>` on modelOptions spread. - Drop SDK-return casts `as AsyncIterable<StreamEvents>` / `as AsyncIterable<ChatStreamChunk>` — `EventStream<T>` already is `AsyncIterable<T>`. - Drop `tool as Tool` in the webSearchTool guard — `Tool<any, any, any>` is assignable to `Tool` directly. - Remove `'function_call'` from RunFinishedEvent.finishReason union. Normalize OpenAI's legacy v1 function_call termination to `tool_calls` inside chat-completions-text — the SDK-vocabulary value no longer leaks into the public AG-UI type. - Drop redundant `satisfies StreamChunk` from yield/array-element sites across adapters and ai-client tests. The contextual type from `AsyncIterable<StreamChunk>` / `Array<StreamChunk>` already validates every emission; the suffix added no extra safety. - Annotate the `ev.*` builders in ai/tests/test-utils.ts with explicit return types (RunStartedEvent, TextMessageStartEvent, …) instead of `satisfies StreamChunk`. Each builder now returns the precise event variant rather than the wide union. - Drop zod from ai-openrouter peerDependencies — no source imports zod; it's only used in tests, where it stays as a devDep. (OpenRouter SDK already declares zod as a regular dep, so transitive consumers aren't affected.) - Clean up mid-PR rename leftovers: stale "openai-compatible adapters" jsdoc in ai-openai/utils/client.ts, and `'openai-compatible'` / `'openai-compatible-responses'` default-name strings in the openai-base test subclasses (now `openai-base` / `openai-base-responses`). * refactor(ai-openrouter): drop residual chunk casts in responses-text Extend the no-`as`-on-chunks principle (PR TanStack#545 review) to five sibling sites missed by 44db925: - `response.created/in_progress/incomplete/failed` model + error/incomplete capture (lines 462, 491): `NormalizedStreamEvent.response` is already `Partial<OpenResponsesResult>`, so the duck-type casts were redundant. Read `chunk.response?.{model,error,incompleteDetails}` directly. - `response.content_part.{added,done}` (lines 629, 673): type `NormalizedStreamEvent.part` as the SDK's `ContentPartAddedEventPart` discriminated union (`ResponseOutputText | ReasoningTextContent | OpenAIResponsesRefusalContent | Unknown<'type'>`) and switch `handleContentPart` to narrow on `part.type`. The previous `text?` / `refusal?` duck-type allowed unsafe access on unknown parts. - `response.completed` `outputItems.some(item.type === 'function_call')` (line 998): the array element type is already `OutputItems`, line 921 above already narrows without a cast — leftover. Behaviourally identical; verified by openrouter unit tests (80/80) and e2e suite (30/30). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor(ai): tighten summarize TProviderOptions to Record<string, unknown> Unify the generic constraint and default across the summarize surface: - `SummarizationOptions`: `extends object = Record<string, any>` → `extends Record<string, unknown> = Record<string, unknown>` - `SummarizeAdapter` / `BaseSummarizeAdapter`: constraint tightened from `extends object` to `extends Record<string, unknown>` (default was already `Record<string, unknown>`) - `ChatStreamSummarizeAdapter`: `extends object = Record<string, any>` → `extends Record<string, unknown> = Record<string, unknown>` - `activities/summarize/index.ts` instantiation sites: literal `<string, object>` → `<string, Record<string, unknown>>` Removes the three-way default split (`object` / `Record<string, any>` / `Record<string, unknown>`) that lived inside the summarize folder, and forces unparameterised consumers to narrow before indexed access. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * docs(openai-base): rewrite README; consolidate summarize changeset README: - Drop the broken "Renamed from" note (referenced an outdated state). - Drop the Vercel `@ai-sdk/openai-compatible` industry-term paragraph and the surrounding "Why this package exists" rationale that explained the prior rename — package is back to `openai-base`, that history is moot. - Reframe TL;DR around the actual current contract: "providers that drive the official `openai` SDK against a different `baseURL`" (only ai-openai, ai-grok, ai-groq remain on the base after this PR). - Remove ai-openrouter from subclass lists and the architecture diagram — it was decoupled in this PR and now extends `BaseTextAdapter` directly. - Rewrite the hooks section: the old `callChatCompletion(Stream)` / `callResponse(Stream)` abstract methods were removed in 7aff8b1; the base now takes a pre-built `OpenAI` client and calls `client.chat.completions.create` / `client.responses.create` itself. Document `convertMessage`, `mapOptionsToRequest`, `extractReasoning`, `transformStructuredOutput`, `makeStructuredOutputCompatible`, `processStreamChunks`, `extractTextFromResponse` as the real surface. - Update "build a new provider" example to point at ai-grok / ai-groq. Changesets: - Replace the narrow `summarize-tighten-provider-options-generic.md` (which only covered 6d99fad) with a comprehensive `summarize-unify-on-chat-stream-wrapper.md` that also covers e0dcb77 (provider summarize unification on `ChatStreamSummarizeAdapter`, `modelOptions` plumbing fix in the activity layer, new `InferTextProviderOptions<TAdapter>` helper, and removal of the bespoke `*SummarizeProviderOptions` interfaces from 6 provider packages). Adds patch bumps for ai-anthropic / ai-gemini / ai-ollama which were previously uncovered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): revert summarize TProviderOptions constraint to extends object 6d99fad tightened the constraint from `extends object` to `extends Record<string, unknown>` alongside aligning the default. The default change was correct; the constraint change broke vite build / DTS emit for ai-openai, ai-anthropic, ai-gemini, ai-grok, ai-ollama. Their summarize factories instantiate `ChatStreamSummarizeAdapter<TModel, InferTextProviderOptions<XTextAdapter<TModel>>>`, and the inferred per-model option shapes (`OpenAIBaseOptions & OpenAIReasoningOptions & ...` etc.) are typed interfaces with named optional fields and no string index signature — TS won't assign them to `Record<string, unknown>`. Revert just the constraint to `extends object`, keep the default at `Record<string, unknown>`. Restores the pattern `BaseSummarizeAdapter` already had on main, now applied uniformly across all four declarations. The 7 activity-layer `<string, Record<string, unknown>>` instantiations in summarize/index.ts revert to `<string, object>`, and the two `summarizeOptions: SummarizationOptions = {...}` literals are explicitly annotated `SummarizationOptions<object>` so the modelOptions: object | undefined destructured from the activity-layer options assigns correctly. Changeset paragraph 5 amended to describe what actually shipped (default-aligned, constraint preserved). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes * refactor(ai): drop the SummarizationOptions<object> annotation noise Let TS infer summarizeOptions from the literal in runSummarize / runStreamingSummarize. The contextual check happens at the adapter.summarize(...) / adapter.summarizeStream(...) call site against the adapter's own typed signature, which is sufficient — the explicit local annotation was just visual noise. Drops the unused SummarizationOptions import too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai, ai-client): replace removed StreamChunk casts with typed event data Follow-up to the cast removals: where the old `as unknown as StreamChunk` casts were hiding real data-shape issues, fix the data instead of re-introducing the bypass. Source: - ai-client/src/connection-adapters.ts: synth RUN_FINISHED chunk now includes `threadId` (the cast had been hiding the missing required field). Use `EventType.RUN_FINISHED` / `EventType.RUN_ERROR` literals. Test helpers (`chunk()` / `makeChunk()` / `sc()`): - Replace string-typed `(type: string, fields) => StreamChunk` (which needed `as unknown as StreamChunk` to lie) with a generic `<T extends StreamChunk['type']>(type: T, fields?) => Extract<StreamChunk, { type: T }>`. One typed cast remains inside each helper at the boundary; no `as unknown` casts. - `sc()` retyped as a typed identity (`<T extends StreamChunk>(c: T) => T`) so inline literal narrowing flows from the `type` discriminant. Inline literals + missing fields fixed at call sites: - All `chunk('X', ...)` → `chunk(EventType.X, ...)` across stream-processor.test.ts (42), strip-to-spec-middleware.test.ts (4), chat.test.ts (1). - All `type: 'X'` inside test object literals → `type: EventType.X` across stream-to-response, custom-events-integration, extend-adapter, stream-processor (the four MESSAGES_SNAPSHOT inline literals). - extend-adapter mock RUN_FINISHED gained `threadId`. - custom-events-integration TOOL_CALL_START gained `toolCallName` (the cast had been hiding the missing required field). - stream-processor MESSAGES_SNAPSHOT bodies (the two whose casts were removed) converted from TanStack `UIMessage` shape (parts/createdAt) to AG-UI `Message` shape (id/role/content) — the processor casts internally, but the upstream MessagesSnapshotEvent.messages field requires AG-UI Message. types.ts is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: apply automated fixes --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
1 parent a4ddbbf commit 0f17a38

103 files changed

Lines changed: 8121 additions & 5227 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@tanstack/openai-base': minor
3+
'@tanstack/ai-openai': patch
4+
'@tanstack/ai-grok': patch
5+
'@tanstack/ai-groq': patch
6+
'@tanstack/ai-openrouter': patch
7+
---
8+
9+
Decouple `@tanstack/ai-openrouter` from the shared OpenAI base, and collapse the base into a thinner shim over the `openai` SDK.
10+
11+
Three changes that ship together:
12+
13+
**1. Rename `@tanstack/ai-openai-compatible``@tanstack/openai-base`.** The previous name implied a multi-vendor protocol surface. After ai-openrouter is decoupled (see below), the only remaining consumers (`ai-openai`, `ai-grok`, `ai-groq`) all back onto the `openai` SDK with a different `baseURL` — "base" describes that role accurately. Imports change:
14+
15+
```diff
16+
- import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/ai-openai-compatible'
17+
+ import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
18+
- import { OpenAICompatibleResponsesTextAdapter } from '@tanstack/ai-openai-compatible'
19+
+ import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base'
20+
```
21+
22+
`@tanstack/ai-openai-compatible@0.2.x` remains published for anyone with a pinned lockfile reference but will receive no further updates.
23+
24+
**2. `@tanstack/openai-base` adopts the `openai` SDK directly.** The previous package vendored ~720 LOC of hand-written wire-format types (`ChatCompletion`, `ResponseStreamEvent`, etc.) and exposed abstract `callChatCompletion*` / `callResponse*` hooks subclasses had to implement. Both are gone:
25+
26+
- The base now depends on `openai` again and imports types directly from `openai/resources/...`. The vendored `src/types/` directory is removed; consumers that imported wire types from the package (e.g. `import type { ResponseInput } from '@tanstack/ai-openai-compatible'`) should now import from the openai SDK.
27+
- The abstract SDK-call methods are removed. The base constructor takes a pre-built `OpenAI` client (`new OpenAIBaseChatCompletionsTextAdapter(model, name, openaiClient)`) and calls `client.chat.completions.create` / `client.responses.create` itself. Subclasses (`ai-openai`, `ai-grok`, `ai-groq`) now just construct the SDK with their provider-specific `baseURL` and pass it to `super``callChatCompletion*` / `callResponse*` overrides go away.
28+
29+
The other extension hooks (`extractReasoning`, `extractTextFromResponse`, `processStreamChunks`, `makeStructuredOutputCompatible`, `transformStructuredOutput`, `mapOptionsToRequest`, `convertMessage`) remain. Groq's `processStreamChunks` and `makeStructuredOutputCompatible` overrides (for `x_groq.usage` promotion and Groq's structured-output schema quirks) are unchanged.
30+
31+
**3. Decouple `@tanstack/ai-openrouter` from the OpenAI base entirely.** OpenRouter ships its own SDK (`@openrouter/sdk`) with a camelCase shape, so inheriting from the OpenAI-shaped base forced a snake_case ↔ camelCase round-trip on every request and stream event. ai-openrouter now extends `BaseTextAdapter` directly and inlines its own stream processors (`OpenRouterTextAdapter` for chat-completions, `OpenRouterResponsesTextAdapter` for the Responses beta), reading OpenRouter's camelCase types natively. The `@tanstack/openai-base` and `openai` dependencies are removed from ai-openrouter; only `@openrouter/sdk`, `@tanstack/ai`, and `@tanstack/ai-utils` remain.
32+
33+
Public API is unchanged: `openRouterText`, `openRouterResponsesText`, `createOpenRouterText`, `createOpenRouterResponsesText`, the OpenRouter tool factories, provider routing surface (`provider`, `models`, `plugins`, `variant`, `transforms`), app attribution headers (`httpReferer`, `appTitle`), `:variant` model suffixing, `RequestAbortedError` propagation, and the OpenRouter-specific structured-output null-preservation all behave the same. The ~300 LOC of inbound/outbound shape converters (`toOpenRouterRequest`, `toChatCompletion`, `adaptOpenRouterStreamChunks`, `toSnakeResponseResult`, …) are gone.
34+
35+
`ai-ollama` remains on `BaseTextAdapter` directly — its native API uses a different wire format from Chat Completions and was never on the shared base.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-openrouter': patch
3+
---
4+
5+
Internal: drop the remaining duck-typed `as { ... }` casts on stream chunks in `OpenRouterResponsesTextAdapter`. Five sites (`response.created/in_progress/incomplete/failed` model + error capture, `response.content_part.added/done` payload, and the `response.completed` function-call detection) now narrow via the SDK's discriminated unions directly. Behaviourally identical; reduces the chance of a SDK type rename silently slipping past us.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@tanstack/ai': patch
3+
'@tanstack/ai-anthropic': patch
4+
'@tanstack/ai-gemini': patch
5+
'@tanstack/ai-grok': patch
6+
'@tanstack/ai-ollama': patch
7+
'@tanstack/ai-openai': patch
8+
'@tanstack/ai-openrouter': patch
9+
---
10+
11+
Unify the summarize subsystem on a shared chat-stream wrapper, plumb `modelOptions` through end-to-end, and tighten the `TProviderOptions` generic.
12+
13+
**Provider summarize adapters now share one implementation.** Anthropic, Gemini, Ollama, and OpenRouter previously each shipped a bespoke 200–300 LOC summarize adapter that re-implemented streaming, error handling, usage accounting, and chunk assembly on top of their text adapter. They now construct a `ChatStreamSummarizeAdapter` (formerly `ChatStreamWrapperAdapter`, renamed and exported from `@tanstack/ai/activities`) wrapping their own text adapter, matching the existing OpenAI/Grok pattern. Removes ~600 LOC of duplicated logic across the six providers and ensures behavioural parity.
14+
15+
**`SummarizationOptions.modelOptions` now reaches the wire.** Previously the activity layer (`runSummarize` / `runStreamingSummarize`) silently dropped `modelOptions` when building the internal `SummarizationOptions` it forwarded to the adapter. Provider-specific knobs (Anthropic cache control, OpenRouter plugins, Gemini safety settings, Groq tuning params, …) now flow through correctly.
16+
17+
**Provider summarize types resolve from the wrapped text adapter.** Each provider previously shipped a bespoke `XSummarizeProviderOptions` interface (a partial copy of its text provider options). Those interfaces are removed; summarize provider options are now inferred from the text adapter's `~types` via the new `InferTextProviderOptions<TAdapter>` helper exported from `@tanstack/ai/activities`. IntelliSense for `modelOptions` on `summarize({ adapter: openai('gpt-4o'), … })` now matches what `chat({ adapter: openai('gpt-4o'), … })` would show.
18+
19+
**`SummarizeAdapter` interface methods are now generic in `TProviderOptions`.** `summarize` and `summarizeStream` previously took `SummarizationOptions` (defaulted, so `modelOptions` was effectively `Record<string, any>` regardless of the adapter's typed shape). They now take `SummarizationOptions<TProviderOptions>`, threading the class's `TProviderOptions` generic through. Source-compatible for callers that didn't specify the generic; type-tighter for implementers and downstream consumers.
20+
21+
**Default aligned across the summarize surface.** `SummarizationOptions`, `SummarizeAdapter`, `BaseSummarizeAdapter`, and `ChatStreamSummarizeAdapter` previously had a mixed `Record<string, any>` / `Record<string, unknown>` / `object` set of defaults for `TProviderOptions`. They now uniformly default to `Record<string, unknown>` so unparameterised consumers narrow before indexed access on `modelOptions`. The `extends object` constraint is unchanged — per-model typed interfaces (e.g. `OpenAIBaseOptions & OpenAIReasoningOptions & ...`) inferred via `InferTextProviderOptions<TAdapter>` continue to satisfy it without needing a string index signature. No public-surface signature change for callers that supply a concrete provider-options shape (every shipping adapter does).
22+
23+
Bespoke `*SummarizeProviderOptions` interfaces (e.g. `OpenAISummarizeProviderOptions`, `AnthropicSummarizeProviderOptions`, `GeminiSummarizeProviderOptions`, `OllamaSummarizeProviderOptions`, `OpenRouterSummarizeProviderOptions`) are removed from the provider packages' public exports. Consumers who imported them should switch to inferring the type from the adapter (`InferTextProviderOptions<typeof adapter>`) or remove the explicit annotation (it'll be inferred from the adapter argument).

docs/adapters/openrouter.md

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,17 @@ const stream = chat({
3535
## Configuration
3636

3737
```typescript
38-
import { createOpenRouter, type OpenRouterConfig } from "@tanstack/ai-openrouter";
39-
40-
const config: OpenRouterConfig = {
41-
apiKey: process.env.OPENROUTER_API_KEY!,
42-
baseURL: "https://openrouter.ai/api/v1", // Optional
43-
httpReferer: "https://your-app.com", // Optional, for rankings
44-
xTitle: "Your App Name", // Optional, for rankings
45-
};
46-
47-
const adapter = createOpenRouter(config.apiKey, config);
38+
import { createOpenRouterText } from "@tanstack/ai-openrouter";
39+
40+
const adapter = createOpenRouterText(
41+
"openai/gpt-5",
42+
process.env.OPENROUTER_API_KEY!,
43+
{
44+
serverURL: "https://openrouter.ai/api/v1", // Optional
45+
httpReferer: "https://your-app.com", // Optional, for rankings
46+
appTitle: "Your App Name", // Optional, for rankings
47+
},
48+
);
4849
```
4950

5051
## Available Models
@@ -122,18 +123,52 @@ OpenRouter can automatically route requests to the best available provider:
122123
```typescript
123124
const stream = chat({
124125
adapter: openRouterText("openrouter/auto"),
125-
messages,
126-
providerOptions: {
126+
messages,
127+
modelOptions: {
127128
models: [
128129
"openai/gpt-4o",
129130
"anthropic/claude-3.5-sonnet",
130131
"google/gemini-pro",
131132
],
132-
route: "fallback", // Use fallback if primary fails
133133
},
134134
});
135135
```
136-
136+
137+
## Chat Completions vs Responses (beta)
138+
139+
OpenRouter exposes two OpenAI-compatible wire formats, and the adapter
140+
package ships one of each:
141+
142+
| Adapter | Endpoint | Status | When to use |
143+
| -------------------------- | ------------------------- | -------- | ---------------------------------------------------------------------------- |
144+
| `openRouterText` | `/v1/chat/completions` | Stable | Default for almost everything. Broadest model + tool support. |
145+
| `openRouterResponsesText` | `/v1/responses` | Beta | OpenAI Responses-shaped request/response; richer multi-turn state on OpenAI-style models. |
146+
147+
Both adapters route to any underlying model OpenRouter supports
148+
(`anthropic/...`, `google/...`, `meta-llama/...`, etc.) — the wire format
149+
describes how your client talks to OpenRouter, not which provider answers.
150+
`/v1/responses` is OpenAI's newer API surface; OpenRouter implements it so
151+
clients that prefer that wire format can use it across the same 300+
152+
model catalogue.
153+
154+
```typescript
155+
import { chat } from "@tanstack/ai";
156+
import { openRouterResponsesText } from "@tanstack/ai-openrouter";
157+
158+
const stream = chat({
159+
adapter: openRouterResponsesText("anthropic/claude-sonnet-4.5"),
160+
messages: [{ role: "user", content: "Hello!" }],
161+
});
162+
```
163+
164+
Caveats while the Responses adapter is in beta:
165+
166+
- Function tools are supported; OpenRouter's branded server-tools (web
167+
search, file search, …) are not yet wired through this path — use
168+
`openRouterText` if you need those.
169+
- If in doubt, prefer `openRouterText`. The Chat Completions endpoint has
170+
broader provider coverage and feature parity today.
171+
137172
## Next Steps
138173

139174
- [Getting Started](../getting-started/quick-start) - Learn the basics

0 commit comments

Comments
 (0)