Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0e72363
refactor: migrate ai-groq + ai-openrouter onto @tanstack/openai-base …
tombeckenham May 11, 2026
2c52bd1
ci: apply automated fixes
autofix-ci[bot] May 11, 2026
0171b18
fix(openai-base, ai-openrouter, ai): silent failures in chat-completi…
tombeckenham May 11, 2026
d2fea9d
feat(ai-openrouter, openai-base): OpenRouter Responses (beta) adapter
tombeckenham May 12, 2026
fb29464
chore(ai-groq): remove dead unused message-param types
tombeckenham May 12, 2026
62c610b
fix(ai-openrouter): pass UNKNOWN-fallback events through verbatim
tombeckenham May 12, 2026
1da0efb
Merge remote-tracking branch 'origin/main' into 543-migrate-ai-groq-a…
tombeckenham May 12, 2026
5afeea3
refactor(adapters): remove asChunk casts, enforce satisfies StreamChunk
tombeckenham May 12, 2026
3e39b8d
Merge remote-tracking branch 'origin/main' into cr-545
AlemTuzlak May 12, 2026
dd9e509
fix(ai-openrouter): preserve assistant/tool message content fidelity
AlemTuzlak May 12, 2026
bde61e6
fix(ai-groq): correct ChatCompletionNamedToolChoice shape
AlemTuzlak May 12, 2026
e83df33
test(ai-groq): reset pendingMockCreate between tests
AlemTuzlak May 12, 2026
fb8cf48
test(e2e): route OpenRouter summarize through createOpenRouterSummarize
AlemTuzlak May 12, 2026
0a14005
chore(ai-openrouter): declare zod as peer dependency
AlemTuzlak May 12, 2026
7bb3d8b
fix(ai-groq): drop spurious timestamp field from processStreamChunks …
AlemTuzlak May 12, 2026
c1cda01
fix(ai-openrouter): stringify error.code on response.failed events
AlemTuzlak May 12, 2026
4ba13c9
fix(ai-openrouter): default image data URI mime type to octet-stream
AlemTuzlak May 12, 2026
d1f80e1
fix(openai-base): stop processing chunks after top-level error event
AlemTuzlak May 12, 2026
a701cb2
fix(openai-base, ai-openrouter): route Responses structuredOutput thr…
AlemTuzlak May 12, 2026
21e6b4e
fix(ai-openrouter): extract text from array-shaped tool message content
AlemTuzlak May 12, 2026
9b0bdbd
chore(ai-groq): declare @tanstack/ai as workspace devDependency
AlemTuzlak May 12, 2026
9460493
fix(ai-openrouter): route audio URLs to text fallback on chat-complet…
AlemTuzlak May 12, 2026
9fd3168
docs(ai-groq): correct message-types header β€” Groq SDK was dropped
AlemTuzlak May 12, 2026
290b0e7
fix(ai-openrouter): reject inline document data on chat-completions
AlemTuzlak May 12, 2026
06d3d8c
refactor: rename @tanstack/openai-base β†’ @tanstack/openai-compatible
tombeckenham May 13, 2026
16ce307
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
7d45389
refactor: rename @tanstack/openai-compatible β†’ @tanstack/ai-openai-co…
tombeckenham May 13, 2026
3101dbe
docs(ai-openai-compatible, ai-openrouter): explain the protocol-vs-pr…
tombeckenham May 13, 2026
5e15d2b
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
90a6018
docs(adapters/openrouter): add Chat Completions vs Responses (beta) s…
tombeckenham May 13, 2026
03bbe46
refactor(ai-openai-compatible): narrow to chat/responses; decouple fr…
tombeckenham May 13, 2026
bf36ae8
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
d57a44e
refactor(ai): rename chat-stream-wrapper to chat-stream-summarize
tombeckenham May 13, 2026
07115a3
refactor(summarize): unify provider summarize adapters on chat-stream…
tombeckenham May 13, 2026
f9b2294
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
e30a3ca
refactor(ai-openai-compatible): vendor wire types; drop openai dep
tombeckenham May 13, 2026
2c0fd29
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/migrate-groq-openrouter-to-openai-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@tanstack/ai-openai-compatible': minor
'@tanstack/ai-groq': patch
'@tanstack/ai-openrouter': patch
'@tanstack/ai': patch
---

