Skip to content

Commit 27c9aeb

Browse files
AlemTuzlakautofix-ci[bot]tombeckenham
authored
feat: extract @tanstack/openai-base and @tanstack/ai-utils packages (#409)
* feat(ai-utils): add @tanstack/ai-utils package with shared utilities Introduces the @tanstack/ai-utils package providing shared, zero-dependency utilities (generateId, getApiKeyFromEnv, transformNullsToUndefined, defineModelMeta) for use across TanStack AI adapter packages. * fix(ai-utils): align with canonical adapter patterns - Fix getApiKeyFromEnv to check globalThis.window.env before process.env, matching all existing adapters - Fix generateId to use substring(7) instead of substring(2,10) for consistent random part length - Add JSDoc to transformNullsToUndefined explaining the null→undefined JSON Schema reason - Add missing test cases for empty output modalities and negative output pricing * feat(openai-base): add @tanstack/openai-base with schema converter, tools, and types * feat(openai-base): add Chat Completions text adapter base class Extract the streaming Chat Completions pipeline from ai-grok into a reusable OpenAICompatibleChatCompletionsTextAdapter. Providers that use the OpenAI Chat Completions API (/v1/chat/completions) can extend this class and only need to set baseURL, lock type parameters, and override methods for provider-specific quirks. Protected override points: mapOptionsToRequest, convertMessage, convertContentPart, processStreamChunks, makeStructuredOutputCompatible, normalizeContent, extractTextContent. Also adds Chat Completions-specific tool converter (distinct from the existing Responses API tool converter). * feat(openai-base): add Responses API text adapter base class Extract and generalize the OpenAI Responses API text adapter into OpenAICompatibleResponsesTextAdapter. This handles the full Responses API streaming pipeline (9+ event types), including reasoning/thinking tokens, tool call streaming, and structured output via text.format. Also adds responses-tool-converter for the flat Responses API tool format (distinct from Chat Completions' nested function format). * feat(openai-base): add image, summarize, transcription, TTS, and video adapter base classes * refactor(ai-openai): delegate to @tanstack/openai-base and @tanstack/ai-utils Migrate ai-openai to extend base classes from openai-base and delegate utility functions to ai-utils, eliminating ~1,800 lines of duplicated code while maintaining zero breaking changes. Changes: - utils/client.ts: generateId and getOpenAIApiKeyFromEnv delegate to ai-utils - utils/schema-converter.ts: transformNullsToUndefined and makeOpenAIStructuredOutputCompatible delegate to ai-utils/openai-base - tools/*: all 14 tool files re-export from openai-base - adapters/text.ts: extends OpenAICompatibleResponsesTextAdapter, overrides mapOptionsToRequest for OpenAI-specific tool conversion and validation - adapters/image.ts: extends OpenAICompatibleImageAdapter, overrides validation methods - adapters/summarize.ts: extends OpenAICompatibleSummarizeAdapter - adapters/tts.ts: extends OpenAICompatibleTTSAdapter, overrides validation - adapters/transcription.ts: extends OpenAICompatibleTranscriptionAdapter - adapters/video.ts: extends OpenAICompatibleVideoAdapter, overrides validation and request building - openai-base config.ts: removed explicit baseURL to avoid null incompatibility All 127 existing tests pass, types check clean, build succeeds. * refactor(ai-grok): delegate to @tanstack/openai-base and @tanstack/ai-utils * refactor: migrate ai-groq, ai-openrouter, ai-ollama to shared utilities Replace duplicated generateId, env-var lookup, and null-transform logic in ai-groq, ai-openrouter, and ai-ollama with imports from @tanstack/ai-utils and @tanstack/openai-base. makeGroqStructuredOutputCompatible now delegates to makeStructuredOutputCompatible and applies the Groq-specific quirk of removing empty required arrays. * style: format files with prettier * refactor: migrate ai-anthropic, ai-gemini, ai-fal, ai-elevenlabs to @tanstack/ai-utils Replace duplicated generateId and getXxxApiKeyFromEnv implementations in ai-anthropic, ai-gemini, ai-fal, and ai-elevenlabs with imports from @tanstack/ai-utils. All provider-specific wrapper function names preserved for backwards compatibility. * chore: add changesets for openai-base extraction * fix: address CodeRabbit review comments on openai-base extraction - Fix schema-converter default required parameter and null-widening for nested types - Fix removeEmptyRequired to recurse into anyOf/oneOf/allOf/additionalProperties (groq) - Forward modelOptions, request headers/signal in chat-completions-text adapter - Remove stream_options leak into non-streaming structured output calls - Use call_id instead of internal id for tool call correlation (responses-text) - Make transcription verbose_json default provider-agnostic via protected override - Add runtime guards for ArrayBuffer and atob in transcription adapter - Derive TTS outputFormat from merged request after modelOptions spread - Add downloadContent probe and fix expires_at seconds-to-milliseconds (video) - Fix mcp-tool type ordering so metadata cannot override type: 'mcp' - Add tests proving all fixes work * fix: resolve eslint and knip failures from full test suite - Fix unnecessary type assertions in chat-completions-text and responses-text - Fix eslint import order in ai-groq client.ts - Fix unnecessary condition in ai-groq schema-converter combinator recursion - Fix array-type lint error in openai-base provider-options - Remove unused files in ai-grok (tools/index.ts, tool-converter.ts, function-tool.ts) - Remove unused exports (createOpenAIClient, generateId, validateTextProviderOptions, InternalTextProviderOptions) * ci: apply automated fixes * fix: address CodeRabbit review comments - responses-text: remove unsafe `any` cast for call_id, fix overly restrictive item.id guard on function_call handling - transcription: add ensureFileSupport() and decodeBase64() helpers for cross-environment safety (Node < 20, missing atob) - schema-converter.test: rename misleading oneOf test name - env.test: use unique generated keys for deterministic assertions * fix: address code review findings - tool-choice: widen MCPToolChoice.server_label from 'deepwiki' literal to string - gemini client: restore error message mentioning both GOOGLE_API_KEY and GEMINI_API_KEY - chat-completions-text/responses-text: use console.error for stream errors - chat-completions-text: spread modelOptions first so explicit fields win - package.json: remove duplicate workspace deps from devDependencies * fix: remove unnecessary type assertions in transcription adapter * ci: apply automated fixes * fix(openai-base): align rebased openai-base with current main Post-merge fixes landed on top of the rebase onto main: - Thread TToolCapabilities generic through OpenAICompatibleChatCompletionsTextAdapter and OpenAICompatibleResponsesTextAdapter so ai-openai / ai-grok / ai-groq / ai-openrouter pass their own tool-capability maps while the base stays brand-agnostic. - Align AG-UI event yields and adapter signatures with main's EventType-enum-typed StreamChunk: wrap event yields through an asChunk helper; align BaseImage/TTS/ Transcription super() calls with (model, config) signature; switch GeneratedImage construction to the url-xor-b64Json discriminated shape; propagate options.logger through summarize → chatStream. - Responses API tool-call events now use item.id as toolCallId and emit both toolCallName and toolName, matching main's contract. - Prefer videos.retrieve URL for video content (retrieve-returns-url path) — aligns with main's behavior and works against providers that don't implement /content. - ai-utils: preserve the full random suffix in generateId (pre-rebase regression would have failed ai-fal's entropy test). - Recreate ai-grok/src/tools/index.ts as a thin re-export of openai-base's Chat Completions tool converter (vite entry expects it). * fix: address unresolved CodeRabbit findings + audit hygiene regression CodeRabbit findings: - ai-groq schema-converter: recursively normalise nested empty-object schemas in properties, items, anyOf/oneOf/allOf, additionalProperties. Top-level patch missed nested z.object({}) branches that openai-base's transformer skipped, leading to Groq strict-mode validation failures. Adds 3 regression tests. - openai-base chat-completions-text: capture usage from any chunk including the terminal usage-only chunk emitted with stream_options.include_usage so RUN_FINISHED carries accurate token counts. Previously the terminal chunk was skipped via 'if (!choice) continue' and RUN_FINISHED emitted earlier without usage. Also emit a synthetic RUN_FINISHED if the stream ended without a finish_reason chunk. - openai-base chat-completions-text: fail fast on unsupported content parts in convertMessage instead of silently dropping them and sending an empty user prompt that masks a capability mismatch. Audit-hygiene regression carried forward from main #465: - openai-base {chat-completions-text,responses-text}: replace seven console.error blocks across chatStream / structuredOutput / processStreamChunks catch sites with logger.errors(...) using toRunErrorPayload. Raw SDK errors can carry request metadata (including auth headers); main #465 had already removed this from ai-openai/text.ts before the extraction but the base classes lifted the unsafe form. Tests updated to inject testLogger via resolveDebugOption(false) on every chatStream / structuredOutput call, matching the pattern from ai-ollama tests after #467 made logger required on TextOptions. Verification: - pnpm test:lib : 31 projects pass - pnpm test:types: 32 projects pass - pnpm test:eslint: 31 projects pass - pnpm build : 33 projects pass * fix: address Round 1 cr-loop findings (subject-scope bucket-a) Six-agent unbiased review surfaced behavioural and audit-hygiene regressions across the extracted base classes. Fixes: openai-base/adapters/chat-completions-text.ts - Defer RUN_FINISHED until end-of-stream so the trailing usage-only chunk that OpenAI emits AFTER finish_reason (when stream_options.include_usage is on) is captured. Previous fix emitted RUN_FINISHED with usage: undefined and dropped the trailing chunk. - Forward upstream finish_reason ('length', 'content_filter', etc.) instead of collapsing all non-tool reasons to 'stop'. - Surface tool-args JSON parse failures via logger.errors so a model emitting malformed JSON for tool arguments is debuggable instead of silently invoking the tool with input: {}. - Fix mapOptionsToRequest precedence: `{...modelOptions, temperature: options.temperature, ...}` clobbered `modelOptions.temperature` with undefined whenever the caller didn't set the top-level option. Now spreads top-level fields only when defined. - Throw on user message with zero content parts instead of silently sending content: '' (a paid request with no input). openai-base/adapters/responses-text.ts - Only reset accumulators on response.created. Previous code reset on response.failed/incomplete too, which left TEXT_MESSAGE_START events unbalanced when a response failed mid-stream. On terminal failure events we now emit TEXT_MESSAGE_END before RUN_ERROR. - Synthesize a terminal RUN_FINISHED if the stream ends without response.completed (truncated upstream connection), matching the chat-completions adapter's behavior so consumers always see a terminal event for every started run. - Surface tool-args JSON parse failures via logger.errors (parallel to chat-completions fix). - Distinguish refusals from unsupported content_part types in handleContentPart instead of always reporting 'Unknown refusal'. - Fix mapOptionsToRequest precedence: previously `...modelOptions` was spread LAST, silently shadowing the canonical top-level fields. Now matches the chat-completions adapter (modelOptions first, then defined top-level options), so callers tuning either backend see identical behaviour. - extractTextFromResponse now throws a distinct refusal error instead of returning '' and letting JSON.parse('') produce a confusing 'Failed to parse structured output as JSON. Content: ' message. openai-base/adapters/summarize.ts - generateId(this.name) for the result id (was hard-coded to ''). - Throw when chatStream emits RUN_ERROR instead of pretending a failed run succeeded with summary: ''. openai-base/adapters/{image,transcription,tts}.ts - Wrap SDK calls in try/catch + logger.errors with toRunErrorPayload so raw SDK errors (which can carry request metadata including auth headers) never reach user-supplied loggers. Matches the audit hygiene main #465 applied to ai-openai/text.ts before the extraction. - tts.ts: cross-runtime ArrayBuffer→base64 helper so the adapter works in browser/edge runtimes that lack Buffer. - transcription.ts: confidence calculation no longer treats avg_logprob === 0 (perfect-confidence) as missing. openai-base/tools/file-search-tool.ts - validateMaxNumResults(0) now correctly throws (was skipped because `if (maxNumResults && ...)` short-circuited on falsy zero). ai-openai/adapters/transcription.ts - shouldDefaultToVerbose returns true ONLY for whisper-1. The gpt-4o-transcribe* models reject 'verbose_json' with HTTP 400, so defaulting them to verbose was a guaranteed-failure regression. The previous logic was inverted. ai-openai/tools/{file-search,image-generation}-tool.ts - Drop locally duplicated validators; import from openai-base. The local copy of validateMaxNumResults had the same falsy-zero bug. ai-openai/tools/computer-use-tool.ts - Document that the brand discriminator ('computer_use') intentionally differs from the runtime tool name ('computer_use_preview'). The brand matches the model-meta capability union; the runtime name matches the OpenAI SDK literal that the special-tool dispatcher switches on. ai-utils/src/transforms.ts - Document transformNullsToUndefined's actual behavior (object keys are removed, top-level null becomes undefined, arrays preserve positional null). Restrict scope to JSON-shaped values; class instances/Date/Map/Set are not preserved by Object.entries recursion. ai-elevenlabs/src/utils/client.ts - Wire getElevenLabsApiKeyFromEnv and generateId through @tanstack/ai-utils so the dependency added during the merge is actually consumed (was unused after main's #504 SDK migration). ai-openrouter/package.json - Move @tanstack/ai from dependencies to devDependencies (it's already declared as a peerDependency). Avoids dual-instance hazards where the workspace package gets installed twice. Also bump the peer range to workspace:^ to match every other adapter. .changeset/refactor-providers-to-shared-packages.md - Correct the description: only ai-openai/ai-grok/ai-groq inherit from @tanstack/openai-base; the other six providers only consume @tanstack/ai-utils. Previous wording implied all nine adapters delegated to openai-base. Verification: - pnpm test:lib : 31 projects pass (116+ unit tests) - pnpm test:types : 32 projects pass - pnpm test:eslint : 31 projects pass - pnpm build : 33 projects pass * fix: address Round 2 cr-loop findings Round 2 confirmation surfaced bucket-(a) issues across multiple agents — many on code paths I had missed in Round 1. ai-openai/src/adapters/text.ts - mapOptionsToRequest: restructure to mirror the base OpenAICompatibleResponsesTextAdapter precedence (modelOptions first, then conditional spreads of explicit top-level options when defined). The previous override spread `...modelOptions` LAST and wrote `temperature: options.temperature` unconditionally, which both silently shadowed canonical fields with modelOptions values AND clobbered modelOptions.temperature with undefined whenever the caller didn't set the top-level option. Five separate review agents flagged this as a real regression vs. the base class fix. openai-base/src/adapters/responses-text.ts - response.failed / response.incomplete now sets runFinishedEmitted after RUN_ERROR so the post-loop synthetic terminal block doesn't emit a duplicate RUN_FINISHED on top of an error event. Also coalesces the (error + incomplete_details) double-emit into a single RUN_ERROR with merged message + code. - response.completed now forwards `incomplete_details.reason` (length / content_filter / etc.) as finishReason instead of collapsing to 'stop', mirroring the chat-completions adapter's preservation of upstream finish reasons. - Top-level error event sets runFinishedEmitted (terminal). - structuredOutput strips streaming-only fields (stream / stream_options) from a subclass override of mapOptionsToRequest before the non-streaming call, parallel to chat-completions's cleanup. A subclass returning stream_options on the streaming-shaped result would otherwise 4xx the structured-output call. - mapOptionsToRequest: `tools` is now a conditional spread so a caller's modelOptions.tools survives when options.tools is undefined. - convertContentPartToInput audio branch wraps raw base64 in a data URL using part.source.mimeType, mirroring the image branch. `input_file` rejects bare base64 strings. - convertMessagesToInput throws on a user message with no content parts instead of injecting `text: ''` (parallel to chat-completions fail-loud behavior — paid request with no input mask intent). openai-base/src/adapters/chat-completions-text.ts - mapOptionsToRequest: `tools` conditional spread (same fix as responses-text). Drop the now-redundant `as Array<...>` cast that ESLint correctly flagged as unnecessary. openai-base/src/adapters/summarize.ts - TEXT_MESSAGE_CONTENT branch only appends delta when defined, preventing a content-less chunk with an undefined delta from concatenating literal 'undefined' to the summary. openai-base/src/adapters/video.ts - Replace both `Buffer.from(buffer).toString('base64')` call sites with a cross-runtime `arrayBufferToBase64` helper (atob/btoa fallback for browser/edge). Buffer is Node-only — the previous code threw ReferenceError on Workers / Edge / browsers. openai-base/src/tools/{image-generation,web-search,web-search-preview}-tool.ts - Convert tool functions: spread `metadata` first, then force the literal `type` constant last. The previous order let a hand-built tool with the wrong metadata.type override the discriminator while the dispatcher had already routed by tool.name, producing a payload whose runtime type didn't match the route. Mirrors the existing mcpTool pattern. openai-base/tests/{chat-completions,responses}-text.test.ts - Drop the extra outer `logger: testLogger` from the 'throws on invalid JSON response' structuredOutput tests; logger is supposed to live on chatOptions only and the outer copy was a Round 1 patch-script artifact. ai-utils/src/transforms.ts - transformNullsToUndefined doc: clarify that array elements recurse via this same function, so a top-level null element becomes undefined (not null). Correct the class-instance description: native built-ins like Date/Map/Set become {}, but arbitrary class instances produce a plain-object snapshot of own enumerable string properties, not a uniform empty object. .changeset/refactor-providers-to-shared-packages.md - Correct the description: ai-groq does NOT inherit from @tanstack/openai-base text adapter (it speaks groq-sdk and keeps its own BaseTextAdapter-derived adapter); it only consumes the schema converter and tool converters. Verification: - pnpm test:types : 32 projects pass - pnpm test:lib : 31 projects pass (116+ unit tests) - pnpm test:eslint : 31 projects pass - pnpm build : 33 projects pass * fix: address Round 3 cr-loop findings (lifecycle + edge cases) Round 3 confirmation surfaced subtler tool-call lifecycle and terminal-event idempotency gaps that earlier rounds missed. openai-base/adapters/chat-completions-text.ts - Move RUN_STARTED emission BEFORE the `if (!choice) continue` guard so a stream that arrives entirely as usage-only chunks (no choices) still emits RUN_STARTED. Without this the post-loop synthetic terminal block (which gates on hasEmittedRunStarted) skipped emission entirely, producing zero events for the run. - Skip non-started entries in the finish_reason TOOL_CALL_END loop and in the post-loop synthetic close. A delta with index but no id/name populates toolCallsInProgress without ever emitting TOOL_CALL_START; emitting TOOL_CALL_END for those entries violated AG-UI lifecycle (END without matching START) and produced empty toolCallId payloads consumers couldn't pair. - Clear toolCallsInProgress after emitting TOOL_CALL_END, and gate the synthetic finishReason on a separate pendingToolCount counter rather than `toolCallsInProgress.size > 0`. Previously, a tool-then-clean-stop run reported finishReason: 'tool_calls' because the map was never cleared after the in-loop emission. - parsedInput now coerces non-object JSON to {} (parallel to the Responses adapter's existing guard). A bare string/number/null in the arguments wire would otherwise propagate to tool execution as the TOOL_CALL_END.input. - Close any started tool calls in the post-loop synthetic block on truncated streams (no finish_reason ever arrives) so consumers see a matching TOOL_CALL_END for every TOOL_CALL_START. - convertMessage: throw on a single-text user message whose content is empty (paid request with no input). Previously the early-return bypassed the multipart fail-loud guard. - convertMessage: assistant tool-only messages now use `content: null` instead of `content: ''`, matching the OpenAI Chat Completions contract; empty-string content interacts oddly with tokenization on some backends. - convertContentPart image branch defaults the data-URI MIME to `application/octet-stream` when source.mimeType is undefined, instead of interpolating literal "undefined" into the URI. openai-base/adapters/responses-text.ts - response.failed / response.incomplete now ALWAYS emit RUN_ERROR (even when both `error` and `incomplete_details` are missing) and `return` out of the loop. Previously, an incomplete event with no detail silently coerced the run to RUN_FINISHED { finishReason: 'stop' } via the post-loop synthetic block, masking the failure. The early return also stops further chunks from being processed after a terminal event. - response.output_item.added: emit TOOL_CALL_START only when the metadata's started flag is false. A duplicate output_item.added for the same item id (retried streams / replay) no longer emits a second TOOL_CALL_START. - response.function_call_arguments.delta: drop the `args` field. Its `metadata ? undefined : chunk.delta` polarity was inverted (populated only on orphan-tool-call paths), the chat-completions adapter never emitted it, and consumers should accumulate `delta` themselves. - response.function_call_arguments.done: skip TOOL_CALL_END (and log) when the metadata's started flag is false, mirroring the chat- completions adapter's started-guard. Emitting END without a matching START broke AG-UI consumers' lifecycle pairing. - handleContentPart RUN_ERROR is now treated as terminal: set runFinishedEmitted = true and `return` out of the loop. Previously the loop kept processing and the synthetic terminal block could emit a second RUN_FINISHED after a refusal. - Mark hasStreamedContentDeltas / hasStreamedReasoningDeltas in the content_part.added handler so a subsequent matching content_part.done doesn't re-emit the same text (the existing skip guard for done events depended on these flags being true). - handleContentPart's reasoning_text branch caches the fallback stepId (`if (!stepId) stepId = generateId(...)`) instead of generating a fresh id on each call. Multiple reasoning content parts arriving without a preceding STEP_STARTED no longer emit different stepIds. - convertContentPartToInput image and audio: default the data-URI MIME to application/octet-stream when source.mimeType is undefined. openai-base/adapters/{chat-completions,responses}-tool-converter.ts - Shallow-copy the schemaConverter result before mutating additionalProperties. A subclass-supplied passthrough converter could otherwise have its caller's schema mutated by the `jsonSchema.additionalProperties = false` assignment. openai-base/adapters/video.ts - getVideoUrl SDK fall-through (content/getContent/download): convert the binary response to a data URL like the downloadContent path already does. Previously the bottom `return { url: response.url }` returned the API endpoint URL (or undefined for non-Response shapes) in place of a usable video URL. openai-base/package.json - Drop `zod` from peerDependencies and devDependencies. Grep confirms no zod imports anywhere in the package; the peer dep produced a spurious peer-warning on every consumer. Verification: - pnpm test:types : 32 projects pass - pnpm test:lib : 31 projects pass (116+ unit tests) - pnpm test:eslint : 31 projects pass - pnpm build : 33 projects pass Deferred bucket (b/c) follow-ups (not in this commit): - Include tests/ in ai-utils + openai-base tsconfig.json (requires fixing ~20 pre-existing test-file TS issues: EventType enum literals vs string literals, possibly-undefined narrowing, mock typing). - ai-fal peer-dep workspace:* → workspace:^ (pre-existing on main). - defineModelMeta gaps (output.cached, NaN, integer context_window). - transformNullsToUndefined runtime guard for non-plain objects. * chore(examples/ts-react-chat): refresh model picker Update MODEL_OPTIONS with current flagship models: - OpenAI: GPT-5.2, 5.2 Pro, 5.1, 5/Mini/Nano, 4.1 - Anthropic: Claude Opus 4.7, 4.6, Sonnet 4.6/4.5, Haiku 4.5 (cleaner non-dated form matching the model-meta canonical names) - OpenRouter: now spans openai/, anthropic/, google/, x-ai/, and meta-llama/ slugs so the OpenRouter adapter can demonstrate routing across multiple upstream providers from one API - Default model is now OpenAI GPT-5.2 (was GPT-4o) * refactor(openai-base, ai-utils, ai-openai, ai-grok): address PR #409 review feedback - Eliminate `toCompatibleConfig` casts: `OpenAIClientConfig`/`GrokClientConfig` extend `OpenAICompatibleClientConfig` directly; ai-grok keeps a `withGrokDefaults()` helper for its xAI baseURL default. - Fix tool-call streaming bugs: - chat-completions-text: track `emittedAnyToolCallEnd` at outer scope so a `tool_calls` finish_reason without a started/ended pair downgrades to `stop`. - responses-text: guard `function_call_arguments.delta` with `metadata?.started` to match the `.done` handler. - Schema converter: `originalRequired` is optional and falls back to `schema.required`; adapters now pass the unmodified array (not `|| []`) so root-level required fields are no longer silently widened to `null`. - Forward request options: shared `extractRequestOptions` helper threads `headers`/`signal` from `Request | RequestInit` into both Chat Completions and Responses SDK calls without per-call casts. - Restore logger calls dropped during the refactor: `logger.request` before every SDK call (image/summarize/transcription/tts/video/responses-text/ chat-completions-text), `logger.provider` per-chunk in both streaming adapters, and try/catch + `logger.errors` around `summarize`, `summarizeStream`, and `createVideoJob`. - Move base64 helpers into `@tanstack/ai-utils` (`arrayBufferToBase64`, `base64ToArrayBuffer`) with `Uint8Array.toBase64`/`fromBase64` fast paths and `Buffer`/`atob`/`btoa` fallbacks; remove the per-adapter copies. - Add `warnIfLargeMediaBuffer` in video.ts to surface base64-encoding-an- unbounded-blob OOM risks on Workers/Lambda. - Replace `client as any` in video.ts with a typed `getVideosClient()` accessor; drop the arbitrary 1-10 cap in base `validateNumberOfImages`. - Delete dead code: `openai-base/src/types/message-metadata.ts`, `provider-options.ts`, and the unused `defineModelMeta` in ai-utils. - Brand-via-base: every ai-openai tool factory now delegates to its `@tanstack/openai-base` counterpart and brands the result, removing ten duplicate function bodies. - `satisfies` instead of `as` for the structured-output response narrow. - Add `@types/node` dev dep on ai-utils so `process` typechecks. - Tests: new `media-adapters.test.ts` (image / summarize / transcription / tts / video) in openai-base, new `base64.test.ts` (incl. 1.5 MiB roundtrip) in ai-utils. All test suites green: ai-utils 18, openai-base 71, ai-groq 17, ai-openai 131, ai-grok 53. `test:types`, `test:eslint`, and `build` clean for every affected package. * Removed openai and zod as direct dependencies of grok adapter --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
1 parent 4a943d6 commit 27c9aeb

107 files changed

Lines changed: 8422 additions & 3582 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.

.changeset/add-ai-utils-package.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-utils': minor
3+
---
4+
5+
New package: shared provider-agnostic utilities for TanStack AI adapters. Includes `generateId`, `getApiKeyFromEnv`, `transformNullsToUndefined`, and `ModelMeta` types with `defineModelMeta` validation helper. Zero runtime dependencies.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/openai-base': minor
3+
---
4+
5+
New package: shared base adapters and utilities for OpenAI-compatible providers. Includes Chat Completions and Responses API text adapter base classes, image/summarize/transcription/TTS/video adapter base classes, schema converter, 15 tool converters, and shared types. Providers extend these base classes to reduce duplication and ensure consistent behavior.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@tanstack/ai-openai': patch
3+
'@tanstack/ai-grok': patch
4+
'@tanstack/ai-groq': patch
5+
'@tanstack/ai-openrouter': patch
6+
'@tanstack/ai-ollama': patch
7+
'@tanstack/ai-anthropic': patch
8+
'@tanstack/ai-gemini': patch
9+
'@tanstack/ai-fal': patch
10+
'@tanstack/ai-elevenlabs': patch
11+
---
12+
13+
Internal refactor: every provider now delegates `getApiKeyFromEnv` / `generateId` / `transformNullsToUndefined` / `ModelMeta` helpers to the new `@tanstack/ai-utils` package. `ai-openai` and `ai-grok` additionally inherit OpenAI-compatible adapter base classes (Chat Completions / Responses text, image, summarize, transcription, TTS, video) from the new `@tanstack/openai-base` package; `ai-groq` keeps its own `BaseTextAdapter`-derived text adapter (Groq uses the `groq-sdk`, not the OpenAI SDK) but consumes `@tanstack/openai-base`'s schema converter and tool converters. The remaining providers (`ai-anthropic`, `ai-gemini`, `ai-ollama`, `ai-openrouter`, `ai-fal`, `ai-elevenlabs`) only consume `@tanstack/ai-utils` because they speak provider-native protocols, not OpenAI-compatible ones. No breaking changes — all public APIs remain identical.

examples/ts-react-chat/src/lib/model-selection.ts

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,48 @@ export interface ModelOption {
1515

1616
export const MODEL_OPTIONS: Array<ModelOption> = [
1717
// OpenAI
18+
{ provider: 'openai', model: 'gpt-5.2', label: 'OpenAI - GPT-5.2' },
19+
{ provider: 'openai', model: 'gpt-5.2-pro', label: 'OpenAI - GPT-5.2 Pro' },
20+
{ provider: 'openai', model: 'gpt-5.1', label: 'OpenAI - GPT-5.1' },
21+
{ provider: 'openai', model: 'gpt-5', label: 'OpenAI - GPT-5' },
22+
{ provider: 'openai', model: 'gpt-5-mini', label: 'OpenAI - GPT-5 Mini' },
23+
{ provider: 'openai', model: 'gpt-5-nano', label: 'OpenAI - GPT-5 Nano' },
24+
{ provider: 'openai', model: 'gpt-4.1', label: 'OpenAI - GPT-4.1' },
1825
{ provider: 'openai', model: 'gpt-4o', label: 'OpenAI - GPT-4o' },
1926
{ provider: 'openai', model: 'gpt-4o-mini', label: 'OpenAI - GPT-4o Mini' },
20-
{ provider: 'openai', model: 'gpt-5', label: 'OpenAI - GPT-5' },
2127

2228
// Anthropic
2329
{
2430
provider: 'anthropic',
25-
model: 'claude-sonnet-4-6',
26-
label: 'Anthropic - Claude Sonnet 4.6',
31+
model: 'claude-opus-4-7',
32+
label: 'Anthropic - Claude Opus 4.7',
2733
},
2834
{
2935
provider: 'anthropic',
30-
model: 'claude-sonnet-4-5-20250929',
31-
label: 'Anthropic - Claude Sonnet 4.5',
36+
model: 'claude-opus-4-6',
37+
label: 'Anthropic - Claude Opus 4.6',
3238
},
3339
{
3440
provider: 'anthropic',
35-
model: 'claude-opus-4-5-20251101',
36-
label: 'Anthropic - Claude Opus 4.5',
41+
model: 'claude-sonnet-4-6',
42+
label: 'Anthropic - Claude Sonnet 4.6',
43+
},
44+
{
45+
provider: 'anthropic',
46+
model: 'claude-sonnet-4-5',
47+
label: 'Anthropic - Claude Sonnet 4.5',
3748
},
3849
{
3950
provider: 'anthropic',
40-
model: 'claude-haiku-4-0-20250514',
41-
label: 'Anthropic - Claude Haiku 4.0',
51+
model: 'claude-haiku-4-5',
52+
label: 'Anthropic - Claude Haiku 4.5',
4253
},
4354

4455
// Gemini
4556
{
4657
provider: 'gemini',
47-
model: 'gemini-2.0-flash',
48-
label: 'Gemini - 2.0 Flash',
58+
model: 'gemini-2.5-pro',
59+
label: 'Gemini - 2.5 Pro',
4960
},
5061
{
5162
provider: 'gemini',
@@ -54,20 +65,60 @@ export const MODEL_OPTIONS: Array<ModelOption> = [
5465
},
5566
{
5667
provider: 'gemini',
57-
model: 'gemini-2.5-pro',
58-
label: 'Gemini - 2.5 Pro',
68+
model: 'gemini-2.0-flash',
69+
label: 'Gemini - 2.0 Flash',
5970
},
6071

61-
// Openrouter
72+
// Openrouter — multi-provider via OpenRouter's unified API
73+
{
74+
provider: 'openrouter',
75+
model: 'openai/gpt-5.2',
76+
label: 'OpenRouter - OpenAI GPT-5.2',
77+
},
78+
{
79+
provider: 'openrouter',
80+
model: 'openai/gpt-5.1',
81+
label: 'OpenRouter - OpenAI GPT-5.1',
82+
},
83+
{
84+
provider: 'openrouter',
85+
model: 'openai/gpt-5',
86+
label: 'OpenRouter - OpenAI GPT-5',
87+
},
88+
{
89+
provider: 'openrouter',
90+
model: 'openai/gpt-4o',
91+
label: 'OpenRouter - OpenAI GPT-4o',
92+
},
93+
{
94+
provider: 'openrouter',
95+
model: 'anthropic/claude-opus-4.7',
96+
label: 'OpenRouter - Anthropic Claude Opus 4.7',
97+
},
98+
{
99+
provider: 'openrouter',
100+
model: 'anthropic/claude-sonnet-4.6',
101+
label: 'OpenRouter - Anthropic Claude Sonnet 4.6',
102+
},
103+
{
104+
provider: 'openrouter',
105+
model: 'anthropic/claude-haiku-4.5',
106+
label: 'OpenRouter - Anthropic Claude Haiku 4.5',
107+
},
108+
{
109+
provider: 'openrouter',
110+
model: 'google/gemini-2.5-pro',
111+
label: 'OpenRouter - Google Gemini 2.5 Pro',
112+
},
62113
{
63114
provider: 'openrouter',
64-
model: 'openai/chatgpt-4o-latest',
65-
label: 'Openrouter - ChatGPT 4o Latest',
115+
model: 'x-ai/grok-4',
116+
label: 'OpenRouter - xAI Grok 4',
66117
},
67118
{
68119
provider: 'openrouter',
69-
model: 'openai/chatgpt-4o-mini',
70-
label: 'Openrouter - ChatGPT 4o Mini',
120+
model: 'meta-llama/llama-3.3-70b-instruct',
121+
label: 'OpenRouter - Meta Llama 3.3 70B (Groq-routed)',
71122
},
72123

73124
// Ollama

packages/typescript/ai-anthropic/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"test:types": "tsc"
4545
},
4646
"dependencies": {
47-
"@anthropic-ai/sdk": "^0.71.2"
47+
"@anthropic-ai/sdk": "^0.71.2",
48+
"@tanstack/ai-utils": "workspace:*"
4849
},
4950
"peerDependencies": {
5051
"@tanstack/ai": "workspace:^",
Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Anthropic_SDK from '@anthropic-ai/sdk'
2+
import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'
23
import type { ClientOptions } from '@anthropic-ai/sdk'
34

45
export interface AnthropicClientConfig extends ClientOptions {
@@ -22,26 +23,12 @@ export function createAnthropicClient(
2223
* @throws Error if ANTHROPIC_API_KEY is not found
2324
*/
2425
export function getAnthropicApiKeyFromEnv(): string {
25-
const env =
26-
typeof globalThis !== 'undefined' && (globalThis as any).window?.env
27-
? (globalThis as any).window.env
28-
: typeof process !== 'undefined'
29-
? process.env
30-
: undefined
31-
const key = env?.ANTHROPIC_API_KEY
32-
33-
if (!key) {
34-
throw new Error(
35-
'ANTHROPIC_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',
36-
)
37-
}
38-
39-
return key
26+
return getApiKeyFromEnv('ANTHROPIC_API_KEY')
4027
}
4128

4229
/**
4330
* Generates a unique ID with a prefix
4431
*/
4532
export function generateId(prefix: string): string {
46-
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
33+
return _generateId(prefix)
4734
}

packages/typescript/ai-elevenlabs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
},
5050
"dependencies": {
5151
"@elevenlabs/client": "^1.3.1",
52-
"@elevenlabs/elevenlabs-js": "^2.44.0"
52+
"@elevenlabs/elevenlabs-js": "^2.44.0",
53+
"@tanstack/ai-utils": "workspace:*"
5354
},
5455
"peerDependencies": {
5556
"@tanstack/ai": "workspace:^",

packages/typescript/ai-elevenlabs/src/utils/client.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'
2+
import {
3+
getApiKeyFromEnv,
4+
generateId as sharedGenerateId,
5+
} from '@tanstack/ai-utils'
26
import type { ElevenLabsOutputFormat } from '../model-meta'
37

48
/**
@@ -39,14 +43,7 @@ function getEnvironment(): EnvObject | undefined {
3943
}
4044

4145
export function getElevenLabsApiKeyFromEnv(): string {
42-
const key = getEnvironment()?.ELEVENLABS_API_KEY
43-
if (!key) {
44-
throw new Error(
45-
'ELEVENLABS_API_KEY is required. Please set it in your environment ' +
46-
'variables or pass it explicitly to the adapter factory.',
47-
)
48-
}
49-
return key
46+
return getApiKeyFromEnv('ELEVENLABS_API_KEY')
5047
}
5148

5249
export function getElevenLabsAgentIdFromEnv(): string {
@@ -80,9 +77,9 @@ export function createElevenLabsClient(
8077
})
8178
}
8279

83-
export function generateId(prefix: string): string {
84-
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`
85-
}
80+
// Re-exported from `@tanstack/ai-utils` so existing callers keep working
81+
// while the implementation stays deduped across provider packages.
82+
export const generateId = sharedGenerateId
8683

8784
/**
8885
* Convert an ArrayBuffer to base64 in a cross-runtime way.

packages/typescript/ai-fal/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"transcription"
4747
],
4848
"dependencies": {
49-
"@fal-ai/client": "^1.9.4"
49+
"@fal-ai/client": "^1.9.4",
50+
"@tanstack/ai-utils": "workspace:*"
5051
},
5152
"devDependencies": {
5253
"@tanstack/ai": "workspace:*",

packages/typescript/ai-fal/src/utils/client.ts

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,13 @@
11
import { fal } from '@fal-ai/client'
2+
import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils'
23

34
export interface FalClientConfig {
45
apiKey: string
56
proxyUrl?: string
67
}
78

8-
interface EnvObject {
9-
FAL_KEY?: string
10-
}
11-
12-
interface WindowWithEnv {
13-
env?: EnvObject
14-
}
15-
16-
function getEnvironment(): EnvObject | undefined {
17-
if (typeof globalThis !== 'undefined') {
18-
const win = (globalThis as { window?: WindowWithEnv }).window
19-
if (win?.env) {
20-
return win.env
21-
}
22-
}
23-
if (typeof process !== 'undefined') {
24-
return process.env as EnvObject
25-
}
26-
return undefined
27-
}
28-
299
export function getFalApiKeyFromEnv(): string {
30-
const env = getEnvironment()
31-
const key = env?.FAL_KEY
32-
33-
if (!key) {
34-
throw new Error(
35-
'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',
36-
)
37-
}
38-
39-
return key
10+
return getApiKeyFromEnv('FAL_KEY')
4011
}
4112

4213
export function configureFalClient(config?: FalClientConfig): void {
@@ -48,7 +19,7 @@ export function configureFalClient(config?: FalClientConfig): void {
4819
}
4920

5021
export function generateId(prefix: string): string {
51-
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`
22+
return _generateId(prefix)
5223
}
5324

5425
/**

0 commit comments

Comments
 (0)