Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/kubb-provider-clients.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@tanstack/ai-utils': minor
'@tanstack/openai-base': minor
'@tanstack/ai-anthropic': patch
'@tanstack/ai-gemini': patch
'@tanstack/ai-mistral': patch
'@tanstack/ai-openrouter': patch
'@tanstack/ai-elevenlabs': patch
'@tanstack/ai-fal': patch
---

The provider clients now build their requests with a client that Kubb generates from the modelschemas OpenAPI document of each provider. Adapter options, results, errors, retries, and headers do not change. Calls that the documents do not describe (file uploads, job status, model lists) still use the hand-written code. `@tanstack/ai-utils` has a new `@tanstack/ai-utils/kubb` entry point with the shared runtime of the generated clients and `sendWith()`, which sends a generated request through a provider's own fetch code.

`createElevenLabsClient()` takes an optional `fetch`, like the ElevenLabs SDK option of the same name.

Each request body is now type-checked against the generated OpenAPI types.

Breaking for `@tanstack/openai-base`: `OpenAIClientLike` takes the generated request body types, so an `OpenAI` or `AzureOpenAI` SDK instance no longer type-checks as the adapter client. Build the client with `createOpenAIClient({ apiKey, baseURL })` instead. `@tanstack/openai-base` also exports the generated body types for speech, transcription, embeddings, image generation, image edit and video.

`@tanstack/ai-mistral` sends a `json_schema` response format in snake_case on the streaming path, as the API expects. `@tanstack/ai-elevenlabs` sends a string `entityDetection` or `entityRedaction` value in transcription requests, where before it dropped it.
102 changes: 102 additions & 0 deletions .github/workflows/sync-provider-clients.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Sync Provider Clients

# Refetches the modelschemas OpenAPI documents and checks the generated
# provider clients (scripts/provider-clients). When a document changed, it
# regenerates the clients and opens a PR. Separate from Sync Model Metadata.

on:
schedule:
- cron: '30 6 * * *'
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: write
pull-requests: write
issues: write # failure alert issue

jobs:
sync:
name: Sync Provider Clients
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: true # sync job pushes regenerated clients

- name: Setup Tools
uses: TanStack/config/.github/setup@190f659075ff0845850e330883eb26d7ffd0671f # main

- name: Check provider clients
id: check
continue-on-error: true
run: pnpm generate:provider-clients:check

- name: Regenerate provider clients
if: steps.check.outcome == 'failure'
run: pnpm generate:provider-clients

- name: Check for package changes
id: changes
run: |
if git diff --quiet -- packages/; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Commit and force-push
if: steps.changes.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/
git commit -m "chore: sync provider clients"
# GITHUB_TOKEN pushes do not start the PR Test / E2E workflows.
# After this job, a maintainer must run those checks from the
# Actions tab or push an empty commit to automated/sync-provider-clients.
git push --force origin HEAD:automated/sync-provider-clients

- name: Create PR
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
BRANCH="automated/sync-provider-clients"
EXISTING_PR=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$EXISTING_PR" ] || [ "$EXISTING_PR" = "null" ]; then
gh pr create \
--title "chore: sync provider clients" \
--body "Automated regeneration of the provider HTTP clients from the modelschemas OpenAPI documents (\`pnpm generate:provider-clients\`). Review the generated diff, then update the adapters if an operation they call changed." \
--base main \
--head "$BRANCH"
fi

- name: Open or update failure issue
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
shell: bash
run: |
gh label create sync-provider-clients --color B60205 --description "Daily provider client sync failures" --force
BODY=$(printf '%s\n' \
"Sync Provider Clients failed on $(date -u +%F)." \
"" \
"Run: $RUN_URL" \
"" \
"Close this issue when the sync is green again.")
ISSUE=$(gh issue list --label sync-provider-clients --state open --json number --jq '.[0].number // empty')
if [ -n "$ISSUE" ]; then
gh issue comment "$ISSUE" --body "$BODY"
else
gh issue create --label sync-provider-clients \
--title "Daily provider client sync is failing" \
--body "$BODY"
fi
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ Do not rebase or hand-edit `automated/sync-models`. The next scheduled run force

The workflow pushes with `GITHUB_TOKEN`, so GitHub does not start Test / E2E on that push. After a sync, a maintainer with write access can run the PR checks from the Actions tab, or push an empty commit to `automated/sync-models`.

## Syncing provider clients