Migrate `ai-groq` and `ai-openrouter` onto `OpenAICompatibleChatCompletionsTextAdapter` so they share the stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates with `ai-openai` / `ai-grok`. Removes ~1k LOC of duplicated stream processing.

`@tanstack/ai-openai-compatible` adds four protected hooks on `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the base: `callChatCompletion` and `callChatCompletionStream` (SDK call sites for non-streaming and streaming Chat Completions), `extractReasoning` (surface reasoning content from chunk shapes that carry it, e.g. OpenRouter's `delta.reasoningDetails`, into the base's REASONING\_\* + legacy STEP_STARTED/STEP_FINISHED lifecycle), and `transformStructuredOutput` (subclasses like OpenRouter can preserve nulls in structured output instead of converting them to undefined).

`@tanstack/ai-openai-compatible` fixes two error-handling regressions in the shared base: `structuredOutput` now throws a distinct `"response contained no content"` error rather than letting empty content cascade into a misleading JSON-parse error, and the post-loop tool-args drain block now logs malformed JSON via `logger.errors` (matching the in-loop finish_reason path) so truncated streams emitting partial tool args are debuggable instead of silently invoking the tool with `{}`.

`@tanstack/ai` normalizes abort-shaped errors (`AbortError`, `APIUserAbortError`, `RequestAbortedError`) to a stable `{ message: 'Request aborted', code: 'aborted' }` payload in `toRunErrorPayload`, so consumers can discriminate user-initiated cancellation from other failures without matching on provider-specific message strings.

`@tanstack/ai-groq` drops the `groq-sdk` dependency in favour of the OpenAI SDK pointed at `https://api.groq.com/openai/v1` (the same pattern as `ai-grok` against xAI). The Groq-specific quirk where streaming usage arrives under `chunk.x_groq.usage` is preserved via a small `processStreamChunks` wrapper that promotes it to the standard `chunk.usage` slot.

`@tanstack/ai-openrouter` keeps `@openrouter/sdk` (the source of truth for OpenRouter's typed provider routing, plugins, and metadata) but routes the SDK call through the base via overridden hooks. A small request shape converter (`max_tokens` β†’ `maxCompletionTokens`, etc.) and chunk shape adapter (camelCase β†’ snake_case for the base's reader) bridge the SDKs. No public API changes; provider routing, app attribution headers (`httpReferer`, `appTitle`), reasoning variants (`:thinking`), and `RequestAbortedError` handling are preserved. Fixes: `stream_options.include_usage` is now correctly camelCased to `includeUsage` so streaming `RUN_FINISHED.usage` is populated (previously silently dropped by the SDK Zod schema); mid-stream `chunk.error.code` is stringified so provider error codes (401, 429, 500, …) survive the `toRunErrorPayload` narrow; assistant `toolCalls[].function.arguments` is stringified to match the SDK's `string` contract; and `convertMessage` now mirrors the base's fail-loud guards (throws on empty user content and unsupported content parts) instead of silently sending empty paid requests.

`ai-ollama` remains on `BaseTextAdapter` β€” its native API uses a different wire format from Chat Completions (different chunk shape, request shape, tool-call streaming, and reasoning surface) and doesn't fit the OpenAI base without rebuilding most of the processing it would otherwise inherit. Migrating it remains a separate effort.
27 changes: 27 additions & 0 deletions .changeset/rename-openai-base-to-ai-openai-compatible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@tanstack/ai-openai-compatible': minor
'@tanstack/ai-openai': patch
'@tanstack/ai-openrouter': patch
'@tanstack/ai-groq': patch
'@tanstack/ai-grok': patch
---

Rename `@tanstack/openai-base` β†’ `@tanstack/ai-openai-compatible`.

The previous "base" name implied this package tracked OpenAI's product roadmap. In reality it implements two OpenAI-shaped _wire-format protocols_ that multiple providers ship:

