Loading workspace insights... Statistics interval
7 days30 daysLatest CI Pipeline Executions
e88c3196 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. e6bad50c 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) e6bad50c 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) a71d9620 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. a71d9620 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. 9823145c 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 a1df0688 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