The provider HTTP clients are generated. [Kubb](https://kubb.dev) reads the OpenAPI document that [modelschemas](https://modelschemas.com) publishes for each provider (`GET /v1/openapi/{provider}`). Do not edit the generated files by hand.

| Goal | Command |
| ---------------------------------------- | -------------------------------------- |
| Regenerate the clients | `pnpm generate:provider-clients` |
| Fail when a live document changes output | `pnpm generate:provider-clients:check` |

- `scripts/provider-clients/providers.ts` lists the providers and the package of each one. A package that speaks another provider's API (Groq, Grok, Azure, and the gateways use the OpenAI API) uses that provider's client. It does not get its own copy.
- Each client is written to `src/generated/client/` in its package (`sdk.ts` and `types.ts`). All clients share one runtime from `@tanstack/ai-utils/kubb`.
- A provider is skipped when modelschemas has no document for it (`missing_connect`, `no_endpoints`, `unknown_provider`). Its committed client does not change.
- The generated functions build the path and the body. The hand-written code in each package's `src/client/` still does auth, retries, errors, SSE parsing, and audio bytes. It also sends the calls that the documents do not describe, such as file uploads and job status.

The daily **Sync Provider Clients** workflow runs the check. When a document changed, it regenerates the clients and opens a PR from `automated/sync-provider-clients`. Review the diff, then update the adapters if an operation that they call changed.

## Day-to-day commands

All commands are run from the repo root. Nx handles affected detection and caching.
Expand Down
8 changes: 5 additions & 3 deletions docs/advanced/extend-adapter-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ Your adapter talks to a thinking model. You want `chat({ reasoning: 'high' })` t
On an adapter that extends `@tanstack/openai-base`, that is all. The base sends `reasoning_effort` on Chat Completions, or `reasoning: { effort, summary }` on Responses:

```typescript
import OpenAI from 'openai'
import { chat } from '@tanstack/ai'
import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
import {
OpenAIBaseChatCompletionsTextAdapter,
createOpenAIClient,
} from '@tanstack/openai-base'
import type {
DefaultMessageMetadataByModality,
Modality,
Expand Down Expand Up @@ -117,7 +119,7 @@ class MyTextAdapter<TModel extends MyModel> extends OpenAIBaseChatCompletionsTex
const adapter = new MyTextAdapter(
'my-model',
'my-provider',
new OpenAI({ apiKey: 'my-key', baseURL: 'https://api.example.com/v1' }),
createOpenAIClient({ apiKey: 'my-key', baseURL: 'https://api.example.com/v1' }),
)

chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], reasoning: 'high', threadId: 'thread-1' })
Expand Down
10 changes: 8 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"ignoreDependencies": [
"@faker-js/faker",
"@aws-sdk/client-bedrock",
"@typescript/typescript6"
"@typescript/typescript6",
"@kubb/plugin-client",
"@kubb/plugin-fetch",
"@kubb/plugin-ts"
],
"ignoreBinaries": ["taskkill", "ps"],
"ignoreWorkspaces": [
Expand All @@ -25,7 +28,10 @@
"packages/ai-openai/src/audio/audio-provider-options.ts",
"packages/ai-devtools/src/production.ts",
"codemods/**/__testfixtures__/**",
"packages/ai-persistence/skills-test/stubs/**"
"packages/ai-persistence/skills-test/stubs/**",
"packages/*/src/generated/client/sdk.ts",
"packages/*/src/generated/client/types.ts",
"packages/ai-utils/src/generated/kubb/**"
],
"ignoreExportsUsedInFile": true,
"workspaces": {
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"test:knip": "knip",
"test:docs": "tsx scripts/verify-links.ts",
"test:dts": "node scripts/scan-dangling-dts.mjs",
"test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts && vitest run scripts/coverage-check.test.ts scripts/print-failed-nx-logs.test.ts",
"test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/provider-clients scripts/publish-staged-docs.test.ts && vitest run scripts/coverage-check.test.ts scripts/print-failed-nx-logs.test.ts",
"test:ai-review": "vitest run --root agent-scripts/ai-review",
"ai-review": "node agent-scripts/ai-review/run.ts",
"maintainer:sweep": "node scripts/maintainer/sweep.ts",
Expand All @@ -45,6 +45,8 @@
"dev:chat": "pnpm --filter ts-react-chat dev",
"format": "oxfmt .",
"generate-docs": "node scripts/generate-docs.ts && pnpm run copy:readme",
"generate:provider-clients": "node scripts/provider-clients/generate.ts",
"generate:provider-clients:check": "node scripts/provider-clients/generate.ts --check",
"generate:fal-image-fields": "tsx scripts/generate-fal-image-field-map.ts",
"generate:models": "pnpm generate:models:fetch && pnpm regenerate:models && tsx scripts/sync-elevenlabs-models.ts && tsx scripts/sync-provider-models.ts && pnpm format",
"generate:models:elevenlabs": "tsx scripts/sync-elevenlabs-models.ts",
Expand Down Expand Up @@ -72,6 +74,9 @@
"@changesets/changelog-github": "^1.0.1",
"@changesets/cli": "^2.30.0",
"@faker-js/faker": "^10.1.0",
"@kubb/plugin-client": "^5.10.0",
"@kubb/plugin-fetch": "^5.13.0",
"@kubb/plugin-ts": "^5.4.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@tanstack/ai": "workspace:*",
"@tanstack/ai-grok-build": "workspace:*",
Expand All @@ -85,6 +90,7 @@
"happy-dom": "^20.11.2",
"kiira": "0.6.0",
"knip": "^6.26.0",
"kubb": "^5.16.1",
"markdown-link-extractor": "^4.0.3",
"nx": "23.1.1",
"oxfmt": "^0.59.0",
Expand Down
74 changes: 61 additions & 13 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ import type {
AnthropicMessagesClient,
} from '../utils/client'
import type { ComputerUseSingleToolConfig } from '../tools/computer-use-tool'
import type { AnthropicClient } from '../client/index'
import type { AnthropicClient, MessageCreateParams } from '../client/index'
import type { AnthropicPostV1MessagesBetaTrueBody } from '../generated/client/types'

/**
* The block type carried by an Anthropic provider-executed (server) tool's
Expand Down Expand Up @@ -222,6 +223,34 @@ function toAnthropicToolChoice(
return { type: 'tool' as const, name: choice.name }
}

/**
* The request as the generated Messages body. Each marked field is where the
* SDK-typed request and the generated document differ.
*/
function messagesBody({
messages,
tools,
temperature,
top_k,
top_p,
...rest
}: InternalTextProviderOptions): AnthropicPostV1MessagesBetaTrueBody {
const sampling: Partial<AnthropicPostV1MessagesBetaTrueBody> = {
// @ts-expect-error modelschemas gap: the Messages body has no temperature, top_k or top_p
temperature,
top_k,
top_p,
}
return {
...rest,
...sampling,
// @ts-expect-error public type mismatch: BetaMessageParam vs BetaInputMessageUnion, messages
messages,
// @ts-expect-error public type mismatch: BetaTool vs BetaToolUnion, tools
tools,
}
}

/** Models already warned that `chat({ reasoning })` does nothing for them. */
const modelsWarnedWithoutReasoning = new Set<string>()

Expand Down Expand Up @@ -330,7 +359,7 @@ export function computeAnthropicBetas(
modelOptions:
| {
thinking?: {
type?: 'enabled' | 'disabled' | 'adaptive'
type?: string
budget_tokens?: number
}
context_management?: unknown | null
Expand Down Expand Up @@ -503,22 +532,39 @@ type ResolveToolCapabilities<TModel extends string> =
? NonNullable<AnthropicChatModelToolCapabilitiesByName[TModel]>
: readonly []

type SdkAnthropicMessagesClient = {
type CreateOptions = Parameters<
AnthropicClient['beta']['messages']['create']
>[1]

/** The client's `create`, typed against the generated request body. */
type AnthropicMessagesCreateClient = {
beta: {
messages: Pick<Anthropic_SDK['beta']['messages'], 'create'>
messages: {
create: {
(
params: MessageCreateParams & { stream: true },
options?: CreateOptions,
): Promise<AsyncIterable<Anthropic_SDK.Beta.BetaRawMessageStreamEvent>>
(
params: MessageCreateParams & { stream?: false },
options?: CreateOptions,
): Promise<Anthropic_SDK.Beta.BetaMessage>
}
}
}
}

/**
* Restore the package SDK's precise overloads at the adapter boundary.
* Alternative clients may use a separate Anthropic 0.x SDK whose declarations
* drift while implementing the same Messages protocol at runtime.
* Restore the stream and non-stream overloads at the adapter boundary. The
* public client type erases them, because alternative clients may use a
* separate Anthropic 0.x SDK whose declarations drift while implementing the
* same Messages protocol at runtime.
*/
function asSdkAnthropicMessagesClient(
client: AnthropicMessagesClient,
): SdkAnthropicMessagesClient {
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- The public callable deliberately erases version-specific SDK overloads; restore this package's SDK type at the internal boundary.
return client as unknown as SdkAnthropicMessagesClient
client: AnthropicMessagesClient | AnthropicClient,
): AnthropicMessagesCreateClient {
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- The public callable deliberately erases the overloads; restore them at the internal boundary.
return client as unknown as AnthropicMessagesCreateClient
}

// ===========================
Expand Down Expand Up @@ -564,7 +610,7 @@ export class AnthropicTextAdapter<
| MidConversationChannels
| undefined = undefined

private readonly client: SdkAnthropicMessagesClient
private readonly client: AnthropicMessagesCreateClient
private readonly allowEmptySignature: boolean
/**
* With `auth: 'oauth'`: add the Claude Code identity system block, the CLI
Expand Down Expand Up @@ -1157,7 +1203,9 @@ export class AnthropicTextAdapter<
// Last step: the merged messages decide which message is last.
return {
...toolChoiceField,
...applyAnthropicPromptCache(requestParams, options.promptCache),
...messagesBody(
applyAnthropicPromptCache(requestParams, options.promptCache),
),
}
}

Expand Down
Loading
Loading