- **Chat Completions** (`/v1/chat/completions`) β€” natively implemented by OpenAI, Groq, Grok, OpenRouter, vLLM, SGLang, Together, etc.
- **Responses** (`/v1/responses`) β€” OpenAI's reference implementation plus OpenRouter's beta routing implementation (which fans out to Anthropic, Google, and other underlying models).

"OpenAI-compatible" is the actual industry term for this family of wire formats (cf. Vercel's `@ai-sdk/openai-compatible`, LiteLLM's "OpenAI-compatible endpoint", BentoML / Lightning AI docs). The renamed package makes the boundary explicit: it holds the protocol, while OpenAI-specific tools, models, and behaviors continue to live in `@tanstack/ai-openai`.

No runtime behavior changes. Class names (`OpenAICompatibleChatCompletionsTextAdapter`, `OpenAICompatibleResponsesTextAdapter`, …) and protected hook contracts are unchanged. Consumer packages (`ai-openai`, `ai-openrouter`, `ai-groq`, `ai-grok`) only update their internal import paths β€” public API is unchanged.

If you were importing from `@tanstack/openai-base` directly (uncommon β€” the package was not yet documented as a public extension point), update your imports:

```diff
- import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/openai-base'
+ import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/ai-openai-compatible'
```

`@tanstack/openai-base@0.2.x` remains published on npm for anyone with a pinned lockfile reference but will receive no further updates.
63 changes: 49 additions & 14 deletions docs/adapters/openrouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,17 @@ const stream = chat({
## Configuration

```typescript
import { createOpenRouter, type OpenRouterConfig } from "@tanstack/ai-openrouter";

const config: OpenRouterConfig = {
apiKey: process.env.OPENROUTER_API_KEY!,
baseURL: "https://openrouter.ai/api/v1", // Optional
httpReferer: "https://your-app.com", // Optional, for rankings
xTitle: "Your App Name", // Optional, for rankings
};

const adapter = createOpenRouter(config.apiKey, config);
import { createOpenRouterText } from "@tanstack/ai-openrouter";

const adapter = createOpenRouterText(
"openai/gpt-5",
process.env.OPENROUTER_API_KEY!,
{
serverURL: "https://openrouter.ai/api/v1", // Optional
httpReferer: "https://your-app.com", // Optional, for rankings
appTitle: "Your App Name", // Optional, for rankings
},
);
```

## Available Models
Expand Down Expand Up @@ -122,18 +123,52 @@ OpenRouter can automatically route requests to the best available provider:
```typescript
const stream = chat({
adapter: openRouterText("openrouter/auto"),
messages,
providerOptions: {
messages,
modelOptions: {
models: [
"openai/gpt-4o",
"anthropic/claude-3.5-sonnet",
"google/gemini-pro",
],
route: "fallback", // Use fallback if primary fails
},
});
```


## Chat Completions vs Responses (beta)

OpenRouter exposes two OpenAI-compatible wire formats, and the adapter
package ships one of each:

| Adapter | Endpoint | Status | When to use |
| -------------------------- | ------------------------- | -------- | ---------------------------------------------------------------------------- |
| `openRouterText` | `/v1/chat/completions` | Stable | Default for almost everything. Broadest model + tool support. |
| `openRouterResponsesText` | `/v1/responses` | Beta | OpenAI Responses-shaped request/response; richer multi-turn state on OpenAI-style models. |

Both adapters route to any underlying model OpenRouter supports
(`anthropic/...`, `google/...`, `meta-llama/...`, etc.) β€” the wire format
describes how your client talks to OpenRouter, not which provider answers.
`/v1/responses` is OpenAI's newer API surface; OpenRouter implements it so
clients that prefer that wire format can use it across the same 300+
model catalogue.

```typescript
import { chat } from "@tanstack/ai";
import { openRouterResponsesText } from "@tanstack/ai-openrouter";

const stream = chat({
adapter: openRouterResponsesText("anthropic/claude-sonnet-4.5"),
messages: [{ role: "user", content: "Hello!" }],
});
```

Caveats while the Responses adapter is in beta:

- Function tools are supported; OpenRouter's branded server-tools (web
search, file search, …) are not yet wired through this path β€” use
`openRouterText` if you need those.
- If in doubt, prefer `openRouterText`. The Chat Completions endpoint has
broader provider coverage and feature parity today.

## Next Steps

- [Getting Started](../getting-started/quick-start) - Learn the basics
Expand Down
239 changes: 28 additions & 211 deletions packages/typescript/ai-anthropic/src/adapters/summarize.ts
Original file line number Diff line number Diff line change
@@ -1,237 +1,54 @@
import { BaseSummarizeAdapter } from '@tanstack/ai/adapters'
import {
createAnthropicClient,
generateId,
getAnthropicApiKeyFromEnv,
} from '../utils'
import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters'
import { getAnthropicApiKeyFromEnv } from '../utils'
import { AnthropicTextAdapter } from './text'
import type { InferTextProviderOptions } from '@tanstack/ai/adapters'
import type { ANTHROPIC_MODELS } from '../model-meta'
import type {
StreamChunk,
SummarizationOptions,
SummarizationResult,
} from '@tanstack/ai'
import type { AnthropicClientConfig } from '../utils'

/** Cast an event object to StreamChunk. */
const asChunk = (chunk: Record<string, unknown>) =>
chunk as unknown as StreamChunk

/**
* Configuration for Anthropic summarize adapter
*/
export interface AnthropicSummarizeConfig extends AnthropicClientConfig {}

/**
* Anthropic-specific provider options for summarization
*/
export interface AnthropicSummarizeProviderOptions {
/** Temperature for response generation (0-1) */
temperature?: number
/** Maximum tokens in the response */
maxTokens?: number
}

/** Model type for Anthropic summarization */
export type AnthropicSummarizeModel = (typeof ANTHROPIC_MODELS)[number]

/**
* Anthropic Summarize Adapter
*
* Tree-shakeable adapter for Anthropic summarization functionality.
* Import only what you need for smaller bundle sizes.
*/
export class AnthropicSummarizeAdapter<
TModel extends AnthropicSummarizeModel,
> extends BaseSummarizeAdapter<TModel, AnthropicSummarizeProviderOptions> {
readonly kind = 'summarize' as const
readonly name = 'anthropic' as const

private client: ReturnType<typeof createAnthropicClient>

constructor(config: AnthropicSummarizeConfig, model: TModel) {
super({}, model)
this.client = createAnthropicClient(config)
}

async summarize(options: SummarizationOptions): Promise<SummarizationResult> {
const { logger } = options
const systemPrompt = this.buildSummarizationPrompt(options)

logger.request(`activity=summarize provider=anthropic`, {
provider: 'anthropic',
model: options.model,
})

try {
const response = await this.client.messages.create({
model: options.model,
messages: [{ role: 'user', content: options.text }],
system: systemPrompt,
max_tokens: options.maxLength || 500,
temperature: 0.3,
stream: false,
})

const content = response.content
.map((c) => (c.type === 'text' ? c.text : ''))
.join('')

return {
id: response.id,
model: response.model,
summary: content,
usage: {
promptTokens: response.usage.input_tokens,
completionTokens: response.usage.output_tokens,
totalTokens:
response.usage.input_tokens + response.usage.output_tokens,
},
}
} catch (error) {
logger.errors('anthropic.summarize fatal', {
error,
source: 'anthropic.summarize',
})
throw error
}
}

async *summarizeStream(
options: SummarizationOptions,
): AsyncIterable<StreamChunk> {
const { logger } = options
const systemPrompt = this.buildSummarizationPrompt(options)
const id = generateId(this.name)
const model = options.model
let accumulatedContent = ''
let inputTokens = 0
let outputTokens = 0

logger.request(`activity=summarize provider=anthropic`, {
provider: 'anthropic',
model,
stream: true,
})

try {
const stream = await this.client.messages.create({
model: options.model,
messages: [{ role: 'user', content: options.text }],
system: systemPrompt,
max_tokens: options.maxLength || 500,
temperature: 0.3,
stream: true,
})

for await (const event of stream) {
logger.provider(`provider=anthropic type=${event.type}`, {
chunk: event,
})

if (event.type === 'message_start') {
inputTokens = event.message.usage.input_tokens
} else if (event.type === 'content_block_delta') {
if (event.delta.type === 'text_delta') {
const delta = event.delta.text
accumulatedContent += delta
yield asChunk({
type: 'TEXT_MESSAGE_CONTENT',
messageId: id,
model,
timestamp: Date.now(),
delta,
content: accumulatedContent,
})
}
} else if (event.type === 'message_delta') {
outputTokens = event.usage.output_tokens
yield asChunk({
type: 'RUN_FINISHED',
runId: id,
model,
timestamp: Date.now(),
finishReason: event.delta.stop_reason as
| 'stop'
| 'length'
| 'content_filter'
| null,
usage: {
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
},
})
}
}
} catch (error) {
logger.errors('anthropic.summarize fatal', {
error,
source: 'anthropic.summarize',
})
throw error
}
}

private buildSummarizationPrompt(options: SummarizationOptions): string {
let prompt = 'You are a professional summarizer. '

switch (options.style) {
case 'bullet-points':
prompt += 'Provide a summary in bullet point format. '
break
case 'paragraph':
prompt += 'Provide a summary in paragraph format. '
break
case 'concise':
prompt += 'Provide a very concise summary in 1-2 sentences. '
break
default:
prompt += 'Provide a clear and concise summary. '
}

if (options.focus && options.focus.length > 0) {
prompt += `Focus on the following aspects: ${options.focus.join(', ')}. `
}

if (options.maxLength) {
prompt += `Keep the summary under ${options.maxLength} tokens. `
}

return prompt
}
}

/**
* Creates an Anthropic summarize adapter with explicit API key.
* Type resolution happens here at the call site.
*
* @param model - The model name (e.g., 'claude-sonnet-4-5', 'claude-3-5-haiku-latest')
* @param apiKey - Your Anthropic API key
* @param config - Optional additional configuration
* @returns Configured Anthropic summarize adapter instance with resolved types
* @example
* ```typescript
* const adapter = createAnthropicSummarize('claude-sonnet-4-5', 'sk-ant-...');
* ```
*/
export function createAnthropicSummarize<
TModel extends AnthropicSummarizeModel,
>(
model: TModel,
apiKey: string,
config?: Omit<AnthropicSummarizeConfig, 'apiKey'>,
): AnthropicSummarizeAdapter<TModel> {
return new AnthropicSummarizeAdapter({ apiKey, ...config }, model)
): ChatStreamSummarizeAdapter<
TModel,
InferTextProviderOptions<AnthropicTextAdapter<TModel>>
> {
return new ChatStreamSummarizeAdapter(
new AnthropicTextAdapter({ apiKey, ...config }, model),
model,
'anthropic',
)
}

/**
* Creates an Anthropic summarize adapter with automatic API key detection.
* Type resolution happens here at the call site.
* Creates an Anthropic summarize adapter with API key from `ANTHROPIC_API_KEY`.
*
* @param model - The model name (e.g., 'claude-sonnet-4-5', 'claude-3-5-haiku-latest')
* @param config - Optional configuration (excluding apiKey which is auto-detected)
* @returns Configured Anthropic summarize adapter instance with resolved types
* @example
* ```typescript
* const adapter = anthropicSummarize('claude-sonnet-4-5');
* await summarize({ adapter, text: 'Long article text...' });
* ```
*/
export function anthropicSummarize<TModel extends AnthropicSummarizeModel>(
model: TModel,
config?: Omit<AnthropicSummarizeConfig, 'apiKey'>,
): AnthropicSummarizeAdapter<TModel> {
const apiKey = getAnthropicApiKeyFromEnv()
return createAnthropicSummarize(model, apiKey, config)
): ChatStreamSummarizeAdapter<
TModel,
InferTextProviderOptions<AnthropicTextAdapter<TModel>>
> {
return createAnthropicSummarize(model, getAnthropicApiKeyFromEnv(), config)
}
Loading
Loading