From 9fa051f3c951b73c9e6de28c807c467f4b4c890d Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sun, 22 Feb 2026 09:57:39 +0000 Subject: [PATCH 1/3] Rewrite ai-gateway-provider API and docs Major rewrite of packages/ai-gateway-provider: new public API (createAIGateway and createAIGatewayFallback) that wraps standard AI SDK providers instead of providing many internal provider wrappers. Removes src/auth.ts and all src/providers/* wrapper files, drops optionalDependencies and subpath provider exports, and updates package.json scripts and peerDeps. index.ts was refactored to add resolveProvider, capture/restore fetch logic, request normalization, BYOK auth stripping, cf-aig-* gateway option headers, and binding vs REST dispatch. README and new chat.md updated with usage, options, supported providers, and Workers guidance. Added comprehensive unit, integration, and e2e tests and accompanying changeset files. --- .changeset/ai-gateway-provider-rewrite.md | 30 + .changeset/workers-ai-gateway-tests.md | 5 + .oxlintrc.json | 3 +- packages/ai-gateway-provider/README.md | 479 +++++++++---- packages/ai-gateway-provider/package.json | 117 +--- packages/ai-gateway-provider/src/auth.ts | 17 - packages/ai-gateway-provider/src/index.ts | 567 ++++++++++------ packages/ai-gateway-provider/src/providers.ts | 66 +- .../src/providers/amazon-bedrock.ts | 5 - .../src/providers/anthropic.ts | 5 - .../src/providers/azure.ts | 5 - .../src/providers/cerebras.ts | 5 - .../src/providers/cohere.ts | 5 - .../src/providers/deepgram.ts | 5 - .../src/providers/deepseek.ts | 5 - .../src/providers/elevenlabs.ts | 5 - .../src/providers/fireworks.ts | 5 - .../src/providers/google-vertex.ts | 14 - .../src/providers/google.ts | 6 - .../ai-gateway-provider/src/providers/groq.ts | 5 - .../src/providers/index.ts | 16 - .../src/providers/mistral.ts | 5 - .../src/providers/openai.ts | 5 - .../src/providers/openrouter.ts | 5 - .../src/providers/perplexity.ts | 5 - .../src/providers/unified.ts | 12 - .../ai-gateway-provider/src/providers/xai.ts | 5 - .../ai-gateway-provider/test/endpoint.test.ts | 66 -- .../test/integration/.env.example | 6 + .../test/integration/binding.test.ts | 161 +++++ .../test/integration/global-setup.ts | 105 +++ .../test/integration/rest-api.test.ts | 227 +++++++ .../integration/vitest.integration.config.ts | 10 + .../test/integration/worker/src/index.ts | 101 +++ .../test/integration/worker/wrangler.jsonc | 8 + .../test/stream-text.test.ts | 110 --- .../test/text-generation.test.ts | 65 -- .../test/unit/fallback.test.ts | 313 +++++++++ .../test/unit/gateway.test.ts | 637 ++++++++++++++++++ .../test/unit/options.test.ts | 118 ++++ .../test/unit/providers.test.ts | 157 +++++ .../test/unit/resolve-provider.test.ts | 185 +++++ packages/ai-gateway-provider/tsup.config.ts | 6 +- packages/ai-gateway-provider/vitest.config.ts | 1 + packages/workers-ai-provider/README.md | 28 +- .../e2e/fixtures/binding-worker/src/index.ts | 5 +- .../test/e2e/workers-ai-gateway.e2e.test.ts | 213 ++++++ 47 files changed, 3072 insertions(+), 857 deletions(-) create mode 100644 .changeset/ai-gateway-provider-rewrite.md create mode 100644 .changeset/workers-ai-gateway-tests.md delete mode 100644 packages/ai-gateway-provider/src/auth.ts delete mode 100644 packages/ai-gateway-provider/src/providers/amazon-bedrock.ts delete mode 100644 packages/ai-gateway-provider/src/providers/anthropic.ts delete mode 100644 packages/ai-gateway-provider/src/providers/azure.ts delete mode 100644 packages/ai-gateway-provider/src/providers/cerebras.ts delete mode 100644 packages/ai-gateway-provider/src/providers/cohere.ts delete mode 100644 packages/ai-gateway-provider/src/providers/deepgram.ts delete mode 100644 packages/ai-gateway-provider/src/providers/deepseek.ts delete mode 100644 packages/ai-gateway-provider/src/providers/elevenlabs.ts delete mode 100644 packages/ai-gateway-provider/src/providers/fireworks.ts delete mode 100644 packages/ai-gateway-provider/src/providers/google-vertex.ts delete mode 100644 packages/ai-gateway-provider/src/providers/google.ts delete mode 100644 packages/ai-gateway-provider/src/providers/groq.ts delete mode 100644 packages/ai-gateway-provider/src/providers/index.ts delete mode 100644 packages/ai-gateway-provider/src/providers/mistral.ts delete mode 100644 packages/ai-gateway-provider/src/providers/openai.ts delete mode 100644 packages/ai-gateway-provider/src/providers/openrouter.ts delete mode 100644 packages/ai-gateway-provider/src/providers/perplexity.ts delete mode 100644 packages/ai-gateway-provider/src/providers/unified.ts delete mode 100644 packages/ai-gateway-provider/src/providers/xai.ts delete mode 100644 packages/ai-gateway-provider/test/endpoint.test.ts create mode 100644 packages/ai-gateway-provider/test/integration/.env.example create mode 100644 packages/ai-gateway-provider/test/integration/binding.test.ts create mode 100644 packages/ai-gateway-provider/test/integration/global-setup.ts create mode 100644 packages/ai-gateway-provider/test/integration/rest-api.test.ts create mode 100644 packages/ai-gateway-provider/test/integration/vitest.integration.config.ts create mode 100644 packages/ai-gateway-provider/test/integration/worker/src/index.ts create mode 100644 packages/ai-gateway-provider/test/integration/worker/wrangler.jsonc delete mode 100644 packages/ai-gateway-provider/test/stream-text.test.ts delete mode 100644 packages/ai-gateway-provider/test/text-generation.test.ts create mode 100644 packages/ai-gateway-provider/test/unit/fallback.test.ts create mode 100644 packages/ai-gateway-provider/test/unit/gateway.test.ts create mode 100644 packages/ai-gateway-provider/test/unit/options.test.ts create mode 100644 packages/ai-gateway-provider/test/unit/providers.test.ts create mode 100644 packages/ai-gateway-provider/test/unit/resolve-provider.test.ts create mode 100644 packages/workers-ai-provider/test/e2e/workers-ai-gateway.e2e.test.ts diff --git a/.changeset/ai-gateway-provider-rewrite.md b/.changeset/ai-gateway-provider-rewrite.md new file mode 100644 index 000000000..c213ee911 --- /dev/null +++ b/.changeset/ai-gateway-provider-rewrite.md @@ -0,0 +1,30 @@ +--- +"ai-gateway-provider": major +--- + +Rewrote the AI Gateway provider with a new API. `createAIGateway` now wraps any standard AI SDK provider as a drop-in replacement — no more importing wrapped providers from `ai-gateway-provider/providers/*`. + +**New features:** + +- `createAIGateway({ provider, binding/accountId })` — wraps any AI SDK provider +- `createAIGatewayFallback({ models, binding/accountId })` — cross-provider fallback +- `providerName` — explicit provider name for custom base URLs +- `byok: true` — strips provider auth headers for BYOK/Unified Billing +- `byokAlias` and `zdr` gateway options +- 22 providers auto-detected from URL (up from 13) +- `resolveProvider` exported as a utility + +**Bug fixes:** + +- Updated stale cache header names (`cf-skip-cache` → `cf-aig-skip-cache`, `cf-cache-ttl` → `cf-aig-cache-ttl`) +- Fixed fetch mutation not being restored after gateway calls +- Removed unanchored Google Vertex regex that could match unintended URLs +- Fixed query parameter loss for custom URLs with `providerName` + +**Breaking changes:** + +- `createAiGateway` → `createAIGateway` (new API shape — takes `provider` instead of model arrays) +- Removed all `ai-gateway-provider/providers/*` subpath exports +- Removed `@ai-sdk/openai-compatible` from peer dependencies +- Removed all `optionalDependencies` +- Fallback is now a separate `createAIGatewayFallback` function diff --git a/.changeset/workers-ai-gateway-tests.md b/.changeset/workers-ai-gateway-tests.md new file mode 100644 index 000000000..eba26be04 --- /dev/null +++ b/.changeset/workers-ai-gateway-tests.md @@ -0,0 +1,5 @@ +--- +"workers-ai-provider": patch +--- + +Added AI Gateway e2e tests for the binding path. The test worker now accepts a `?gateway=` query parameter to route requests through a specified AI Gateway, validating that the built-in `gateway` option works for chat, streaming, multi-turn, tool calling, structured output, embeddings, and image generation. diff --git a/.oxlintrc.json b/.oxlintrc.json index 0b883c8d4..ef2cf40f3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -19,7 +19,8 @@ "no-await-in-loop": "off", "react/react-in-jsx-scope": "off", "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "eslint(no-shadow)": "off" }, "ignorePatterns": ["**/worker-configuration.d.ts", "types/workerd.d.ts"] } diff --git a/packages/ai-gateway-provider/README.md b/packages/ai-gateway-provider/README.md index b52f29eac..8ba686310 100644 --- a/packages/ai-gateway-provider/README.md +++ b/packages/ai-gateway-provider/README.md @@ -2,12 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -This library provides an AI Gateway Provider for the [Vercel AI SDK](https://sdk.vercel.ai/docs), enabling you to seamlessly integrate multiple AI models from different providers behind a unified interface. It leverages Cloudflare's AI Gateway to manage and optimize your AI model usage. - -## Features - -- **Runtime Agnostic:** Works in all JavaScript runtimes supported by the Vercel AI SDK including Node.js, Edge Runtime, and more. -- **Automatic Provider Fallback:** ✨ Define an array of models and the provider will **automatically fallback** to the next available provider if one fails, ensuring high availability and resilience for your AI applications. +Route any [Vercel AI SDK](https://sdk.vercel.ai/docs) provider through [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/). Wrap your provider with `createAIGateway` and use it exactly as before — caching, logging, rate limiting, retries, and analytics are handled by the gateway. ## Installation @@ -15,205 +10,417 @@ This library provides an AI Gateway Provider for the [Vercel AI SDK](https://sdk npm install ai-gateway-provider ``` -## Usage +You also need the AI SDK core and at least one provider: -### Basic Example with API Key +```bash +npm install ai @ai-sdk/openai +``` + +## Quick start ```typescript -import { createAiGateway } from "ai-gateway-provider"; -import { createOpenAI } from "ai-gateway-provider/providers/openai"; +import { createAIGateway } from "ai-gateway-provider"; +import { createOpenAI } from "@ai-sdk/openai"; import { generateText } from "ai"; -const aigateway = createAiGateway({ - accountId: "{CLOUDFLARE_ACCOUNT_ID}", - gateway: "{GATEWAY_NAME}", - apiKey: "{CF_AIG_TOKEN}", // If your AI Gateway has authentication enabled -}); +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); -const openai = createOpenAI({ apiKey: "{OPENAI_API_KEY}" }); +const gateway = createAIGateway({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + gateway: "my-gateway", + apiKey: process.env.CLOUDFLARE_API_TOKEN, // if gateway auth is enabled + provider: openai, +}); const { text } = await generateText({ - model: aigateway(openai.chat("gpt-5.1")), - prompt: "Write a vegetarian lasagna recipe for 4 people.", + model: gateway("gpt-4o-mini"), + prompt: "Write a haiku about clouds.", }); ``` -### Basic Examples with BYOK / Unified Billing +## Configuration modes -```typescript -import { createAiGateway } from "ai-gateway-provider"; -import { createOpenAI } from "ai-gateway-provider/providers/openai"; -import { generateText } from "ai"; +### REST API mode -const aigateway = createAiGateway({ - accountId: "{CLOUDFLARE_ACCOUNT_ID}", - gateway: "{GATEWAY_NAME}", - apiKey: "{CF_AIG_TOKEN}", +For use outside Cloudflare Workers. Requests go over the public internet to `gateway.ai.cloudflare.com`. + +```typescript +const gateway = createAIGateway({ + provider: openai, + accountId: "your-account-id", + gateway: "your-gateway-name", + apiKey: "your-cf-api-token", // optional, required if gateway auth is on }); +``` + +### Cloudflare Worker binding mode + +For use inside Cloudflare Workers. Faster (no public internet hop), more secure (pre-authenticated), and no API token needed. + +```typescript +export default { + async fetch(request, env) { + const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }); + + const gateway = createAIGateway({ + provider: openai, + binding: env.AI.gateway("my-gateway"), + }); + + const { text } = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello!", + }); + + return new Response(text); + }, +}; +``` + +Requires an `ai` binding in your `wrangler.jsonc`: + +```jsonc +{ + "ai": { "binding": "AI" }, +} +``` + +### BYOK / Unified Billing (no provider API key) + +With [BYOK](https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/) or [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/), Cloudflare manages the provider credentials — you don't need a real API key. Set `byok: true` and pass any string as the provider's API key: + +```typescript +const openai = createOpenAI({ apiKey: "unused" }); -const openai = createOpenAI(); +const gateway = createAIGateway({ + provider: openai, + byok: true, // strips provider auth headers so the gateway uses its stored key + binding: env.AI.gateway("my-gateway"), + options: { + byokAlias: "production", // optional: select a specific stored key alias + }, +}); const { text } = await generateText({ - model: aigateway(openai.chat("gpt-5.1")), - prompt: "Write a vegetarian lasagna recipe for 4 people.", + model: gateway("gpt-4o-mini"), + prompt: "Hello!", }); ``` -### Unified API / Dynamic Routes +The dummy key satisfies the provider SDK's initialization. `byok: true` strips the provider auth headers (e.g., `Authorization`, `x-api-key`) from the request before sending it to the gateway, so the gateway uses its stored BYOK key (or Unified Billing credits) instead. + +> **Note:** If you use the default provider export (e.g., `import { openai } from "@ai-sdk/openai"`) instead of `createOpenAI()`, it reads from the `OPENAI_API_KEY` environment variable. Set it to any value: `OPENAI_API_KEY=unused`. + +## Gateway options + +Pass `options` to control caching, logging, retries, and more. These are sent as headers to the AI Gateway and apply to every request through that gateway instance. ```typescript -import { createAiGateway } from "ai-gateway-provider"; -import { unified, createUnified } from "ai-gateway-provider/providers/unified"; -import { generateText } from "ai"; +const gateway = createAIGateway({ + provider: openai, + accountId: "...", + gateway: "my-gateway", + options: { + // Caching + cacheTtl: 3600, // cache responses for 1 hour (seconds) + skipCache: false, // bypass the cache for this request + cacheKey: "my-key", // custom cache key + + // Logging + collectLog: true, // override the gateway's default log setting -const aigateway = createAiGateway({ - accountId: "{{CLOUDFLARE_ACCOUNT_ID}}", - gateway: "{{GATEWAY_NAME}}", - apiKey: "{{CF_AIG_TOKEN}}", + // Observability + metadata: { userId: "u123", env: "prod" }, // up to 5 entries + eventId: "evt-abc", // correlation ID + + // Reliability + requestTimeoutMs: 10000, // request timeout + retries: { + maxAttempts: 3, // 1–5 + retryDelayMs: 1000, + backoff: "exponential", // "constant" | "linear" | "exponential" + }, + + // BYOK + byokAlias: "production", // select a stored key alias + + // Zero Data Retention (Unified Billing only) + zdr: true, // route through ZDR-capable endpoints + }, }); +``` -const { text } = await generateText({ - model: aigateway(unified("dynamic/customer-support")), - prompt: "Write a vegetarian lasagna recipe for 4 people.", +## Streaming + +Works with `streamText` exactly as you'd expect: + +```typescript +import { streamText } from "ai"; + +const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Write a poem about clouds.", }); + +for await (const chunk of result.textStream) { + process.stdout.write(chunk); +} ``` -## Automatic Fallback Example +## Fallback + +Use `createAIGatewayFallback` to define a chain of models. The AI Gateway tries each in order and returns the first successful response. All models are sent in a single gateway request — the gateway handles the fallback logic server-side. ```typescript -// Define multiple provider options with fallback priority -const model = aigateway([ - anthropic("claude-3-5-haiku-20241022"), // Primary choice - openai.chat("gpt-4o-mini"), // First fallback - mistral("mistral-large-latest"), // Second fallback -]); - -// The system will automatically try the next model if previous ones fail -const { text } = await generateText({ - model, - prompt: "Suggest three names for my tech startup.", +import { createAIGatewayFallback } from "ai-gateway-provider"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText } from "ai"; + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); + +const model = createAIGatewayFallback({ + // Gateway config (shared across all models) + accountId: "your-account-id", + gateway: "my-gateway", + apiKey: "your-cf-token", + // Models to try, in order + models: [ + openai("gpt-4o"), // primary + openai("gpt-4o-mini"), // fallback + ], }); + +const { text } = await generateText({ model, prompt: "Hello!" }); ``` -### Cloudflare AI Binding Example +Works with bindings too: -Binding Benefits: +```typescript +const model = createAIGatewayFallback({ + binding: env.AI.gateway("my-gateway"), + models: [openai("gpt-4o"), openai("gpt-4o-mini")], +}); +``` -- Faster Requests: Saves milliseconds by avoiding open internet routing. -- Enhanced Security: Uses a special pre-authenticated pipeline. -- No Cloudflare API Token Required: Authentication is handled by the binding. +Models from different providers can be mixed — as long as each URL is auto-detected from the [provider registry](#supported-providers): ```typescript -const aigateway = createAiGateway({ +import { createOpenAI } from "@ai-sdk/openai"; +import { createAnthropic } from "@ai-sdk/anthropic"; + +const model = createAIGatewayFallback({ binding: env.AI.gateway("my-gateway"), - options: { - // Optional per-request override - skipCache: true, - }, + models: [ + createAnthropic({ apiKey: "..." })("claude-sonnet-4-20250514"), + createOpenAI({ apiKey: "..." })("gpt-4o"), + ], }); -const openai = createOpenAI({ apiKey: "openai api key" }); -const anthropic = createAnthropic({ apiKey: "anthropic api key" }); +``` -const model = aigateway([ - anthropic("claude-3-5-haiku-20241022"), // Primary choice - openai.chat("gpt-4o-mini"), // Fallback if first fails -]); +## Custom base URLs (`providerName`) -const { text } = await generateText({ - model: model, - prompt: "Write a vegetarian lasagna recipe for 4 people.", +If your provider uses a non-standard base URL (proxy, self-hosted, etc.), the gateway can't auto-detect which provider it is from the URL. Set `providerName` explicitly: + +```typescript +const openai = createOpenAI({ + apiKey: "...", + baseURL: "https://my-proxy.example.com/v1", +}); + +const gateway = createAIGateway({ + provider: openai, + providerName: "openai", // tells the gateway which provider this is + accountId: "...", + gateway: "my-gateway", }); ``` -### Request-Level Options +When `providerName` is set: + +- The gateway uses that name instead of auto-detecting from the URL +- If the URL matches a known provider, the smart endpoint transform is still used +- If the URL doesn't match, the URL pathname is used as the endpoint + +## Unified API (compat endpoint) -You can now customize AI Gateway settings for each request: +The AI Gateway offers an OpenAI-compatible endpoint that can route to any provider. Use the standard OpenAI SDK pointed at the compat base URL: ```typescript -const aigateway = createAiGateway({ - // ... other configs +import { createOpenAI } from "@ai-sdk/openai"; - options: { - // all fields are optional! - cacheKey: "my-custom-cache-key", - cacheTtl: 3600, // Cache for 1 hour - skipCache: false, - metadata: { - userId: "user123", - requestType: "recipe", - }, - retries: { - maxAttempts: 3, - retryDelayMs: 1000, - backoff: "exponential", - }, - }, +const compat = createOpenAI({ + apiKey: "unused", // BYOK or Unified Billing handles auth + baseURL: "https://gateway.ai.cloudflare.com/v1/compat", +}); + +const gateway = createAIGateway({ + provider: compat, + accountId: "...", + gateway: "my-gateway", + apiKey: "your-cf-token", +}); + +// Use any provider's model via the compat endpoint +const { text } = await generateText({ + model: gateway("google-ai-studio/gemini-2.5-pro"), + prompt: "Hello!", }); ``` -## Configuration +This also works with [Dynamic Routes](https://developers.cloudflare.com/ai-gateway/features/dynamic-routing/) — use your route name as the model (e.g., `gateway("dynamic/support")`). + +## API reference + +### `createAIGateway(config)` + +Wraps a provider so all its requests go through AI Gateway. Returns a function with the same call signature as the original provider. + +| Field | Type | Required | Description | +| -------------- | ----------------- | ------------ | --------------------------------------------------------- | +| `provider` | Provider function | Yes | Any AI SDK provider (e.g., `openai`, `createOpenAI(...)`) | +| `providerName` | string | No | Explicit provider name for the gateway (see above) | +| `byok` | boolean | No | Strip provider auth headers for BYOK / Unified Billing | +| `accountId` | string | REST only | Your Cloudflare account ID | +| `gateway` | string | REST only | Name of your AI Gateway | +| `apiKey` | string | No | Cloudflare API token (if gateway auth is enabled) | +| `binding` | object | Binding only | `env.AI.gateway("name")` binding | +| `options` | AiGatewayOptions | No | Gateway options (see table below) | + +### `createAIGatewayFallback(config)` + +Creates a fallback chain of models behind a single gateway. The gateway tries each model in order and returns the first successful response. + +| Field | Type | Required | Description | +| ----------- | ----------------- | ------------ | ------------------------------------------------------ | +| `models` | LanguageModelV3[] | Yes | Models to try, in priority order | +| `byok` | boolean | No | Strip provider auth headers for BYOK / Unified Billing | +| `accountId` | string | REST only | Your Cloudflare account ID | +| `gateway` | string | REST only | Name of your AI Gateway | +| `apiKey` | string | No | Cloudflare API token | +| `binding` | object | Binding only | `env.AI.gateway("name")` binding | +| `options` | AiGatewayOptions | No | Gateway options (see below) | + +### Gateway options + +| Option | Type | Header | Description | +| ---------------------- | ------- | ------------------------ | -------------------------------------------- | +| `cacheTtl` | number | `cf-aig-cache-ttl` | Cache TTL in seconds (min 60, max 1 month) | +| `skipCache` | boolean | `cf-aig-skip-cache` | Bypass the cache | +| `cacheKey` | string | `cf-aig-cache-key` | Custom cache key | +| `metadata` | object | `cf-aig-metadata` | Up to 5 key-value pairs for tagging requests | +| `collectLog` | boolean | `cf-aig-collect-log` | Override default log collection setting | +| `eventId` | string | `cf-aig-event-id` | Custom event / correlation ID | +| `requestTimeoutMs` | number | `cf-aig-request-timeout` | Request timeout in milliseconds | +| `retries.maxAttempts` | 1–5 | `cf-aig-max-attempts` | Number of retry attempts | +| `retries.retryDelayMs` | number | `cf-aig-retry-delay` | Delay between retries (ms) | +| `retries.backoff` | string | `cf-aig-backoff` | `"constant"`, `"linear"`, or `"exponential"` | +| `byokAlias` | string | `cf-aig-byok-alias` | Select a stored BYOK key alias | +| `zdr` | boolean | `cf-aig-zdr` | Zero Data Retention (Unified Billing only) | + +### Error classes + +| Error | When | +| ---------------------------- | --------------------------------------------------------- | +| `AiGatewayDoesNotExist` | The specified gateway name doesn't exist | +| `AiGatewayUnauthorizedError` | Gateway auth is enabled but no valid API key was provided | -### `createAiGateway(options: AiGatewaySettings)` +### `resolveProvider(url, providerName?)` -#### API Key Authentication +Exported utility that matches a URL against the provider registry. Returns `{ name, endpoint }`. Throws if no match and no `providerName` is given. -- `accountId`: Your Cloudflare account ID -- `gateway`: The name of your AI Gateway -- `apiKey` (Optional): Your Cloudflare API key +## Workers AI -#### Cloudflare AI Binding +Workers AI uses a binding (`env.AI.run()`) rather than HTTP, so it can't be wrapped with `createAIGateway`. It has built-in gateway support instead: -- `binding`: Cloudflare AI Gateway binding -- `options` (Optional): Request-level AI Gateway settings +```typescript +import { createWorkersAI } from "workers-ai-provider"; +import { generateText } from "ai"; -### Request Options +const workersAI = createWorkersAI({ + binding: env.AI, + gateway: { id: "my-gateway" }, +}); -- `cacheKey`: Custom cache key for the request -- `cacheTtl`: Cache time-to-live in seconds -- `skipCache`: Bypass caching for the request -- `metadata`: Custom metadata for the request -- `collectLog`: Enable/disable log collection -- `eventId`: Custom event identifier -- `requestTimeoutMs`: Request timeout in milliseconds -- `retries`: Retry configuration - - `maxAttempts`: Number of retry attempts (1-5) - - `retryDelayMs`: Delay between retries - - `backoff`: Retry backoff strategy ('constant', 'linear', 'exponential') +const { text } = await generateText({ + model: workersAI("@cf/meta/llama-3.1-8b-instruct-fast"), + prompt: "Hello!", +}); +``` -## Supported Providers +This routes Workers AI requests through the AI Gateway natively — no wrapping needed. All gateway features (caching, logging, analytics) apply. + +> **Note:** The `gateway` option only works with the binding. In REST mode (`createWorkersAI({ accountId, apiKey })`), the gateway option is ignored. For REST + gateway, use the binding approach inside a Cloudflare Worker instead. + +## Supported providers + +Auto-detected from URL (no `providerName` needed): + +| Provider | AI SDK Package | Gateway Name | +| ---------------- | ----------------------------- | ------------------ | +| OpenAI | `@ai-sdk/openai` | `openai` | +| Anthropic | `@ai-sdk/anthropic` | `anthropic` | +| Google AI Studio | `@ai-sdk/google` | `google-ai-studio` | +| Google Vertex AI | `@ai-sdk/google-vertex` | `google-vertex-ai` | +| Mistral | `@ai-sdk/mistral` | `mistral` | +| Groq | `@ai-sdk/groq` | `groq` | +| xAI / Grok | `@ai-sdk/xai` | `grok` | +| Perplexity | `@ai-sdk/perplexity` | `perplexity-ai` | +| DeepSeek | `@ai-sdk/deepseek` | `deepseek` | +| Azure OpenAI | `@ai-sdk/azure` | `azure-openai` | +| Amazon Bedrock | `@ai-sdk/amazon-bedrock` | `aws-bedrock` | +| Cerebras | `@ai-sdk/cerebras` | `cerebras` | +| Cohere | `@ai-sdk/cohere` | `cohere` | +| Deepgram | `@ai-sdk/deepgram` | `deepgram` | +| ElevenLabs | `@ai-sdk/elevenlabs` | `elevenlabs` | +| Fireworks | `@ai-sdk/fireworks` | `fireworks` | +| OpenRouter | `@openrouter/ai-sdk-provider` | `openrouter` | +| Replicate | — | `replicate` | +| HuggingFace | — | `huggingface` | +| Cartesia | — | `cartesia` | +| Fal AI | — | `fal` | +| Ideogram | — | `ideogram` | + +Any other provider works with `providerName` set explicitly. + +## Testing + +### Unit tests -- OpenAI -- Anthropic -- DeepSeek -- Google AI Studio -- Grok -- Mistral -- Perplexity AI -- Replicate -- Groq +```bash +npm run test:unit +``` + +No credentials needed — uses mocked HTTP via msw. + +### Integration tests (binding + REST API) -## Supported Methods +Tests against real AI Gateways in both binding mode (via a Worker) and REST API mode (direct HTTP). -Currently, the following methods are supported: +**Prerequisites:** -- **Non-streaming text generation**: Using `generateText()` from the Vercel AI SDK -- **Chat completions**: Using `generateText()` with message-based prompts +1. `npx wrangler login` (one-time) +2. Two AI Gateways in your Cloudflare dashboard: + - One **unauthenticated** (no `cf-aig-authorization` required) + - One **authenticated** (with BYOK configured for OpenAI) -More can be added, please open an issue in the GitHub repository! +**Setup:** -## Error Handling +1. Copy `test/integration/.env.example` to `test/integration/.env` and fill in your values -The library throws the following custom errors: +**Run:** + +```bash +npm run test:integration +``` -- `AiGatewayUnauthorizedError`: Your AI Gateway has authentication enabled, but a valid API key was not provided. -- `AiGatewayDoesNotExist`: Specified AI Gateway does not exist +The test runner starts `wrangler dev` automatically for binding tests. Tests skip when credentials aren't configured. ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/cloudflare/ai/blob/main/LICENSE) file for details. +MIT — see [LICENSE](https://github.com/cloudflare/ai/blob/main/LICENSE). -## Relevant Links +## Links -- [Vercel AI SDK Documentation](https://sdk.vercel.ai/docs) -- [Cloudflare AI Gateway Documentation](https://developers.cloudflare.com/ai-gateway/) -- [GitHub Repository](https://github.com/cloudflare/ai) +- [Cloudflare AI Gateway docs](https://developers.cloudflare.com/ai-gateway/) +- [Vercel AI SDK docs](https://sdk.vercel.ai/docs) +- [GitHub repo](https://github.com/cloudflare/ai) diff --git a/packages/ai-gateway-provider/package.json b/packages/ai-gateway-provider/package.json index 391324d9f..9537b1d4c 100644 --- a/packages/ai-gateway-provider/package.json +++ b/packages/ai-gateway-provider/package.json @@ -33,96 +33,6 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" - }, - "./providers/amazon-bedrock": { - "types": "./dist/providers/amazon-bedrock.d.ts", - "import": "./dist/providers/amazon-bedrock.mjs", - "require": "./dist/providers/amazon-bedrock.js" - }, - "./providers/openai": { - "types": "./dist/providers/openai.d.ts", - "import": "./dist/providers/openai.mjs", - "require": "./dist/providers/openai.js" - }, - "./providers/xai": { - "types": "./dist/providers/xai.d.ts", - "import": "./dist/providers/xai.mjs", - "require": "./dist/providers/xai.js" - }, - "./providers/google": { - "types": "./dist/providers/google.d.ts", - "import": "./dist/providers/google.mjs", - "require": "./dist/providers/google.js" - }, - "./providers/groq": { - "types": "./dist/providers/groq.d.ts", - "import": "./dist/providers/groq.mjs", - "require": "./dist/providers/groq.js" - }, - "./providers/mistral": { - "types": "./dist/providers/mistral.d.ts", - "import": "./dist/providers/mistral.mjs", - "require": "./dist/providers/mistral.js" - }, - "./providers/perplexity": { - "types": "./dist/providers/perplexity.d.ts", - "import": "./dist/providers/perplexity.mjs", - "require": "./dist/providers/perplexity.js" - }, - "./providers/anthropic": { - "types": "./dist/providers/anthropic.d.ts", - "import": "./dist/providers/anthropic.mjs", - "require": "./dist/providers/anthropic.js" - }, - "./providers/azure": { - "types": "./dist/providers/azure.d.ts", - "import": "./dist/providers/azure.mjs", - "require": "./dist/providers/azure.js" - }, - "./providers/cerebras": { - "types": "./dist/providers/cerebras.d.ts", - "import": "./dist/providers/cerebras.mjs", - "require": "./dist/providers/cerebras.js" - }, - "./providers/cohere": { - "types": "./dist/providers/cohere.d.ts", - "import": "./dist/providers/cohere.mjs", - "require": "./dist/providers/cohere.js" - }, - "./providers/deepgram": { - "types": "./dist/providers/deepgram.d.ts", - "import": "./dist/providers/deepgram.mjs", - "require": "./dist/providers/deepgram.js" - }, - "./providers/deepseek": { - "types": "./dist/providers/deepseek.d.ts", - "import": "./dist/providers/deepseek.mjs", - "require": "./dist/providers/deepseek.js" - }, - "./providers/elevenlabs": { - "types": "./dist/providers/elevenlabs.d.ts", - "import": "./dist/providers/elevenlabs.mjs", - "require": "./dist/providers/elevenlabs.js" - }, - "./providers/fireworks": { - "types": "./dist/providers/fireworks.d.ts", - "import": "./dist/providers/fireworks.mjs", - "require": "./dist/providers/fireworks.js" - }, - "./providers/google-vertex": { - "types": "./dist/providers/google-vertex.d.ts", - "import": "./dist/providers/google-vertex.mjs", - "require": "./dist/providers/google-vertex.js" - }, - "./providers/openrouter": { - "types": "./dist/providers/openrouter.d.ts", - "import": "./dist/providers/openrouter.mjs", - "require": "./dist/providers/openrouter.js" - }, - "./providers/unified": { - "types": "./dist/providers/unified.d.ts", - "import": "./dist/providers/unified.mjs", - "require": "./dist/providers/unified.js" } }, "publishConfig": { @@ -131,8 +41,10 @@ "scripts": { "build": "rm -rf dist && tsup --config tsup.config.ts", "format": "oxfmt --write .", - "test:ci": "vitest --watch=false", "test": "vitest", + "test:unit": "vitest run test/unit", + "test:integration": "vitest run --config test/integration/vitest.integration.config.ts", + "test:ci": "vitest run test/unit", "type-check": "tsc --noEmit" }, "devDependencies": { @@ -140,28 +52,7 @@ "typescript": "5.9.3" }, "peerDependencies": { - "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", - "@ai-sdk/provider-utils": "^4.0.0", - "ai": "^6.0.0" - }, - "optionalDependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.62", - "@ai-sdk/anthropic": "^3.0.46", - "@ai-sdk/azure": "^3.0.31", - "@ai-sdk/cerebras": "^2.0.34", - "@ai-sdk/cohere": "^3.0.21", - "@ai-sdk/deepgram": "^2.0.20", - "@ai-sdk/deepseek": "^2.0.20", - "@ai-sdk/elevenlabs": "^2.0.20", - "@ai-sdk/fireworks": "^2.0.34", - "@ai-sdk/google": "^3.0.30", - "@ai-sdk/google-vertex": "^4.0.61", - "@ai-sdk/groq": "^3.0.24", - "@ai-sdk/mistral": "^3.0.20", - "@ai-sdk/openai": "^3.0.30", - "@ai-sdk/perplexity": "^3.0.19", - "@ai-sdk/xai": "^3.0.57", - "@openrouter/ai-sdk-provider": "^2.2.3" + "@ai-sdk/provider-utils": "^4.0.0" } } diff --git a/packages/ai-gateway-provider/src/auth.ts b/packages/ai-gateway-provider/src/auth.ts deleted file mode 100644 index d0286a804..000000000 --- a/packages/ai-gateway-provider/src/auth.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const CF_TEMP_TOKEN = "CF_TEMP_TOKEN"; - -type HasApiKey = { apiKey: string }; - -export function authWrapper any>( - func: Func, -): (config: Parameters[0]) => ReturnType { - return (config) => { - if (!config) { - return func({ apiKey: CF_TEMP_TOKEN }); - } - if (config.apiKey === undefined) { - config.apiKey = CF_TEMP_TOKEN; - } - return func(config); - }; -} diff --git a/packages/ai-gateway-provider/src/index.ts b/packages/ai-gateway-provider/src/index.ts index c9fa82bae..4326c9432 100644 --- a/packages/ai-gateway-provider/src/index.ts +++ b/packages/ai-gateway-provider/src/index.ts @@ -1,275 +1,416 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"; import type { FetchFunction } from "@ai-sdk/provider-utils"; -import { CF_TEMP_TOKEN } from "./auth"; -import { providers } from "./providers"; +import { providers as providerRegistry } from "./providers"; -export class AiGatewayInternalFetchError extends Error {} +class AiGatewayInternalFetchError extends Error {} export class AiGatewayDoesNotExist extends Error {} export class AiGatewayUnauthorizedError extends Error {} -async function streamToObject(stream: ReadableStream) { - const response = new Response(stream); - return await response.json(); +async function parseBody(body: BodyInit): Promise { + return new Response(body).json(); +} + +function normalizeHeaders(headers: HeadersInit | undefined): Record { + if (!headers) return {}; + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return headers as Record; +} + +export function resolveProvider( + url: string, + explicitName?: string, +): { name: string; endpoint: string } { + let registryMatch = null; + for (const p of providerRegistry) { + if (p.regex.test(url)) { + registryMatch = p; + } + } + + if (registryMatch) { + return { + name: explicitName ?? registryMatch.name, + endpoint: registryMatch.transformEndpoint(url), + }; + } + + if (explicitName) { + const parsed = new URL(url); + return { + name: explicitName, + endpoint: parsed.pathname.slice(1) + parsed.search, + }; + } + + throw new Error( + `URL "${url}" did not match any known provider. Set providerName to use a custom base URL.`, + ); } type InternalLanguageModelV3 = LanguageModelV3 & { config?: { fetch?: FetchFunction | undefined }; }; -export class AiGatewayChatLanguageModel implements LanguageModelV3 { - readonly specificationVersion = "v3"; - readonly defaultObjectGenerationMode = "json"; +export type AiGatewayRetries = { + maxAttempts?: 1 | 2 | 3 | 4 | 5; + retryDelayMs?: number; + backoff?: "constant" | "linear" | "exponential"; +}; - readonly supportedUrls: Record | PromiseLike> = { - // No URLS are supported for this language model +export type AiGatewayOptions = { + cacheKey?: string; + cacheTtl?: number; + skipCache?: boolean; + metadata?: Record; + collectLog?: boolean; + eventId?: string; + requestTimeoutMs?: number; + retries?: AiGatewayRetries; + byokAlias?: string; + zdr?: boolean; +}; + +export type AiGatewayAPIConfig = { + gateway: string; + accountId: string; + apiKey?: string; + options?: AiGatewayOptions; +}; + +export type AiGatewayBindingConfig = { + binding: { + run(data: unknown): Promise; }; + options?: AiGatewayOptions; +}; - readonly models: InternalLanguageModelV3[]; - readonly config: AiGatewaySettings; +export type AiGatewayConfig = AiGatewayAPIConfig | AiGatewayBindingConfig; - get modelId(): string { - if (!this.models[0]) { - throw new Error("models cannot be empty array"); - } +// ─── Shared helpers ────────────────────────────────────────────── + +type CapturedRequest = { + url: string; + headers: Record; + body: unknown; + originalFetch: FetchFunction | undefined; +}; - return this.models[0].modelId; +async function captureModelRequest( + model: InternalLanguageModelV3, + options: Parameters[0], + method: "doStream" | "doGenerate", +): Promise { + if (!model.config || !Object.keys(model.config).includes("fetch")) { + throw new Error( + `Provider "${model.provider}" is not supported — it does not expose a configurable fetch`, + ); } - get provider(): string { - if (!this.models[0]) { - throw new Error("models cannot be empty array"); - } + const originalFetch = model.config.fetch; + let captured: Omit | undefined; + + model.config.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + captured = { + url, + headers: normalizeHeaders(init?.headers), + body: init?.body ? await parseBody(init.body as BodyInit) : {}, + }; + throw new AiGatewayInternalFetchError(); + }; - return this.models[0].provider; + try { + await model[method](options); + } catch (e) { + if (!(e instanceof AiGatewayInternalFetchError)) throw e; } - constructor(models: LanguageModelV3[], config: AiGatewaySettings) { - this.models = models; - this.config = config; + if (!captured) { + model.config.fetch = originalFetch; + throw new Error("Failed to capture request from provider"); } + return { ...captured, originalFetch }; +} - async processModelRequest< - T extends LanguageModelV3["doStream"] | LanguageModelV3["doGenerate"], - >( - options: Parameters[0], - modelMethod: "doStream" | "doGenerate", - ): Promise>> { - const requests: { url: string; request: Request; modelProvider: string }[] = []; +type GatewayRequestEntry = { + provider: string; + endpoint: string; + headers: Record; + query: unknown; +}; - // Model configuration and request collection - for (const model of this.models) { - if (!model.config || !Object.keys(model.config).includes("fetch")) { - throw new Error( - `Sorry, but provider "${model.provider}" is currently not supported, please open a issue in the github repo!`, - ); - } - - model.config.fetch = (url, request) => { - requests.push({ - modelProvider: model.provider, - request: request as Request, - url: url as string, - }); - throw new AiGatewayInternalFetchError("Stopping provider execution..."); - }; - - try { - await model[modelMethod](options); - } catch (e) { - if (!(e instanceof AiGatewayInternalFetchError)) { - throw e; - } - } - } +const AUTH_HEADERS = ["authorization", "x-api-key", "api-key", "x-goog-api-key"]; + +function stripAuthHeaders(headers: Record): Record { + const result = { ...headers }; + for (const key of AUTH_HEADERS) { + delete result[key]; + } + return result; +} + +function buildGatewayEntry( + captured: CapturedRequest, + providerName?: string, + byok?: boolean, +): GatewayRequestEntry { + const resolved = resolveProvider(captured.url, providerName); + return { + provider: resolved.name, + endpoint: resolved.endpoint, + headers: byok ? stripAuthHeaders(captured.headers) : captured.headers, + query: captured.body, + }; +} - // Process requests - const body = await Promise.all( - requests.map(async (req) => { - let providerConfig = null; - for (const provider of providers) { - if (provider.regex.test(req.url)) { - providerConfig = provider; - } - } - - if (!providerConfig) { - throw new Error( - `Sorry, but provider "${req.modelProvider}" is currently not supported, please open a issue in the github repo!`, - ); - } - - if (!req.request.body) { - throw new Error("Ai Gateway provider received an unexpected empty body"); - } - - // For AI Gateway BYOK / unified billing requests - // delete the fake injected CF_TEMP_TOKEN - - const authHeader = providerConfig.headerKey ?? "authorization"; - const authValue = - "get" in req.request.headers - ? req.request.headers.get(authHeader) - : req.request.headers[authHeader]; - if (authValue?.indexOf(CF_TEMP_TOKEN) !== -1) { - if ("delete" in req.request.headers) { - req.request.headers.delete(authHeader); - } else { - delete req.request.headers[authHeader]; - } - } - - return { - endpoint: providerConfig.transformEndpoint(req.url), - headers: req.request.headers, - provider: providerConfig.name, - query: await streamToObject(req.request.body), - }; - }), +async function dispatchToGateway( + requestBody: GatewayRequestEntry[], + config: AiGatewayConfig, +): Promise { + const gatewayHeaders = parseAiGatewayOptions(config.options ?? {}); + let resp: Response; + + if ("binding" in config) { + const updatedBody = requestBody.map((obj) => ({ + ...obj, + headers: { + ...obj.headers, + ...Object.fromEntries(gatewayHeaders.entries()), + }, + })); + resp = await config.binding.run(updatedBody); + } else { + gatewayHeaders.set("Content-Type", "application/json"); + if (config.apiKey) { + gatewayHeaders.set("cf-aig-authorization", `Bearer ${config.apiKey}`); + } + resp = await fetch( + `https://gateway.ai.cloudflare.com/v1/${config.accountId}/${config.gateway}`, + { + body: JSON.stringify(requestBody), + headers: gatewayHeaders, + method: "POST", + }, ); + } - // Handle response - const headers = parseAiGatewayOptions(this.config.options ?? {}); - let resp: Response; - - if ("binding" in this.config) { - const updatedBody = body.map((obj) => ({ - ...obj, - headers: { - ...(obj.headers ?? {}), - ...Object.fromEntries(headers.entries()), - }, - })); - resp = await this.config.binding.run(updatedBody); - } else { - headers.set("Content-Type", "application/json"); - headers.set("cf-aig-authorization", `Bearer ${this.config.apiKey}`); - resp = await fetch( - `https://gateway.ai.cloudflare.com/v1/${this.config.accountId}/${this.config.gateway}`, - { - body: JSON.stringify(body), - headers: headers, - method: "POST", - }, + if (resp.status === 400) { + const result = (await resp.clone().json()) as { + success?: boolean; + error?: { code: number; message: string }[]; + }; + if (result.success === false && result.error?.length && result.error[0]?.code === 2001) { + throw new AiGatewayDoesNotExist("This AI gateway does not exist"); + } + } else if (resp.status === 401) { + const result = (await resp.clone().json()) as { + success?: boolean; + error?: { code: number; message: string }[]; + }; + if (result.success === false && result.error?.length && result.error[0]?.code === 2009) { + throw new AiGatewayUnauthorizedError( + "Your AI Gateway has authentication active, but you didn't provide a valid apiKey", ); } + } - // Error handling - if (resp.status === 400) { - const cloneResp = resp.clone(); - const result: { - success?: boolean; - error?: { code: number; message: string }[]; - } = await cloneResp.json(); - if ( - result.success === false && - result.error && - result.error.length > 0 && - result.error[0]?.code === 2001 - ) { - throw new AiGatewayDoesNotExist("This AI gateway does not exist"); - } - } else if (resp.status === 401) { - const cloneResp = resp.clone(); - const result: { - success?: boolean; - error?: { code: number; message: string }[]; - } = await cloneResp.json(); - if ( - result.success === false && - result.error && - result.error.length > 0 && - result.error[0]?.code === 2009 - ) { - throw new AiGatewayUnauthorizedError( - "Your AI Gateway has authentication active, but you didn't provide a valid apiKey", - ); - } - } + return resp; +} - const step = Number.parseInt(resp.headers.get("cf-aig-step") ?? "0", 10); - if (!this.models[step]) { - throw new Error("Unexpected AI Gateway Error"); - } +function feedResponseToModel(model: InternalLanguageModelV3, resp: Response): void { + model.config = { + ...model.config, + fetch: () => Promise.resolve(resp), + }; +} - this.models[step].config = { - ...this.models[step].config, - fetch: (_url, _req) => resp as unknown as Promise, - }; +// ─── Single-model gateway ──────────────────────────────────────── + +export class AiGatewayChatLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3"; + + private readonly innerModel: InternalLanguageModelV3; + private readonly gatewayConfig: AiGatewayConfig; + private readonly providerName?: string; + private readonly byok: boolean; + + get modelId() { + return this.innerModel.modelId; + } - return this.models[step][modelMethod](options) as Promise>>; + get provider() { + return this.innerModel.provider; + } + + get supportedUrls() { + return this.innerModel.supportedUrls; + } + + constructor( + model: LanguageModelV3, + config: AiGatewayConfig, + providerName?: string, + byok?: boolean, + ) { + this.innerModel = model as InternalLanguageModelV3; + this.gatewayConfig = config; + this.providerName = providerName; + this.byok = byok ?? false; + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + return this.processRequest(options, "doGenerate"); } async doStream( options: Parameters[0], ): Promise>> { - return this.processModelRequest(options, "doStream"); + return this.processRequest(options, "doStream"); + } + + private async processRequest< + T extends LanguageModelV3["doStream"] | LanguageModelV3["doGenerate"], + >( + options: Parameters[0], + method: "doStream" | "doGenerate", + ): Promise>> { + const captured = await captureModelRequest(this.innerModel, options, method); + const entry = buildGatewayEntry(captured, this.providerName, this.byok); + const resp = await dispatchToGateway([entry], this.gatewayConfig); + feedResponseToModel(this.innerModel, resp); + const result = await (this.innerModel[method](options) as Promise>>); + this.innerModel.config!.fetch = captured.originalFetch; + return result; + } +} + +// ─── Fallback model ────────────────────────────────────────────── + +class AiGatewayFallbackModel implements LanguageModelV3 { + readonly specificationVersion = "v3"; + + private readonly models: InternalLanguageModelV3[]; + private readonly gatewayConfig: AiGatewayConfig; + private readonly byok: boolean; + + get modelId() { + return this.models[0]!.modelId; + } + + get provider() { + return this.models[0]!.provider; + } + + get supportedUrls() { + return this.models[0]!.supportedUrls; + } + + constructor(models: LanguageModelV3[], config: AiGatewayConfig, byok?: boolean) { + this.models = models as InternalLanguageModelV3[]; + this.gatewayConfig = config; + this.byok = byok ?? false; } async doGenerate( options: Parameters[0], ): Promise>> { - return this.processModelRequest(options, "doGenerate"); + return this.processRequest(options, "doGenerate"); } -} -export interface AiGateway { - (models: LanguageModelV3 | LanguageModelV3[]): LanguageModelV3; + async doStream( + options: Parameters[0], + ): Promise>> { + return this.processRequest(options, "doStream"); + } - chat(models: LanguageModelV3 | LanguageModelV3[]): LanguageModelV3; -} + private async processRequest< + T extends LanguageModelV3["doStream"] | LanguageModelV3["doGenerate"], + >( + options: Parameters[0], + method: "doStream" | "doGenerate", + ): Promise>> { + const entries: GatewayRequestEntry[] = []; + const originalFetches: (FetchFunction | undefined)[] = []; -export type AiGatewayReties = { - maxAttempts?: 1 | 2 | 3 | 4 | 5; - retryDelayMs?: number; - backoff?: "constant" | "linear" | "exponential"; -}; -export type AiGatewayOptions = { - cacheKey?: string; - cacheTtl?: number; - skipCache?: boolean; - metadata?: Record; - collectLog?: boolean; - eventId?: string; - requestTimeoutMs?: number; - retries?: AiGatewayReties; -}; -export type AiGatewayAPISettings = { - gateway: string; - accountId: string; - apiKey?: string; - options?: AiGatewayOptions; -}; -export type AiGatewayBindingSettings = { - binding: { - run(data: unknown): Promise; - }; - options?: AiGatewayOptions; -}; -export type AiGatewaySettings = AiGatewayAPISettings | AiGatewayBindingSettings; + for (const model of this.models) { + const captured = await captureModelRequest(model, options, method); + entries.push(buildGatewayEntry(captured, undefined, this.byok)); + originalFetches.push(captured.originalFetch); + } -export function createAiGateway(options: AiGatewaySettings): AiGateway { - const createChatModel = (models: LanguageModelV3 | LanguageModelV3[]) => { - return new AiGatewayChatLanguageModel(Array.isArray(models) ? models : [models], options); - }; + const resp = await dispatchToGateway(entries, this.gatewayConfig); + + const step = Number.parseInt(resp.headers.get("cf-aig-step") ?? "0", 10); + const selectedModel = this.models[step]; + if (!selectedModel) { + throw new Error("Unexpected AI Gateway fallback step"); + } + + feedResponseToModel(selectedModel, resp); + const result = await (selectedModel[method](options) as Promise>>); + + for (let i = 0; i < this.models.length; i++) { + this.models[i]!.config!.fetch = originalFetches[i]; + } - const provider = (models: LanguageModelV3 | LanguageModelV3[]) => createChatModel(models); + return result; + } +} - provider.chat = createChatModel; +// ─── Public API ────────────────────────────────────────────────── + +export function createAIGateway

LanguageModelV3>( + config: AiGatewayConfig & { + provider: P; + providerName?: string; + byok?: boolean; + }, +): (...args: Parameters

) => LanguageModelV3 { + const { provider, providerName, byok, ...gatewayConfig } = config; + return (...args: Parameters

) => { + const model = provider(...args); + return new AiGatewayChatLanguageModel( + model, + gatewayConfig as AiGatewayConfig, + providerName, + byok, + ); + }; +} - return provider; +export function createAIGatewayFallback( + config: AiGatewayConfig & { models: LanguageModelV3[]; byok?: boolean }, +): LanguageModelV3 { + const { models, byok, ...gatewayConfig } = config; + if (models.length === 0) { + throw new Error("createAIGatewayFallback requires at least one model"); + } + return new AiGatewayFallbackModel(models, gatewayConfig as AiGatewayConfig, byok); } export function parseAiGatewayOptions(options: AiGatewayOptions): Headers { const headers = new Headers(); if (options.skipCache === true) { - headers.set("cf-skip-cache", "true"); + headers.set("cf-aig-skip-cache", "true"); } if (options.cacheTtl) { - headers.set("cf-cache-ttl", options.cacheTtl.toString()); + headers.set("cf-aig-cache-ttl", options.cacheTtl.toString()); } if (options.metadata) { @@ -304,5 +445,13 @@ export function parseAiGatewayOptions(options: AiGatewayOptions): Headers { } } + if (options.byokAlias !== undefined) { + headers.set("cf-aig-byok-alias", options.byokAlias); + } + + if (options.zdr !== undefined) { + headers.set("cf-aig-zdr", options.zdr ? "true" : "false"); + } + return headers; } diff --git a/packages/ai-gateway-provider/src/providers.ts b/packages/ai-gateway-provider/src/providers.ts index e32604153..77fdc178e 100644 --- a/packages/ai-gateway-provider/src/providers.ts +++ b/packages/ai-gateway-provider/src/providers.ts @@ -13,21 +13,13 @@ export const providers = [ name: "anthropic", regex: /^https:\/\/api\.anthropic\.com\//, transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.anthropic\.com\//, ""), - headerKey: "x-api-key", }, { name: "google-ai-studio", regex: /^https:\/\/generativelanguage\.googleapis\.com\//, - headerKey: "x-goog-api-key", transformEndpoint: (url: string) => url.replace(/^https:\/\/generativelanguage\.googleapis\.com\//, ""), }, - { - name: "google-vertex-ai", - regex: /aiplatform\.googleapis\.com/, - transformEndpoint: (url: string) => - url.replace(/https:\/\/(.*)[-]?aiplatform\.googleapis\.com\//, ""), - }, { name: "grok", regex: /^https:\/\/api\.x\.ai\//, @@ -59,7 +51,6 @@ export const providers = [ regex: /^https:\/\/(?:[a-z0-9]+-)*aiplatform\.googleapis\.com\//, transformEndpoint: (url: string) => url.replace(/^https:\/\/(?:[a-z0-9]+-)*aiplatform\.googleapis\.com\//, ""), - headerKey: "authorization", }, { name: "azure-openai", @@ -75,13 +66,68 @@ export const providers = [ } return `${resource}/${deployment}/${rest}`; }, - headerKey: "api-key", }, { name: "openrouter", regex: /^https:\/\/openrouter\.ai\/api\//, transformEndpoint: (url: string) => url.replace(/^https:\/\/openrouter\.ai\/api\//, ""), }, + { + name: "aws-bedrock", + regex: /^https:\/\/bedrock-runtime\.([^.]+)\.amazonaws\.com\//, + transformEndpoint: (url: string) => { + const match = url.match(/^https:\/\/bedrock-runtime\.([^.]+)\.amazonaws\.com\/(.*)/); + if (!match) return url; + return `bedrock-runtime/${match[1]}/${match[2]}`; + }, + }, + { + name: "cerebras", + regex: /^https:\/\/api\.cerebras\.ai\/v1\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.cerebras\.ai\/v1\//, ""), + }, + { + name: "cohere", + regex: /^https:\/\/api\.cohere\.(com|ai)\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.cohere\.(com|ai)\//, ""), + }, + { + name: "deepgram", + regex: /^https:\/\/api\.deepgram\.com\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.deepgram\.com\//, ""), + }, + { + name: "elevenlabs", + regex: /^https:\/\/api\.elevenlabs\.io\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.elevenlabs\.io\//, ""), + }, + { + name: "fireworks", + regex: /^https:\/\/api\.fireworks\.ai\/inference\/v1\//, + transformEndpoint: (url: string) => + url.replace(/^https:\/\/api\.fireworks\.ai\/inference\/v1\//, ""), + }, + { + name: "huggingface", + regex: /^https:\/\/api-inference\.huggingface\.co\/models\//, + transformEndpoint: (url: string) => + url.replace(/^https:\/\/api-inference\.huggingface\.co\/models\//, ""), + }, + { + name: "cartesia", + regex: /^https:\/\/api\.cartesia\.ai\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.cartesia\.ai\//, ""), + }, + { + name: "fal", + regex: /^https:\/\/fal\.run\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/fal\.run\//, ""), + }, + { + name: "ideogram", + regex: /^https:\/\/api\.ideogram\.ai\//, + transformEndpoint: (url: string) => url.replace(/^https:\/\/api\.ideogram\.ai\//, ""), + }, { name: "compat", regex: /^https:\/\/gateway\.ai\.cloudflare\.com\/v1\/compat\//, diff --git a/packages/ai-gateway-provider/src/providers/amazon-bedrock.ts b/packages/ai-gateway-provider/src/providers/amazon-bedrock.ts deleted file mode 100644 index 607f736ca..000000000 --- a/packages/ai-gateway-provider/src/providers/amazon-bedrock.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAmazonBedrock as createAmazonBedrockOriginal } from "@ai-sdk/amazon-bedrock"; -import { authWrapper } from "../auth"; - -export const createAmazonBedrock = (...args: Parameters) => - authWrapper(createAmazonBedrockOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/anthropic.ts b/packages/ai-gateway-provider/src/providers/anthropic.ts deleted file mode 100644 index ec84d67b5..000000000 --- a/packages/ai-gateway-provider/src/providers/anthropic.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAnthropic as createAnthropicOriginal } from "@ai-sdk/anthropic"; -import { authWrapper } from "../auth"; - -export const createAnthropic = (...args: Parameters) => - authWrapper(createAnthropicOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/azure.ts b/packages/ai-gateway-provider/src/providers/azure.ts deleted file mode 100644 index 3563973bc..000000000 --- a/packages/ai-gateway-provider/src/providers/azure.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAzure as createAzureOriginal } from "@ai-sdk/azure"; -import { authWrapper } from "../auth"; - -export const createAzure = (...args: Parameters) => - authWrapper(createAzureOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/cerebras.ts b/packages/ai-gateway-provider/src/providers/cerebras.ts deleted file mode 100644 index 9fb37ff1d..000000000 --- a/packages/ai-gateway-provider/src/providers/cerebras.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createCerebras as createCerebrasOriginal } from "@ai-sdk/cerebras"; -import { authWrapper } from "../auth"; - -export const createCerebras = (...args: Parameters) => - authWrapper(createCerebrasOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/cohere.ts b/packages/ai-gateway-provider/src/providers/cohere.ts deleted file mode 100644 index 140dcbe99..000000000 --- a/packages/ai-gateway-provider/src/providers/cohere.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createCohere as createCohereOriginal } from "@ai-sdk/cohere"; -import { authWrapper } from "../auth"; - -export const createCohere = (...args: Parameters) => - authWrapper(createCohereOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/deepgram.ts b/packages/ai-gateway-provider/src/providers/deepgram.ts deleted file mode 100644 index 1faeb9461..000000000 --- a/packages/ai-gateway-provider/src/providers/deepgram.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createDeepgram as createDeepgramOriginal } from "@ai-sdk/deepgram"; -import { authWrapper } from "../auth"; - -export const createDeepgram = (...args: Parameters) => - authWrapper(createDeepgramOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/deepseek.ts b/packages/ai-gateway-provider/src/providers/deepseek.ts deleted file mode 100644 index 6c98e7a68..000000000 --- a/packages/ai-gateway-provider/src/providers/deepseek.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createDeepSeek as createDeepSeekOriginal } from "@ai-sdk/deepseek"; -import { authWrapper } from "../auth"; - -export const createDeepSeek = (...args: Parameters) => - authWrapper(createDeepSeekOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/elevenlabs.ts b/packages/ai-gateway-provider/src/providers/elevenlabs.ts deleted file mode 100644 index 580a6030e..000000000 --- a/packages/ai-gateway-provider/src/providers/elevenlabs.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createElevenLabs as createElevenLabsOriginal } from "@ai-sdk/elevenlabs"; -import { authWrapper } from "../auth"; - -export const createElevenLabs = (...args: Parameters) => - authWrapper(createElevenLabsOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/fireworks.ts b/packages/ai-gateway-provider/src/providers/fireworks.ts deleted file mode 100644 index dbb57fa15..000000000 --- a/packages/ai-gateway-provider/src/providers/fireworks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createFireworks as createFireworksOriginal } from "@ai-sdk/fireworks"; -import { authWrapper } from "../auth"; - -export const createFireworks = (...args: Parameters) => - authWrapper(createFireworksOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/google-vertex.ts b/packages/ai-gateway-provider/src/providers/google-vertex.ts deleted file mode 100644 index 3b1defd8a..000000000 --- a/packages/ai-gateway-provider/src/providers/google-vertex.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createVertex as createVertexOriginal } from "@ai-sdk/google-vertex/edge"; -import { CF_TEMP_TOKEN } from "../auth"; - -export const createVertex = (...args: Parameters) => { - let [config] = args; - if (config === undefined) { - config = { googleCredentials: { cfApiKey: CF_TEMP_TOKEN } } as any; - } - // no google credentials and no express mode apikey - else if (config.googleCredentials === undefined && config.apiKey === undefined) { - config.googleCredentials = { cfApiKey: CF_TEMP_TOKEN } as any; - } - return createVertexOriginal(config); -}; diff --git a/packages/ai-gateway-provider/src/providers/google.ts b/packages/ai-gateway-provider/src/providers/google.ts deleted file mode 100644 index 7ba580029..000000000 --- a/packages/ai-gateway-provider/src/providers/google.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createGoogleGenerativeAI as createGoogleGenerativeAIOriginal } from "@ai-sdk/google"; -import { authWrapper } from "../auth"; - -export const createGoogleGenerativeAI = ( - ...args: Parameters -) => authWrapper(createGoogleGenerativeAIOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/groq.ts b/packages/ai-gateway-provider/src/providers/groq.ts deleted file mode 100644 index c046dac15..000000000 --- a/packages/ai-gateway-provider/src/providers/groq.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createGroq as createGroqOriginal } from "@ai-sdk/groq"; -import { authWrapper } from "../auth"; - -export const createGroq = (...args: Parameters) => - authWrapper(createGroqOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/index.ts b/packages/ai-gateway-provider/src/providers/index.ts deleted file mode 100644 index a31b24b69..000000000 --- a/packages/ai-gateway-provider/src/providers/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { createAmazonBedrock } from "./amazon-bedrock"; -export { createAnthropic } from "./anthropic"; -export { createAzure } from "./azure"; -export { createCerebras } from "./cerebras"; -export { createCohere } from "./cohere"; -export { createDeepgram } from "./deepgram"; -export { createDeepSeek } from "./deepseek"; -export { createElevenLabs } from "./elevenlabs"; -export { createFireworks } from "./fireworks"; -export { createGoogleGenerativeAI } from "./google"; -export { createVertex } from "./google-vertex"; -export { createGroq } from "./groq"; -export { createMistral } from "./mistral"; -export { createOpenAI } from "./openai"; -export { createPerplexity } from "./perplexity"; -export { createXai } from "./xai"; diff --git a/packages/ai-gateway-provider/src/providers/mistral.ts b/packages/ai-gateway-provider/src/providers/mistral.ts deleted file mode 100644 index 79bf259f0..000000000 --- a/packages/ai-gateway-provider/src/providers/mistral.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createMistral as createMistralOriginal } from "@ai-sdk/mistral"; -import { authWrapper } from "../auth"; - -export const createMistral = (...args: Parameters) => - authWrapper(createMistralOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/openai.ts b/packages/ai-gateway-provider/src/providers/openai.ts deleted file mode 100644 index 82a23762a..000000000 --- a/packages/ai-gateway-provider/src/providers/openai.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createOpenAI as createOpenAIOriginal } from "@ai-sdk/openai"; -import { authWrapper } from "../auth"; - -export const createOpenAI = (...args: Parameters) => - authWrapper(createOpenAIOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/openrouter.ts b/packages/ai-gateway-provider/src/providers/openrouter.ts deleted file mode 100644 index f8d87000d..000000000 --- a/packages/ai-gateway-provider/src/providers/openrouter.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createOpenRouter as createOpenRouterOriginal } from "@openrouter/ai-sdk-provider"; -import { authWrapper } from "../auth"; - -export const createOpenRouter = (...args: Parameters) => - authWrapper(createOpenRouterOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/perplexity.ts b/packages/ai-gateway-provider/src/providers/perplexity.ts deleted file mode 100644 index 24bd5baee..000000000 --- a/packages/ai-gateway-provider/src/providers/perplexity.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createPerplexity as createPerplexityOriginal } from "@ai-sdk/perplexity"; -import { authWrapper } from "../auth"; - -export const createPerplexity = (...args: Parameters) => - authWrapper(createPerplexityOriginal)(...args); diff --git a/packages/ai-gateway-provider/src/providers/unified.ts b/packages/ai-gateway-provider/src/providers/unified.ts deleted file mode 100644 index 3b6e7e884..000000000 --- a/packages/ai-gateway-provider/src/providers/unified.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { OpenAICompatibleProviderSettings } from "@ai-sdk/openai-compatible"; -import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; - -export const createUnified = (arg?: Partial) => { - return createOpenAICompatible({ - baseURL: "https://gateway.ai.cloudflare.com/v1/compat", // intercepted and replaced with actual base URL later - name: "Unified", - ...(arg || {}), - }); -}; - -export const unified = createUnified(); diff --git a/packages/ai-gateway-provider/src/providers/xai.ts b/packages/ai-gateway-provider/src/providers/xai.ts deleted file mode 100644 index cdef330b8..000000000 --- a/packages/ai-gateway-provider/src/providers/xai.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createXai as createXaiOriginal } from "@ai-sdk/xai"; -import { authWrapper } from "../auth"; - -export const createXai = (...args: Parameters) => - authWrapper(createXaiOriginal)(...args); diff --git a/packages/ai-gateway-provider/test/endpoint.test.ts b/packages/ai-gateway-provider/test/endpoint.test.ts deleted file mode 100644 index 408ba1295..000000000 --- a/packages/ai-gateway-provider/test/endpoint.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { providers } from "../src/providers"; - -const testCases = [ - { - expected: "v1/chat/completions", - name: "openai", - url: "https://api.openai.com/v1/chat/completions", - }, - { - expected: "v1/chat/completions", - name: "deepseek", - url: "https://api.deepseek.com/v1/chat/completions", - }, - { - expected: "v1/messages", - name: "anthropic", - url: "https://api.anthropic.com/v1/messages", - }, - { - expected: "v1beta/models", - name: "google-ai-studio", - url: "https://generativelanguage.googleapis.com/v1beta/models", - }, - { - expected: "v1/chat", - name: "grok", - url: "https://api.x.ai/v1/chat", - }, - { - expected: "v1/chat/completions", - name: "mistral", - url: "https://api.mistral.ai/v1/chat/completions", - }, - { - expected: "v1/chat/completions", - name: "perplexity-ai", - url: "https://api.perplexity.ai/v1/chat/completions", - }, - { - expected: "v1/predictions", - name: "replicate", - url: "https://api.replicate.com/v1/predictions", - }, - { - expected: "chat/completions", - name: "groq", - url: "https://api.groq.com/openai/v1/chat/completions", - }, - { - expected: "myresource/mydeployment/chat/completions?api-version=2024-02-15-preview", - name: "azure-openai", - url: "https://myresource.openai.azure.com/openai/deployments/mydeployment/chat/completions?api-version=2024-02-15-preview", - }, -]; - -describe("ProvidersConfigs endpoint parsing", () => { - for (const testCase of testCases) { - it(`should correctly parse endpoint for provider "${testCase.name}"`, () => { - const provider = providers.find((p) => p.name === testCase.name); - expect(provider).toBeDefined(); - const result = provider!.transformEndpoint(testCase.url); - expect(result).toBe(testCase.expected); - }); - } -}); diff --git a/packages/ai-gateway-provider/test/integration/.env.example b/packages/ai-gateway-provider/test/integration/.env.example new file mode 100644 index 000000000..8b6f472c1 --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/.env.example @@ -0,0 +1,6 @@ +# Shared by both binding and REST API integration tests +OPENAI_API_KEY=sk-your-openai-api-key +CLOUDFLARE_ACCOUNT_ID=your-account-id +CLOUDFLARE_GATEWAY_NAME_UNAUTH=your-unauthenticated-gateway +CLOUDFLARE_GATEWAY_NAME_AUTH=your-authenticated-gateway +CLOUDFLARE_GATEWAY_AUTH_TOKEN=your-cf-aig-authorization-token diff --git a/packages/ai-gateway-provider/test/integration/binding.test.ts b/packages/ai-gateway-provider/test/integration/binding.test.ts new file mode 100644 index 000000000..107074b22 --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/binding.test.ts @@ -0,0 +1,161 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * Integration tests for the AI Gateway binding mode. + * + * The global setup (global-setup.ts) loads test/integration/.env and + * automatically starts `wrangler dev` before tests run. + * + * Required setup: + * 1. Copy test/integration/.env.example to test/integration/.env and fill in values + * 2. Run: npx wrangler login (if not already authenticated) + */ + +const WORKER_URL = + process.env.INTEGRATION_WORKER_URL || + `http://localhost:${process.env.INTEGRATION_WORKER_PORT || "8787"}`; + +let workerAvailable = false; +let healthData: { + unauthGateway: string; + authGateway: string; + hasOpenAIKey: boolean; +} | null = null; + +beforeAll(async () => { + try { + const resp = await fetch(`${WORKER_URL}/health`, { + signal: AbortSignal.timeout(5000), + }); + if (resp.ok) { + workerAvailable = true; + healthData = (await resp.json()) as typeof healthData; + console.log("Worker health:", JSON.stringify(healthData, null, 2)); + } + } catch { + console.warn("\n⚠ Integration worker not reachable. Skipping binding tests.\n"); + } +}); + +async function fetchWorker( + path: string, + params?: Record, +): Promise<{ resp: Response; data: any }> { + const url = new URL(path, WORKER_URL); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + const resp = await fetch(url); + const data = await resp.json(); + if (!resp.ok) { + console.error(`Worker error [${path}]:`, JSON.stringify(data, null, 2)); + } + return { resp, data }; +} + +// ─── Unauthenticated gateway, user's own API key ──────────────── + +describe("Integration: Unauthenticated gateway + own API key", () => { + it("should generate text", async ({ skip }) => { + if (!workerAvailable) skip(); + + const { resp, data } = await fetchWorker("/generate", { + gateway: healthData!.unauthGateway, + }); + expect(resp.ok).toBe(true); + expect(data.text).toBeTruthy(); + expect(typeof data.text).toBe("string"); + expect(data.usage).toBeDefined(); + expect(data.byok).toBe(false); + }); + + it("should stream text", async ({ skip }) => { + if (!workerAvailable) skip(); + + const url = new URL("/stream", WORKER_URL); + url.searchParams.set("gateway", healthData!.unauthGateway); + const resp = await fetch(url); + if (!resp.ok) { + console.error("Worker error [/stream]:", await resp.text()); + } + expect(resp.ok).toBe(true); + + const text = await resp.text(); + expect(text).toBeTruthy(); + }); + + it("should generate text with gateway options", async ({ skip }) => { + if (!workerAvailable) skip(); + + const { resp, data } = await fetchWorker("/generate-with-options", { + gateway: healthData!.unauthGateway, + }); + expect(resp.ok).toBe(true); + expect(data.text).toBeTruthy(); + }); +}); + +// ─── Authenticated gateway, user's own API key ────────────────── + +describe("Integration: Authenticated gateway + own API key", () => { + it("should generate text", async ({ skip }) => { + if (!workerAvailable) skip(); + + const { resp, data } = await fetchWorker("/generate", { + gateway: healthData!.authGateway, + }); + expect(resp.ok).toBe(true); + expect(data.text).toBeTruthy(); + expect(typeof data.text).toBe("string"); + }); + + it("should stream text", async ({ skip }) => { + if (!workerAvailable) skip(); + + const url = new URL("/stream", WORKER_URL); + url.searchParams.set("gateway", healthData!.authGateway); + const resp = await fetch(url); + if (!resp.ok) { + console.error("Worker error [/stream auth]:", await resp.text()); + } + expect(resp.ok).toBe(true); + + const text = await resp.text(); + expect(text).toBeTruthy(); + }); +}); + +// ─── Authenticated gateway, BYOK ──────────────────────────────── +// (BYOK requires an authenticated gateway with stored provider keys) + +describe("Integration: Authenticated gateway + BYOK", () => { + it("should generate text with BYOK on authenticated gateway", async ({ skip }) => { + if (!workerAvailable) skip(); + + const { resp, data } = await fetchWorker("/generate", { + gateway: healthData!.authGateway, + byok: "true", + }); + expect(resp.ok).toBe(true); + expect(data.text).toBeTruthy(); + expect(data.byok).toBe(true); + }); + + it("should stream text with BYOK on authenticated gateway", async ({ skip }) => { + if (!workerAvailable) skip(); + + const url = new URL("/stream", WORKER_URL); + url.searchParams.set("gateway", healthData!.authGateway); + url.searchParams.set("byok", "true"); + const resp = await fetch(url); + if (!resp.ok) { + console.error("Worker error [/stream auth+byok]:", await resp.text()); + } + expect(resp.ok).toBe(true); + + const text = await resp.text(); + expect(text).toBeTruthy(); + }); +}); diff --git a/packages/ai-gateway-provider/test/integration/global-setup.ts b/packages/ai-gateway-provider/test/integration/global-setup.ts new file mode 100644 index 000000000..8931ff2e9 --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/global-setup.ts @@ -0,0 +1,105 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const INTEGRATION_DIR = resolve(__dirname); +const WORKER_DIR = resolve(INTEGRATION_DIR, "worker"); +const PORT = process.env.INTEGRATION_WORKER_PORT || "8787"; +const ENV_FILE = resolve(INTEGRATION_DIR, ".env"); + +let wrangler: ChildProcess | undefined; + +function loadEnvFile() { + if (!existsSync(ENV_FILE)) { + console.warn( + `\n⚠ No .env file found at ${ENV_FILE}`, + "\n Copy .env.example to .env and fill in your values.\n", + ); + return; + } + + const content = readFileSync(ENV_FILE, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) continue; + const key = trimmed.slice(0, eqIndex); + const value = trimmed.slice(eqIndex + 1); + if (!process.env[key]) { + process.env[key] = value; + } + } +} + +export async function setup() { + loadEnvFile(); + + console.log("\nStarting wrangler dev..."); + + wrangler = spawn("npx", ["wrangler", "dev", "--port", PORT, "--env-file", ENV_FILE], { + cwd: WORKER_DIR, + stdio: "pipe", + env: { ...process.env }, + }); + + let stderrOutput = ""; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new Error( + `Wrangler dev startup timed out after 30s.\n` + + `Make sure you've run \`npx wrangler login\` and created test/integration/.env\n` + + `stderr: ${stderrOutput}`, + ), + ); + }, 30000); + + wrangler!.stdout?.on("data", (data: Buffer) => { + const output = data.toString(); + if (output.includes("Ready on")) { + clearTimeout(timeout); + console.log(`Wrangler dev ready on port ${PORT}`); + resolve(); + } + }); + + wrangler!.stderr?.on("data", (data: Buffer) => { + stderrOutput += data.toString(); + }); + + wrangler!.on("exit", (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeout); + reject( + new Error( + `Wrangler dev exited with code ${code}.\n` + + `Make sure you've run \`npx wrangler login\`.\n` + + `stderr: ${stderrOutput}`, + ), + ); + } + }); + }); +} + +export async function teardown() { + if (!wrangler) return; + + wrangler.kill("SIGTERM"); + + await new Promise((resolve) => { + const forceKill = setTimeout(() => { + wrangler?.kill("SIGKILL"); + resolve(); + }, 5000); + + wrangler!.on("close", () => { + clearTimeout(forceKill); + resolve(); + }); + }); + + console.log("Wrangler dev stopped."); +} diff --git a/packages/ai-gateway-provider/test/integration/rest-api.test.ts b/packages/ai-gateway-provider/test/integration/rest-api.test.ts new file mode 100644 index 000000000..7e90a673d --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/rest-api.test.ts @@ -0,0 +1,227 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText, streamText } from "ai"; +import { describe, expect, it } from "vitest"; +import { createAIGateway } from "../../src"; + +/** + * Integration tests for the AI Gateway REST API mode. + * + * These run directly from the test process (no worker needed). + * + * Required env vars: + * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID + * OPENAI_API_KEY — OpenAI API key + * CLOUDFLARE_GATEWAY_NAME_UNAUTH — Name of an unauthenticated AI Gateway + * CLOUDFLARE_GATEWAY_NAME_AUTH — Name of an authenticated AI Gateway + * CLOUDFLARE_GATEWAY_AUTH_TOKEN — cf-aig-authorization token for the authenticated gateway + */ + +const { + CLOUDFLARE_ACCOUNT_ID, + OPENAI_API_KEY, + CLOUDFLARE_GATEWAY_NAME_UNAUTH, + CLOUDFLARE_GATEWAY_NAME_AUTH, + CLOUDFLARE_GATEWAY_AUTH_TOKEN, +} = process.env; + +const hasBasicCreds = !!(CLOUDFLARE_ACCOUNT_ID && OPENAI_API_KEY); +const hasUnauthGateway = !!(hasBasicCreds && CLOUDFLARE_GATEWAY_NAME_UNAUTH); +const hasAuthGateway = !!( + hasBasicCreds && + CLOUDFLARE_GATEWAY_NAME_AUTH && + CLOUDFLARE_GATEWAY_AUTH_TOKEN +); + +if (!hasBasicCreds) { + console.warn( + "\n⚠ Missing CLOUDFLARE_ACCOUNT_ID or OPENAI_API_KEY. Skipping REST API integration tests.\n", + ); +} + +// ─── Unauthenticated gateway, user's own API key ──────────────── + +describe.skipIf(!hasUnauthGateway)("REST API: Unauthenticated gateway + own API key", () => { + it("should generate text", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_UNAUTH!, + provider: openai, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + expect(result.text).toBeTruthy(); + expect(typeof result.text).toBe("string"); + expect(result.usage).toBeDefined(); + }); + + it("should stream text", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_UNAUTH!, + provider: openai, + }); + + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBeTruthy(); + }); + + it("should generate text with gateway options", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_UNAUTH!, + provider: openai, + options: { + collectLog: true, + metadata: { test: "rest-api-unauth" }, + }, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + expect(result.text).toBeTruthy(); + }); +}); + +// ─── Authenticated gateway, BYOK ──────────────────────────────── +// (BYOK requires an authenticated gateway with stored provider keys) + +// ─── Authenticated gateway, user's own API key ────────────────── + +describe.skipIf(!hasAuthGateway)("REST API: Authenticated gateway + own API key", () => { + it("should generate text with gateway auth token", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_AUTH!, + apiKey: CLOUDFLARE_GATEWAY_AUTH_TOKEN!, + provider: openai, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + expect(result.text).toBeTruthy(); + }); + + it("should stream text with gateway auth token", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_AUTH!, + apiKey: CLOUDFLARE_GATEWAY_AUTH_TOKEN!, + provider: openai, + }); + + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBeTruthy(); + }); + + it("should throw AiGatewayUnauthorizedError without auth token", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_AUTH!, + provider: openai, + }); + + await expect( + generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }), + ).rejects.toThrow(); + }); +}); + +describe.skipIf(!hasAuthGateway)("REST API: Authenticated gateway + BYOK", () => { + it("should generate text with BYOK + gateway auth", async () => { + const openai = createOpenAI({ apiKey: "unused" }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_AUTH!, + apiKey: CLOUDFLARE_GATEWAY_AUTH_TOKEN!, + provider: openai, + byok: true, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + expect(result.text).toBeTruthy(); + }); + + it("should stream text with BYOK + gateway auth", async () => { + const openai = createOpenAI({ apiKey: "unused" }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: CLOUDFLARE_GATEWAY_NAME_AUTH!, + apiKey: CLOUDFLARE_GATEWAY_AUTH_TOKEN!, + provider: openai, + byok: true, + }); + + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBeTruthy(); + }); +}); + +// ─── Error handling ────────────────────────────────────────────── + +describe.skipIf(!hasBasicCreds)("REST API: Error handling", () => { + it("should throw on invalid gateway name", async () => { + const openai = createOpenAI({ apiKey: OPENAI_API_KEY! }); + const gateway = createAIGateway({ + accountId: CLOUDFLARE_ACCOUNT_ID!, + gateway: "this-gateway-definitely-does-not-exist-abc123", + provider: openai, + }); + + await expect( + generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/ai-gateway-provider/test/integration/vitest.integration.config.ts b/packages/ai-gateway-provider/test/integration/vitest.integration.config.ts new file mode 100644 index 000000000..be62bd6b0 --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/vitest.integration.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["test/integration/**/*.test.ts"], + globalSetup: ["test/integration/global-setup.ts"], + testTimeout: 60000, + }, +}); diff --git a/packages/ai-gateway-provider/test/integration/worker/src/index.ts b/packages/ai-gateway-provider/test/integration/worker/src/index.ts new file mode 100644 index 000000000..f861dbe7c --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/worker/src/index.ts @@ -0,0 +1,101 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText, streamText } from "ai"; +import { createAIGateway } from "../../../../src"; + +interface Env { + AI: { + gateway(name: string): { + run(data: unknown): Promise; + }; + }; + OPENAI_API_KEY: string; + CLOUDFLARE_GATEWAY_NAME_UNAUTH: string; + CLOUDFLARE_GATEWAY_NAME_AUTH: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const gatewayName = + url.searchParams.get("gateway") || env.CLOUDFLARE_GATEWAY_NAME_UNAUTH || "test-gateway"; + const byok = url.searchParams.get("byok") === "true"; + + try { + if (url.pathname === "/health") { + return Response.json({ + status: "ok", + hasOpenAIKey: !!env.OPENAI_API_KEY, + hasAIBinding: !!env.AI, + unauthGateway: env.CLOUDFLARE_GATEWAY_NAME_UNAUTH, + authGateway: env.CLOUDFLARE_GATEWAY_NAME_AUTH, + }); + } + + const openai = byok + ? createOpenAI({ apiKey: "unused" }) + : createOpenAI({ apiKey: env.OPENAI_API_KEY }); + + const gateway = createAIGateway({ + binding: env.AI.gateway(gatewayName), + provider: openai, + byok, + }); + + if (url.pathname === "/generate") { + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + return Response.json({ + text: result.text, + usage: result.usage, + gateway: gatewayName, + byok, + }); + } + + if (url.pathname === "/stream") { + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + return result.toTextStreamResponse(); + } + + if (url.pathname === "/generate-with-options") { + const gatewayWithOptions = createAIGateway({ + binding: env.AI.gateway(gatewayName), + provider: openai, + byok, + options: { + collectLog: true, + metadata: { test: true, byok }, + }, + }); + + const result = await generateText({ + model: gatewayWithOptions("gpt-4o-mini"), + prompt: "Respond with exactly the word 'hello' and nothing else.", + }); + return Response.json({ + text: result.text, + usage: result.usage, + gateway: gatewayName, + byok, + }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + } catch (e) { + const error = { + message: e instanceof Error ? e.message : "Unknown error", + name: e instanceof Error ? e.name : undefined, + stack: e instanceof Error ? e.stack : undefined, + gateway: gatewayName, + byok, + }; + console.error("[integration worker]", error.message, error.stack); + return Response.json(error, { status: 500 }); + } + }, +}; diff --git a/packages/ai-gateway-provider/test/integration/worker/wrangler.jsonc b/packages/ai-gateway-provider/test/integration/worker/wrangler.jsonc new file mode 100644 index 000000000..ec5310340 --- /dev/null +++ b/packages/ai-gateway-provider/test/integration/worker/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "ai-gateway-provider-test", + "main": "src/index.ts", + "compatibility_date": "2025-01-01", + "ai": { + "binding": "AI" + } +} diff --git a/packages/ai-gateway-provider/test/stream-text.test.ts b/packages/ai-gateway-provider/test/stream-text.test.ts deleted file mode 100644 index e8df82f90..000000000 --- a/packages/ai-gateway-provider/test/stream-text.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createOpenAI } from "@ai-sdk/openai"; -import { streamText } from "ai"; -import { http } from "msw"; -import { setupServer } from "msw/node"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createAiGateway } from "../src"; - -const TEST_ACCOUNT_ID = "test-account-id"; -const TEST_API_KEY = "test-api-key"; -const TEST_GATEWAY = "my-gateway"; - -const defaultStreamingHandler = http.post( - `https://gateway.ai.cloudflare.com/v1/${TEST_ACCOUNT_ID}/${TEST_GATEWAY}`, - async () => { - return new Response( - [ - `data: {"type": "response.created", "response": {"id": "resp-test123", "created_at": ${Math.floor(Date.now() / 1000)}, "model": "gpt-4o-mini"}}\n\n`, - `data: {"type": "response.output_item.added", "output_index": 0, "item": {"type": "message", "role": "assistant", "id": "msg-test123", "content": []}}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": "Hello"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": " chunk"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": "1"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": "Hello"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": " chunk"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": "2"}\n\n`, - `data: {"type": "response.output_item.done", "output_index": 0, "item": {"type": "message", "role": "assistant", "id": "msg-test123", "content": [{"type": "output_text", "text": "Hello chunk1Hello chunk2", "annotations": []}]}}\n\n`, - `data: {"type": "response.completed", "response": {"id": "resp-test123", "created_at": ${Math.floor(Date.now() / 1000)}, "model": "gpt-4o-mini", "output": [{"type": "message", "role": "assistant", "id": "msg-test123", "content": [{"type": "output_text", "text": "Hello chunk1Hello chunk2", "annotations": []}]}], "incomplete_details": null, "object": "response", "usage": {"input_tokens": 10, "output_tokens": 8, "total_tokens": 18}}}\n\n`, - "data: [DONE]", - ].join(""), - { - headers: { - "Content-Type": "text/event-stream", - "Transfer-Encoding": "chunked", - }, - status: 200, - }, - ); - }, -); - -const server = setupServer(defaultStreamingHandler); - -describe("REST API - Streaming Text Tests", () => { - beforeAll(() => server.listen()); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - - it("should stream text using", async () => { - const aigateway = createAiGateway({ - accountId: TEST_ACCOUNT_ID, - apiKey: TEST_API_KEY, - gateway: TEST_GATEWAY, - }); - const openai = createOpenAI({ apiKey: TEST_API_KEY }); - - const result = streamText({ - model: aigateway([openai("gpt-4o-mini")]), - prompt: "Please write a multi-part greeting", - }); - - let accumulatedText = ""; - for await (const chunk of result.textStream) { - accumulatedText += chunk; - } - - expect(accumulatedText).toBe("Hello chunk1Hello chunk2"); - }); -}); - -describe("Binding - Streaming Text Tests", () => { - it("should handle chunk", async () => { - const aigateway = createAiGateway({ - binding: { - run: async () => { - return new Response( - [ - `data: {"type": "response.created", "response": {"id": "resp-test123", "created_at": ${Math.floor(Date.now() / 1000)}, "model": "gpt-4o-mini"}}\n\n`, - `data: {"type": "response.output_item.added", "output_index": 0, "item": {"type": "message", "role": "assistant", "id": "msg-test123", "content": []}}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": "Hello"}\n\n`, - `data: {"type": "response.output_text.delta", "item_id": "msg-test123", "delta": " world!"}\n\n`, - `data: {"type": "response.output_item.done", "output_index": 0, "item": {"type": "message", "role": "assistant", "id": "msg-test123", "content": [{"type": "output_text", "text": "Hello world!", "annotations": []}]}}\n\n`, - `data: {"type": "response.completed", "response": {"id": "resp-test123", "created_at": ${Math.floor(Date.now() / 1000)}, "model": "gpt-4o-mini", "output": [{"type": "message", "role": "assistant", "id": "msg-test123", "content": [{"type": "output_text", "text": "Hello world!", "annotations": []}]}], "incomplete_details": null, "object": "response", "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}}}\n\n`, - "data: [DONE]", - ].join(""), - { - headers: { - "Content-Type": "text/event-stream", - "Transfer-Encoding": "chunked", - }, - status: 200, - }, - ); - }, - }, - }); - const openai = createOpenAI({ apiKey: TEST_API_KEY }); - - const result = streamText({ - model: aigateway([openai("gpt-4o-mini")]), - prompt: "Write a greeting", - }); - - let finalText = ""; - for await (const chunk of result.textStream) { - finalText += chunk; - } - - // Delta chunks are combined to produce the final text => "Hello world!" - expect(finalText).toBe("Hello world!"); - }); -}); diff --git a/packages/ai-gateway-provider/test/text-generation.test.ts b/packages/ai-gateway-provider/test/text-generation.test.ts deleted file mode 100644 index ba6d7540d..000000000 --- a/packages/ai-gateway-provider/test/text-generation.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createOpenAI } from "@ai-sdk/openai"; -import { generateText } from "ai"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createAiGateway } from "../src"; - -const TEST_ACCOUNT_ID = "test-account-id"; -const TEST_API_KEY = "test-api-key"; -const TEST_GATEWAY = "my-gateway"; - -const textGenerationHandler = http.post( - `https://gateway.ai.cloudflare.com/v1/${TEST_ACCOUNT_ID}/${TEST_GATEWAY}`, - async () => { - return HttpResponse.json({ - id: "chatcmpl-test123", - created_at: Math.floor(Date.now() / 1000), - model: "gpt-4o-mini", - output: [ - { - type: "message", - role: "assistant", - id: "msg-test123", - content: [ - { - type: "output_text", - text: "Hello", - annotations: [], - }, - ], - }, - ], - incomplete_details: null, - object: "response", - usage: { - input_tokens: 10, - output_tokens: 1, - total_tokens: 11, - }, - }); - }, -); - -const server = setupServer(textGenerationHandler); - -describe("Text Generation Tests", () => { - beforeAll(() => server.listen()); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - - it("should generate text (non-streaming)", async () => { - const aigateway = createAiGateway({ - accountId: TEST_ACCOUNT_ID, - apiKey: TEST_API_KEY, - gateway: TEST_GATEWAY, - }); - const openai = createOpenAI({ apiKey: TEST_API_KEY }); - - const result = await generateText({ - model: aigateway([openai("gpt-4o-mini")]), - prompt: "Write a greeting", - }); - expect(result.text).toBe("Hello"); - }); -}); diff --git a/packages/ai-gateway-provider/test/unit/fallback.test.ts b/packages/ai-gateway-provider/test/unit/fallback.test.ts new file mode 100644 index 000000000..a2cdf50b2 --- /dev/null +++ b/packages/ai-gateway-provider/test/unit/fallback.test.ts @@ -0,0 +1,313 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText, streamText } from "ai"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createAIGatewayFallback } from "../../src"; + +const TEST_ACCOUNT_ID = "test-account-id"; +const TEST_API_KEY = "test-api-key"; +const TEST_GATEWAY = "my-gateway"; +const GATEWAY_URL = `https://gateway.ai.cloudflare.com/v1/${TEST_ACCOUNT_ID}/${TEST_GATEWAY}`; + +function makeGenerateResponse(text: string) { + return { + id: "chatcmpl-test123", + created_at: Math.floor(Date.now() / 1000), + model: "gpt-4o-mini", + output: [ + { + type: "message", + role: "assistant", + id: "msg-test123", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + incomplete_details: null, + object: "response", + usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 }, + }; +} + +function makeStreamResponse(text: string) { + const chunks = text.split(" "); + const lines = [ + `data: {"type":"response.created","response":{"id":"resp-123","created_at":${Math.floor(Date.now() / 1000)},"model":"gpt-4o-mini"}}\n\n`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","id":"msg-123","content":[]}}\n\n`, + ]; + for (let i = 0; i < chunks.length; i++) { + const delta = i < chunks.length - 1 ? `${chunks[i]} ` : chunks[i]; + lines.push( + `data: {"type":"response.output_text.delta","item_id":"msg-123","delta":"${delta}"}\n\n`, + ); + } + lines.push( + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","id":"msg-123","content":[{"type":"output_text","text":"${text}","annotations":[]}]}}\n\n`, + ); + lines.push( + `data: {"type":"response.completed","response":{"id":"resp-123","created_at":${Math.floor(Date.now() / 1000)},"model":"gpt-4o-mini","output":[{"type":"message","role":"assistant","id":"msg-123","content":[{"type":"output_text","text":"${text}","annotations":[]}]}],"object":"response","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n`, + ); + lines.push("data: [DONE]"); + return lines.join(""); +} + +const server = setupServer(); + +describe("createAIGatewayFallback — API mode", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should generate text with the first model (step 0)", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return HttpResponse.json(makeGenerateResponse("First model responded"), { + headers: { "cf-aig-step": "0" }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + const result = await generateText({ + model, + prompt: "Hello", + }); + + expect(result.text).toBe("First model responded"); + }); + + it("should fall back to the second model (step 1)", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return HttpResponse.json(makeGenerateResponse("Fallback model responded"), { + headers: { "cf-aig-step": "1" }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + const result = await generateText({ + model, + prompt: "Hello", + }); + + expect(result.text).toBe("Fallback model responded"); + }); + + it("should send all models in the request body", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("ok"), { + headers: { "cf-aig-step": "0" }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + await generateText({ model, prompt: "Hello" }); + + expect(capturedBody).toHaveLength(2); + expect(capturedBody[0].provider).toBe("openai"); + expect(capturedBody[1].provider).toBe("openai"); + expect(capturedBody[0].query).toBeDefined(); + expect(capturedBody[1].query).toBeDefined(); + }); + + it("should stream text with fallback", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return new HttpResponse(makeStreamResponse("Streamed fallback"), { + headers: { + "Content-Type": "text/event-stream", + "Transfer-Encoding": "chunked", + "cf-aig-step": "0", + }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + const result = streamText({ model, prompt: "Hello" }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBe("Streamed fallback"); + }); + + it("should send gateway options as headers", async () => { + let capturedHeaders: Headers | null = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedHeaders = request.headers; + return HttpResponse.json(makeGenerateResponse("ok"), { + headers: { "cf-aig-step": "0" }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini")], + options: { cacheTtl: 3600, collectLog: true }, + }); + + await generateText({ model, prompt: "Hello" }); + + expect(capturedHeaders!.get("cf-aig-cache-ttl")).toBe("3600"); + expect(capturedHeaders!.get("cf-aig-collect-log")).toBe("true"); + }); + + it("should expose modelId and provider from the first model", () => { + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + expect(model.modelId).toBeDefined(); + expect(model.provider).toBeDefined(); + }); + + it("should throw if models array is empty", () => { + expect(() => + createAIGatewayFallback({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + models: [], + }), + ).toThrow("createAIGatewayFallback requires at least one model"); + }); +}); + +describe("createAIGatewayFallback — Binding mode", () => { + it("should generate text via binding with fallback", async () => { + const mockBinding = { + run: async (_data: unknown) => { + return new Response(JSON.stringify(makeGenerateResponse("Binding fallback ok")), { + headers: { "cf-aig-step": "0" }, + }); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + binding: mockBinding, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + const result = await generateText({ model, prompt: "Hello" }); + + expect(result.text).toBe("Binding fallback ok"); + }); + + it("should send all models to the binding", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("ok")), { + headers: { "cf-aig-step": "0" }, + }); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + binding: mockBinding, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + await generateText({ model, prompt: "Hello" }); + + expect(capturedData).toHaveLength(2); + expect(capturedData[0].provider).toBe("openai"); + expect(capturedData[1].provider).toBe("openai"); + }); + + it("should route to the correct fallback model via cf-aig-step", async () => { + const mockBinding = { + run: async () => { + return new Response(JSON.stringify(makeGenerateResponse("Second model won")), { + headers: { "cf-aig-step": "1" }, + }); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + binding: mockBinding, + models: [openai("gpt-4o-mini"), openai("gpt-4o")], + }); + + const result = await generateText({ model, prompt: "Hello" }); + + expect(result.text).toBe("Second model won"); + }); + + it("should stream with fallback via binding", async () => { + const mockBinding = { + run: async () => { + return new Response(makeStreamResponse("Binding stream fallback"), { + headers: { + "Content-Type": "text/event-stream", + "cf-aig-step": "0", + }, + }); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const model = createAIGatewayFallback({ + binding: mockBinding, + models: [openai("gpt-4o-mini")], + }); + + const result = streamText({ model, prompt: "Hello" }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBe("Binding stream fallback"); + }); +}); diff --git a/packages/ai-gateway-provider/test/unit/gateway.test.ts b/packages/ai-gateway-provider/test/unit/gateway.test.ts new file mode 100644 index 000000000..9d98684b1 --- /dev/null +++ b/packages/ai-gateway-provider/test/unit/gateway.test.ts @@ -0,0 +1,637 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText, streamText } from "ai"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { AiGatewayDoesNotExist, AiGatewayUnauthorizedError, createAIGateway } from "../../src"; + +const TEST_ACCOUNT_ID = "test-account-id"; +const TEST_API_KEY = "test-api-key"; +const TEST_GATEWAY = "my-gateway"; +const GATEWAY_URL = `https://gateway.ai.cloudflare.com/v1/${TEST_ACCOUNT_ID}/${TEST_GATEWAY}`; + +function makeGenerateResponse(text: string) { + return { + id: "chatcmpl-test123", + created_at: Math.floor(Date.now() / 1000), + model: "gpt-4o-mini", + output: [ + { + type: "message", + role: "assistant", + id: "msg-test123", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + incomplete_details: null, + object: "response", + usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 }, + }; +} + +function makeStreamResponse(text: string) { + const chunks = text.split(" "); + const lines = [ + `data: {"type":"response.created","response":{"id":"resp-123","created_at":${Math.floor(Date.now() / 1000)},"model":"gpt-4o-mini"}}\n\n`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","id":"msg-123","content":[]}}\n\n`, + ]; + for (let i = 0; i < chunks.length; i++) { + const delta = i < chunks.length - 1 ? `${chunks[i]} ` : chunks[i]; + lines.push( + `data: {"type":"response.output_text.delta","item_id":"msg-123","delta":"${delta}"}\n\n`, + ); + } + lines.push( + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","id":"msg-123","content":[{"type":"output_text","text":"${text}","annotations":[]}]}}\n\n`, + ); + lines.push( + `data: {"type":"response.completed","response":{"id":"resp-123","created_at":${Math.floor(Date.now() / 1000)},"model":"gpt-4o-mini","output":[{"type":"message","role":"assistant","id":"msg-123","content":[{"type":"output_text","text":"${text}","annotations":[]}]}],"object":"response","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n`, + ); + lines.push("data: [DONE]"); + return lines.join(""); +} + +const defaultHandler = http.post(GATEWAY_URL, async () => { + return HttpResponse.json(makeGenerateResponse("Hello from gateway")); +}); + +const server = setupServer(defaultHandler); + +describe("createAIGateway — API mode", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should generate text through the gateway", async () => { + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("Hello from gateway"); + }); + + it("should stream text through the gateway", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return new HttpResponse(makeStreamResponse("Hello streamed world"), { + headers: { + "Content-Type": "text/event-stream", + "Transfer-Encoding": "chunked", + }, + }); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBe("Hello streamed world"); + }); + + it("should send cf-aig-authorization header with API key", async () => { + let capturedHeaders: Headers | null = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedHeaders = request.headers; + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: "my-secret-key", + gateway: TEST_GATEWAY, + provider: openai, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedHeaders!.get("cf-aig-authorization")).toBe("Bearer my-secret-key"); + }); + + it("should send gateway options as headers", async () => { + let capturedHeaders: Headers | null = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedHeaders = request.headers; + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + options: { + cacheTtl: 3600, + skipCache: true, + metadata: { userId: "user-123" }, + collectLog: true, + eventId: "evt-abc", + }, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedHeaders!.get("cf-aig-skip-cache")).toBe("true"); + expect(capturedHeaders!.get("cf-aig-cache-ttl")).toBe("3600"); + expect(capturedHeaders!.get("cf-aig-metadata")).toBe('{"userId":"user-123"}'); + expect(capturedHeaders!.get("cf-aig-collect-log")).toBe("true"); + expect(capturedHeaders!.get("cf-aig-event-id")).toBe("evt-abc"); + }); + + it("should send correct provider/endpoint/query in the request body", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedBody).toHaveLength(1); + expect(capturedBody[0].provider).toBe("openai"); + expect(capturedBody[0].endpoint).toMatch(/^v1\//); + expect(capturedBody[0].query).toBeDefined(); + expect(capturedBody[0].headers).toBeDefined(); + }); + + it("should include provider auth headers in the request body", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "sk-my-openai-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedBody[0].headers.authorization).toBe("Bearer sk-my-openai-key"); + }); + + it("should throw AiGatewayDoesNotExist on 400 with code 2001", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return HttpResponse.json( + { + success: false, + error: [{ code: 2001, message: "Gateway not found" }], + }, + { status: 400 }, + ); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await expect( + generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }), + ).rejects.toThrow(AiGatewayDoesNotExist); + }); + + it("should throw AiGatewayUnauthorizedError on 401 with code 2009", async () => { + server.use( + http.post(GATEWAY_URL, async () => { + return HttpResponse.json( + { + success: false, + error: [{ code: 2009, message: "Unauthorized" }], + }, + { status: 401 }, + ); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await expect( + generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }), + ).rejects.toThrow(AiGatewayUnauthorizedError); + }); + + it("should expose modelId and provider from the wrapped model", () => { + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + const model = gateway("gpt-4o-mini"); + expect(model.modelId).toBeDefined(); + expect(model.provider).toBeDefined(); + }); +}); + +describe("createAIGateway — providerName (custom base URL)", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should work with a custom baseURL when providerName is set", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("custom ok")); + }), + ); + + const openai = createOpenAI({ + apiKey: "test-key", + baseURL: "https://my-proxy.example.com/v1", + }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + providerName: "openai", + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("custom ok"); + expect(capturedBody[0].provider).toBe("openai"); + expect(capturedBody[0].endpoint).toMatch(/^v1\//); + }); + + it("should throw without providerName when baseURL is unrecognized", async () => { + const openai = createOpenAI({ + apiKey: "test-key", + baseURL: "https://my-proxy.example.com/v1", + }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await expect( + generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }), + ).rejects.toThrow("did not match any known provider"); + }); + + it("should override the auto-detected provider name", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + providerName: "custom-openai", + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedBody[0].provider).toBe("custom-openai"); + expect(capturedBody[0].endpoint).toMatch(/^v1\//); + }); + + it("should work with binding mode and custom baseURL", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("binding custom ok"))); + }, + }; + + const openai = createOpenAI({ + apiKey: "test-key", + baseURL: "https://custom-llm.internal/api/v1", + }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + providerName: "openai", + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("binding custom ok"); + expect(capturedData[0].provider).toBe("openai"); + expect(capturedData[0].endpoint).toMatch(/^api\/v1\//); + }); +}); + +describe("createAIGateway — Binding mode", () => { + it("should generate text via binding", async () => { + const mockBinding = { + run: async (_data: unknown) => { + return new Response(JSON.stringify(makeGenerateResponse("Hello from binding"))); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("Hello from binding"); + }); + + it("should stream text via binding", async () => { + const mockBinding = { + run: async () => { + return new Response(makeStreamResponse("Binding streamed text"), { + headers: { + "Content-Type": "text/event-stream", + "Transfer-Encoding": "chunked", + }, + }); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + }); + + const result = streamText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + let text = ""; + for await (const chunk of result.textStream) { + text += chunk; + } + + expect(text).toBe("Binding streamed text"); + }); + + it("should pass gateway options to the binding as merged headers", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("ok"))); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + options: { cacheTtl: 7200, collectLog: true }, + }); + + await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(capturedData).toBeTruthy(); + expect(capturedData[0].headers["cf-aig-cache-ttl"]).toBe("7200"); + expect(capturedData[0].headers["cf-aig-collect-log"]).toBe("true"); + }); + + it("should pass provider info and query to the binding", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("ok"))); + }, + }; + + const openai = createOpenAI({ apiKey: "test-key" }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + }); + + await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(capturedData).toHaveLength(1); + expect(capturedData[0].provider).toBe("openai"); + expect(capturedData[0].endpoint).toMatch(/^v1\//); + expect(capturedData[0].query).toBeDefined(); + expect(capturedData[0].headers).toBeDefined(); + expect(capturedData[0].headers.authorization).toBe("Bearer test-key"); + }); +}); + +describe("createAIGateway — byok: true (auth header stripping)", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should strip the authorization header when byok is true", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("byok ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "unused" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + byok: true, + }); + + const result = await generateText({ + model: gateway("gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("byok ok"); + expect(capturedBody[0].headers.authorization).toBeUndefined(); + expect(capturedBody[0].headers["x-api-key"]).toBeUndefined(); + }); + + it("should keep the authorization header when byok is false", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("ok")); + }), + ); + + const openai = createOpenAI({ apiKey: "real-key" }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: openai, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedBody[0].headers.authorization).toBe("Bearer real-key"); + }); + + it("should strip auth headers in binding mode with byok", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("ok"))); + }, + }; + + const openai = createOpenAI({ apiKey: "unused" }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: openai, + byok: true, + }); + + await generateText({ model: gateway("gpt-4o-mini"), prompt: "Hello" }); + + expect(capturedData[0].headers.authorization).toBeUndefined(); + }); +}); + +describe("createAIGateway — Unified API (compat endpoint)", () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("should route through the compat endpoint when using OpenAI SDK with compat baseURL", async () => { + let capturedBody: any = null; + server.use( + http.post(GATEWAY_URL, async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json(makeGenerateResponse("compat ok")); + }), + ); + + const compat = createOpenAI({ + apiKey: "test-key", + baseURL: "https://gateway.ai.cloudflare.com/v1/compat", + }); + const gateway = createAIGateway({ + accountId: TEST_ACCOUNT_ID, + apiKey: TEST_API_KEY, + gateway: TEST_GATEWAY, + provider: compat, + }); + + const result = await generateText({ + model: gateway("openai/gpt-4o-mini"), + prompt: "Hello", + }); + + expect(result.text).toBe("compat ok"); + expect(capturedBody[0].provider).toBe("compat"); + expect(capturedBody[0].endpoint).toMatch(/^responses/); + }); + + it("should work with compat endpoint via binding", async () => { + let capturedData: any = null; + const mockBinding = { + run: async (data: unknown) => { + capturedData = data; + return new Response(JSON.stringify(makeGenerateResponse("compat binding ok"))); + }, + }; + + const compat = createOpenAI({ + apiKey: "test-key", + baseURL: "https://gateway.ai.cloudflare.com/v1/compat", + }); + const gateway = createAIGateway({ + binding: mockBinding, + provider: compat, + }); + + const result = await generateText({ + model: gateway("google-ai-studio/gemini-2.5-pro"), + prompt: "Hello", + }); + + expect(result.text).toBe("compat binding ok"); + expect(capturedData[0].provider).toBe("compat"); + }); +}); diff --git a/packages/ai-gateway-provider/test/unit/options.test.ts b/packages/ai-gateway-provider/test/unit/options.test.ts new file mode 100644 index 000000000..fc25ba905 --- /dev/null +++ b/packages/ai-gateway-provider/test/unit/options.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { parseAiGatewayOptions } from "../../src"; + +describe("parseAiGatewayOptions", () => { + it("should return empty headers for empty options", () => { + const headers = parseAiGatewayOptions({}); + expect([...headers.entries()]).toHaveLength(0); + }); + + it("should set cf-aig-skip-cache when skipCache is true", () => { + const headers = parseAiGatewayOptions({ skipCache: true }); + expect(headers.get("cf-aig-skip-cache")).toBe("true"); + }); + + it("should not set cf-aig-skip-cache when skipCache is false", () => { + const headers = parseAiGatewayOptions({ skipCache: false }); + expect(headers.get("cf-aig-skip-cache")).toBeNull(); + }); + + it("should set cf-aig-cache-ttl", () => { + const headers = parseAiGatewayOptions({ cacheTtl: 3600 }); + expect(headers.get("cf-aig-cache-ttl")).toBe("3600"); + }); + + it("should set cf-aig-cache-key", () => { + const headers = parseAiGatewayOptions({ cacheKey: "my-key" }); + expect(headers.get("cf-aig-cache-key")).toBe("my-key"); + }); + + it("should set cf-aig-metadata as JSON", () => { + const metadata = { userId: "123", env: "prod" }; + const headers = parseAiGatewayOptions({ metadata }); + expect(headers.get("cf-aig-metadata")).toBe(JSON.stringify(metadata)); + }); + + it("should set cf-aig-collect-log to true", () => { + const headers = parseAiGatewayOptions({ collectLog: true }); + expect(headers.get("cf-aig-collect-log")).toBe("true"); + }); + + it("should set cf-aig-collect-log to false", () => { + const headers = parseAiGatewayOptions({ collectLog: false }); + expect(headers.get("cf-aig-collect-log")).toBe("false"); + }); + + it("should set cf-aig-event-id", () => { + const headers = parseAiGatewayOptions({ eventId: "evt-123" }); + expect(headers.get("cf-aig-event-id")).toBe("evt-123"); + }); + + it("should set cf-aig-request-timeout", () => { + const headers = parseAiGatewayOptions({ requestTimeoutMs: 5000 }); + expect(headers.get("cf-aig-request-timeout")).toBe("5000"); + }); + + it("should set retry headers", () => { + const headers = parseAiGatewayOptions({ + retries: { + maxAttempts: 3, + retryDelayMs: 1000, + backoff: "exponential", + }, + }); + expect(headers.get("cf-aig-max-attempts")).toBe("3"); + expect(headers.get("cf-aig-retry-delay")).toBe("1000"); + expect(headers.get("cf-aig-backoff")).toBe("exponential"); + }); + + it("should handle partial retry config", () => { + const headers = parseAiGatewayOptions({ + retries: { maxAttempts: 2 }, + }); + expect(headers.get("cf-aig-max-attempts")).toBe("2"); + expect(headers.get("cf-aig-retry-delay")).toBeNull(); + expect(headers.get("cf-aig-backoff")).toBeNull(); + }); + + it("should set cf-aig-byok-alias", () => { + const headers = parseAiGatewayOptions({ byokAlias: "production" }); + expect(headers.get("cf-aig-byok-alias")).toBe("production"); + }); + + it("should set cf-aig-zdr to true", () => { + const headers = parseAiGatewayOptions({ zdr: true }); + expect(headers.get("cf-aig-zdr")).toBe("true"); + }); + + it("should set cf-aig-zdr to false", () => { + const headers = parseAiGatewayOptions({ zdr: false }); + expect(headers.get("cf-aig-zdr")).toBe("false"); + }); + + it("should set all options together", () => { + const headers = parseAiGatewayOptions({ + skipCache: true, + cacheTtl: 7200, + cacheKey: "combo-key", + metadata: { test: true }, + collectLog: true, + eventId: "evt-456", + requestTimeoutMs: 10000, + retries: { maxAttempts: 5, backoff: "linear" }, + byokAlias: "prod", + zdr: true, + }); + expect(headers.get("cf-aig-skip-cache")).toBe("true"); + expect(headers.get("cf-aig-cache-ttl")).toBe("7200"); + expect(headers.get("cf-aig-cache-key")).toBe("combo-key"); + expect(headers.get("cf-aig-metadata")).toBe('{"test":true}'); + expect(headers.get("cf-aig-collect-log")).toBe("true"); + expect(headers.get("cf-aig-event-id")).toBe("evt-456"); + expect(headers.get("cf-aig-request-timeout")).toBe("10000"); + expect(headers.get("cf-aig-max-attempts")).toBe("5"); + expect(headers.get("cf-aig-backoff")).toBe("linear"); + expect(headers.get("cf-aig-byok-alias")).toBe("prod"); + expect(headers.get("cf-aig-zdr")).toBe("true"); + }); +}); diff --git a/packages/ai-gateway-provider/test/unit/providers.test.ts b/packages/ai-gateway-provider/test/unit/providers.test.ts new file mode 100644 index 000000000..346f9824a --- /dev/null +++ b/packages/ai-gateway-provider/test/unit/providers.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { providers } from "../../src/providers"; + +const testCases = [ + { + name: "openai", + url: "https://api.openai.com/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "deepseek", + url: "https://api.deepseek.com/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "anthropic", + url: "https://api.anthropic.com/v1/messages", + expected: "v1/messages", + }, + { + name: "google-ai-studio", + url: "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", + expected: "v1beta/models/gemini-pro:generateContent", + }, + { + name: "grok", + url: "https://api.x.ai/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "mistral", + url: "https://api.mistral.ai/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "perplexity-ai", + url: "https://api.perplexity.ai/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "replicate", + url: "https://api.replicate.com/v1/predictions", + expected: "v1/predictions", + }, + { + name: "groq", + url: "https://api.groq.com/openai/v1/chat/completions", + expected: "chat/completions", + }, + { + name: "azure-openai", + url: "https://myresource.openai.azure.com/openai/deployments/mydeployment/chat/completions?api-version=2024-02-15-preview", + expected: "myresource/mydeployment/chat/completions?api-version=2024-02-15-preview", + }, + { + name: "openrouter", + url: "https://openrouter.ai/api/v1/chat/completions", + expected: "v1/chat/completions", + }, + { + name: "aws-bedrock", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v1/invoke", + expected: "bedrock-runtime/us-east-1/model/amazon.titan-embed-text-v1/invoke", + }, + { + name: "aws-bedrock", + url: "https://bedrock-runtime.eu-west-1.amazonaws.com/model/anthropic.claude-v2/invoke", + expected: "bedrock-runtime/eu-west-1/model/anthropic.claude-v2/invoke", + }, + { + name: "cerebras", + url: "https://api.cerebras.ai/v1/chat/completions", + expected: "chat/completions", + }, + { + name: "cohere", + url: "https://api.cohere.com/v2/chat", + expected: "v2/chat", + }, + { + name: "cohere", + url: "https://api.cohere.ai/v1/chat", + expected: "v1/chat", + }, + { + name: "deepgram", + url: "https://api.deepgram.com/v1/listen", + expected: "v1/listen", + }, + { + name: "elevenlabs", + url: "https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb", + expected: "v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb", + }, + { + name: "fireworks", + url: "https://api.fireworks.ai/inference/v1/chat/completions", + expected: "chat/completions", + }, + { + name: "huggingface", + url: "https://api-inference.huggingface.co/models/bigcode/starcoder", + expected: "bigcode/starcoder", + }, + { + name: "cartesia", + url: "https://api.cartesia.ai/v1/tts/bytes", + expected: "v1/tts/bytes", + }, + { + name: "fal", + url: "https://fal.run/fal-ai/fast-sdxl", + expected: "fal-ai/fast-sdxl", + }, + { + name: "ideogram", + url: "https://api.ideogram.ai/generate", + expected: "generate", + }, + { + name: "compat", + url: "https://gateway.ai.cloudflare.com/v1/compat/chat/completions", + expected: "chat/completions", + }, +]; + +describe("Provider URL matching and endpoint transformation", () => { + for (const testCase of testCases) { + it(`should match and transform "${testCase.name}" — ${testCase.url}`, () => { + const provider = providers.find( + (p) => p.name === testCase.name && p.regex.test(testCase.url), + ); + expect(provider).toBeDefined(); + expect(provider!.transformEndpoint(testCase.url)).toBe(testCase.expected); + }); + } + + it("should not match unrecognized URLs", () => { + const url = "https://some-random-api.example.com/v1/chat"; + const matched = providers.some((p) => p.regex.test(url)); + expect(matched).toBe(false); + }); + + it("should match Google Vertex AI with region prefix", () => { + const vertexUrl = + "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-pro:generateContent"; + const matched = providers.filter((p) => p.regex.test(vertexUrl)); + expect(matched).toHaveLength(1); + expect(matched[0]!.name).toBe("google-vertex-ai"); + }); + + it("should not match URLs that merely contain aiplatform.googleapis.com as a substring", () => { + const spoofUrl = "https://evil.com/aiplatform.googleapis.com/v1/models"; + const matched = providers.some((p) => p.regex.test(spoofUrl)); + expect(matched).toBe(false); + }); +}); diff --git a/packages/ai-gateway-provider/test/unit/resolve-provider.test.ts b/packages/ai-gateway-provider/test/unit/resolve-provider.test.ts new file mode 100644 index 000000000..5915a9d4c --- /dev/null +++ b/packages/ai-gateway-provider/test/unit/resolve-provider.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { resolveProvider } from "../../src"; + +describe("resolveProvider", () => { + describe("without explicit providerName", () => { + it("should match a known OpenAI URL", () => { + const result = resolveProvider("https://api.openai.com/v1/responses"); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/responses"); + }); + + it("should match a known Anthropic URL", () => { + const result = resolveProvider("https://api.anthropic.com/v1/messages"); + expect(result.name).toBe("anthropic"); + expect(result.endpoint).toBe("v1/messages"); + }); + + it("should match a known Groq URL", () => { + const result = resolveProvider("https://api.groq.com/openai/v1/chat/completions"); + expect(result.name).toBe("groq"); + expect(result.endpoint).toBe("chat/completions"); + }); + + it("should throw for an unrecognized URL", () => { + expect(() => resolveProvider("https://my-proxy.example.com/v1/responses")).toThrow( + 'URL "https://my-proxy.example.com/v1/responses" did not match any known provider', + ); + }); + + it("should suggest providerName in the error message", () => { + expect(() => resolveProvider("https://custom.host/api/chat")).toThrow( + "Set providerName", + ); + }); + }); + + describe("newly added providers", () => { + it("should match Amazon Bedrock and include region in endpoint", () => { + const result = resolveProvider( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-embed-text-v1/invoke", + ); + expect(result.name).toBe("aws-bedrock"); + expect(result.endpoint).toBe( + "bedrock-runtime/us-east-1/model/amazon.titan-embed-text-v1/invoke", + ); + }); + + it("should match Cerebras and strip /v1 prefix", () => { + const result = resolveProvider("https://api.cerebras.ai/v1/chat/completions"); + expect(result.name).toBe("cerebras"); + expect(result.endpoint).toBe("chat/completions"); + }); + + it("should match Cohere (.com domain)", () => { + const result = resolveProvider("https://api.cohere.com/v2/chat"); + expect(result.name).toBe("cohere"); + expect(result.endpoint).toBe("v2/chat"); + }); + + it("should match Cohere (.ai domain)", () => { + const result = resolveProvider("https://api.cohere.ai/v1/chat"); + expect(result.name).toBe("cohere"); + expect(result.endpoint).toBe("v1/chat"); + }); + + it("should match Deepgram", () => { + const result = resolveProvider("https://api.deepgram.com/v1/listen"); + expect(result.name).toBe("deepgram"); + expect(result.endpoint).toBe("v1/listen"); + }); + + it("should match ElevenLabs", () => { + const result = resolveProvider("https://api.elevenlabs.io/v1/text-to-speech/abc123"); + expect(result.name).toBe("elevenlabs"); + expect(result.endpoint).toBe("v1/text-to-speech/abc123"); + }); + + it("should match Fireworks and strip /inference/v1 prefix", () => { + const result = resolveProvider( + "https://api.fireworks.ai/inference/v1/chat/completions", + ); + expect(result.name).toBe("fireworks"); + expect(result.endpoint).toBe("chat/completions"); + }); + + it("should match HuggingFace and strip /models prefix", () => { + const result = resolveProvider( + "https://api-inference.huggingface.co/models/bigcode/starcoder", + ); + expect(result.name).toBe("huggingface"); + expect(result.endpoint).toBe("bigcode/starcoder"); + }); + + it("should match Cartesia", () => { + const result = resolveProvider("https://api.cartesia.ai/v1/tts/bytes"); + expect(result.name).toBe("cartesia"); + expect(result.endpoint).toBe("v1/tts/bytes"); + }); + + it("should match Fal AI", () => { + const result = resolveProvider("https://fal.run/fal-ai/fast-sdxl"); + expect(result.name).toBe("fal"); + expect(result.endpoint).toBe("fal-ai/fast-sdxl"); + }); + + it("should match Ideogram", () => { + const result = resolveProvider("https://api.ideogram.ai/generate"); + expect(result.name).toBe("ideogram"); + expect(result.endpoint).toBe("generate"); + }); + + it("should match the compat (Unified API) endpoint", () => { + const result = resolveProvider( + "https://gateway.ai.cloudflare.com/v1/compat/chat/completions", + ); + expect(result.name).toBe("compat"); + expect(result.endpoint).toBe("chat/completions"); + }); + }); + + describe("with explicit providerName", () => { + it("should use the explicit name when URL matches the registry", () => { + const result = resolveProvider("https://api.openai.com/v1/responses", "my-custom-name"); + expect(result.name).toBe("my-custom-name"); + expect(result.endpoint).toBe("v1/responses"); + }); + + it("should still use the registry transform when URL matches", () => { + const result = resolveProvider( + "https://api.groq.com/openai/v1/chat/completions", + "groq", + ); + expect(result.name).toBe("groq"); + expect(result.endpoint).toBe("chat/completions"); + }); + + it("should fall back to pathname for unrecognized URLs", () => { + const result = resolveProvider( + "https://my-proxy.example.com/v1/chat/completions", + "openai", + ); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/chat/completions"); + }); + + it("should handle a custom base URL with nested path", () => { + const result = resolveProvider( + "https://proxy.corp.net/llm/openai/v1/responses", + "openai", + ); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("llm/openai/v1/responses"); + }); + + it("should handle a root-level custom URL", () => { + const result = resolveProvider("https://localhost:8080/v1/chat/completions", "openai"); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/chat/completions"); + }); + + it("should preserve query parameters for custom URLs", () => { + const result = resolveProvider( + "https://custom-api.example.com/v1/completions?stream=true", + "openai", + ); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/completions?stream=true"); + }); + + it("should preserve multiple query parameters", () => { + const result = resolveProvider( + "https://custom-api.example.com/v1/chat?api-version=2024-02-15&stream=true", + "openai", + ); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/chat?api-version=2024-02-15&stream=true"); + }); + + it("should handle custom URLs with no query parameters", () => { + const result = resolveProvider("https://custom-api.example.com/v1/chat", "openai"); + expect(result.name).toBe("openai"); + expect(result.endpoint).toBe("v1/chat"); + }); + }); +}); diff --git a/packages/ai-gateway-provider/tsup.config.ts b/packages/ai-gateway-provider/tsup.config.ts index 8e16fd32c..eada33de1 100644 --- a/packages/ai-gateway-provider/tsup.config.ts +++ b/packages/ai-gateway-provider/tsup.config.ts @@ -1,15 +1,11 @@ import { defineConfig } from "tsup"; -import pkg from "./package.json"; export default defineConfig({ - entry: ["src/index.ts", "src/providers/*"], + entry: ["src/index.ts"], splitting: false, sourcemap: true, clean: true, dts: true, format: ["cjs", "esm"], - external: Object.keys(pkg.optionalDependencies ?? {}).filter( - (dep) => dep !== "@ai-sdk/google-vertex", - ), target: "es2020", }); diff --git a/packages/ai-gateway-provider/vitest.config.ts b/packages/ai-gateway-provider/vitest.config.ts index 695acd9d1..c4a7fd44e 100644 --- a/packages/ai-gateway-provider/vitest.config.ts +++ b/packages/ai-gateway-provider/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, + include: ["test/unit/**/*.test.ts"], passWithNoTests: true, }, }); diff --git a/packages/workers-ai-provider/README.md b/packages/workers-ai-provider/README.md index d6d9c5876..ecf2b1f06 100644 --- a/packages/workers-ai-provider/README.md +++ b/packages/workers-ai-provider/README.md @@ -56,7 +56,9 @@ const workersai = createWorkersAI({ ### AI Gateway -Route requests through [AI Gateway](https://developers.cloudflare.com/ai-gateway/) for caching, rate limiting, and observability: +Route requests through [AI Gateway](https://developers.cloudflare.com/ai-gateway/) for caching, rate limiting, and observability. + +**With a binding (recommended):** ```ts const workersai = createWorkersAI({ @@ -65,6 +67,30 @@ const workersai = createWorkersAI({ }); ``` +The `gateway` option is passed to the binding's `run()` call natively — all gateway features (caching, logging, analytics, rate limiting) apply automatically. + +**With the REST API:** + +The `gateway` option is not supported in REST mode. Instead, point the REST URL at the AI Gateway's Workers AI endpoint directly using [`ai-gateway-provider`](https://www.npmjs.com/package/ai-gateway-provider) with `providerName`: + +```ts +import { createWorkersAI } from "workers-ai-provider"; +import { createAIGateway } from "ai-gateway-provider"; + +const workersai = createWorkersAI({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiKey: process.env.CLOUDFLARE_API_TOKEN, +}); + +// This won't work — Workers AI doesn't use config.fetch +// createAIGateway({ provider: workersai, ... }) + +// For REST + Gateway, use the AI Gateway's Workers AI endpoint directly: +// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/workers-ai +``` + +For the REST path, we recommend using the binding approach in a Cloudflare Worker instead, which gives you native gateway support without extra configuration. + ## Models Browse the full catalog at [developers.cloudflare.com/workers-ai/models](https://developers.cloudflare.com/workers-ai/models/). diff --git a/packages/workers-ai-provider/test/e2e/fixtures/binding-worker/src/index.ts b/packages/workers-ai-provider/test/e2e/fixtures/binding-worker/src/index.ts index d05b3296b..8dc7c5ad5 100644 --- a/packages/workers-ai-provider/test/e2e/fixtures/binding-worker/src/index.ts +++ b/packages/workers-ai-provider/test/e2e/fixtures/binding-worker/src/index.ts @@ -45,7 +45,10 @@ export default { const body = (await request.json()) as { model?: string }; const model = body.model || "@cf/meta/llama-4-scout-17b-16e-instruct"; - const provider = createWorkersAI({ binding: env.AI }); + const gatewayId = url.searchParams.get("gateway") || undefined; + const provider = gatewayId + ? createWorkersAI({ binding: env.AI, gateway: { id: gatewayId } }) + : createWorkersAI({ binding: env.AI }); try { switch (url.pathname) { diff --git a/packages/workers-ai-provider/test/e2e/workers-ai-gateway.e2e.test.ts b/packages/workers-ai-provider/test/e2e/workers-ai-gateway.e2e.test.ts new file mode 100644 index 000000000..4b649ecbd --- /dev/null +++ b/packages/workers-ai-provider/test/e2e/workers-ai-gateway.e2e.test.ts @@ -0,0 +1,213 @@ +/** + * E2E tests for Workers AI routed through AI Gateway via the binding's + * built-in `gateway` option. + * + * These tests reuse the same binding-worker as the other binding e2e tests, + * but pass a `?gateway=` query parameter so the worker creates the + * provider with `gateway: { id }`. + * + * Prerequisites: + * - Authenticated with Cloudflare (`wrangler login`) + * - An AI Gateway configured in your Cloudflare dashboard + * - Set WORKERS_AI_GATEWAY_NAME env var (or defaults to "test-gateway") + * + * Run with: pnpm test:e2e:binding + */ +import { type ChildProcess, spawn } from "node:child_process"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const WORKER_DIR = new URL("./fixtures/binding-worker", import.meta.url).pathname; +const PORT = 8798; +const BASE = `http://localhost:${PORT}`; +const GATEWAY_NAME = process.env.WORKERS_AI_GATEWAY_NAME || "test-gateway"; + +async function post( + path: string, + body: Record = {}, +): Promise> { + const url = new URL(path, BASE); + url.searchParams.set("gateway", GATEWAY_NAME); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.json() as Promise>; +} + +async function waitForReady(url: string, timeoutMs = 45_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +let wranglerProcess: ChildProcess | null = null; +let serverReady = false; + +describe("Workers AI via AI Gateway (binding)", () => { + beforeAll(async () => { + wranglerProcess = spawn( + "pnpm", + ["exec", "wrangler", "dev", "--port", String(PORT), "--log-level", "error"], + { + cwd: WORKER_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }, + ); + + let stderr = ""; + wranglerProcess.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + wranglerProcess.on("error", (err) => { + console.error("[gateway-e2e] Failed to start wrangler:", err.message); + }); + + serverReady = await waitForReady(`${BASE}/health`, 50_000); + if (!serverReady) { + console.error("[gateway-e2e] wrangler dev failed to start within 50s"); + if (stderr) console.error("[gateway-e2e] stderr:", stderr); + } + }, 60_000); + + afterAll(async () => { + if (wranglerProcess) { + wranglerProcess.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 1_000)); + if (!wranglerProcess.killed) { + wranglerProcess.kill("SIGKILL"); + } + wranglerProcess = null; + } + }, 10_000); + + describe("chat via gateway", () => { + it("should generate text through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/chat", { + model: "@cf/meta/llama-3.1-8b-instruct-fast", + }); + + if (data.error) { + console.error("[gateway] chat error:", data.error); + } + expect(data.error).toBeUndefined(); + expect(typeof data.text).toBe("string"); + expect((data.text as string).length).toBeGreaterThan(0); + }); + + it("should stream text through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/chat/stream", { + model: "@cf/meta/llama-3.1-8b-instruct-fast", + }); + + if (data.error) { + console.error("[gateway] stream error:", data.error); + } + expect(data.error).toBeUndefined(); + expect(typeof data.text).toBe("string"); + expect((data.text as string).length).toBeGreaterThan(0); + }); + }); + + describe("multi-turn via gateway", () => { + it("should remember context across turns", async () => { + if (!serverReady) return; + + const data = await post("/chat/multi-turn", { + model: "@cf/meta/llama-3.1-8b-instruct-fast", + }); + + if (data.error) { + console.error("[gateway] multi-turn error:", data.error); + } + expect(data.error).toBeUndefined(); + expect(typeof data.text).toBe("string"); + expect((data.text as string).toLowerCase()).toContain("alice"); + }); + }); + + describe("tool calling via gateway", () => { + it("should make tool calls through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/chat/tool-call", { + model: "@cf/meta/llama-3.1-8b-instruct-fast", + }); + + if (data.error) { + console.error("[gateway] tool-call error:", data.error); + } + expect(data.error).toBeUndefined(); + const toolCalls = data.toolCalls as unknown[]; + expect(Array.isArray(toolCalls)).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + }); + }); + + describe("structured output via gateway", () => { + it("should return structured output through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/chat/structured", { + model: "@cf/meta/llama-3.1-8b-instruct-fast", + }); + + if (data.error) { + console.error("[gateway] structured error:", data.error); + } + expect(data.error).toBeUndefined(); + const result = data.result as Record; + expect(result).toBeDefined(); + expect(typeof result.name).toBe("string"); + expect(typeof result.capital).toBe("string"); + }); + }); + + describe("embeddings via gateway", () => { + it("should generate embeddings through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/embed", { + model: "@cf/baai/bge-base-en-v1.5", + }); + + if (data.error) { + console.error("[gateway] embed error:", data.error); + } + expect(data.error).toBeUndefined(); + expect(data.count).toBe(2); + expect(data.dimensions).toBe(768); + }); + }); + + describe("image generation via gateway", () => { + it("should generate images through AI Gateway", async () => { + if (!serverReady) return; + + const data = await post("/image", { + model: "@cf/black-forest-labs/flux-1-schnell", + }); + + if (data.error) { + console.error("[gateway] image error:", data.error); + } + expect(data.error).toBeUndefined(); + expect(data.imageCount).toBe(1); + expect(data.imageSize as number).toBeGreaterThan(100); + }); + }); +}); From 10e25e195473a70552a3d4859d13303c6c4f9f4e Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sun, 22 Feb 2026 10:04:10 +0000 Subject: [PATCH 2/3] Remove @ai-sdk/google-vertex patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the local patch for @ai-sdk/google-vertex and clear patchedDependencies in package.json. pnpm-lock.yaml was updated (regenerated) to remove references to the patch and related patched package entries — the patch is no longer required. --- package.json | 4 +- patches/@ai-sdk__google-vertex.patch | 24 -- pnpm-lock.yaml | 467 --------------------------- 3 files changed, 1 insertion(+), 494 deletions(-) delete mode 100644 patches/@ai-sdk__google-vertex.patch diff --git a/package.json b/package.json index 38acdcd81..c93e58c85 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,6 @@ "sharp", "workerd" ], - "patchedDependencies": { - "@ai-sdk/google-vertex": "patches/@ai-sdk__google-vertex.patch" - } + "patchedDependencies": {} } } diff --git a/patches/@ai-sdk__google-vertex.patch b/patches/@ai-sdk__google-vertex.patch deleted file mode 100644 index ce169e466..000000000 --- a/patches/@ai-sdk__google-vertex.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/dist/edge/index.js b/dist/edge/index.js -index 079ab83ed4518113abdc847cc2f644a654a656ec..beffbc925b35aea015d27e936cf5208bcbcc9c8b 100644 ---- a/dist/edge/index.js -+++ b/dist/edge/index.js -@@ -587,6 +587,7 @@ var buildJwt = async (credentials) => { - }; - async function generateAuthToken(credentials) { - try { -+ if(credentials.cfApiKey) return credentials.cfApiKey; - const creds = credentials || await loadCredentials(); - const jwt = await buildJwt(creds); - const response = await fetch("https://oauth2.googleapis.com/token", { -diff --git a/dist/edge/index.mjs b/dist/edge/index.mjs -index 89a0c0ef49b4beaa1d53fdff49f1dea93333dc8a..be6988f70fc17c058183c17da2fd9f1a9a9dee26 100644 ---- a/dist/edge/index.mjs -+++ b/dist/edge/index.mjs -@@ -587,6 +587,7 @@ var buildJwt = async (credentials) => { - }; - async function generateAuthToken(credentials) { - try { -+ if(credentials.cfApiKey) return credentials.cfApiKey; - const creds = credentials || await loadCredentials(); - const jwt = await buildJwt(creds); - const response = await fetch("https://oauth2.googleapis.com/token", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e896c2932..883488bdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -patchedDependencies: - '@ai-sdk/google-vertex': - hash: 5015d8c7ff40e99b6b6d204430f0750dda43beda4beda4e6817b38cd2b057184 - path: patches/@ai-sdk__google-vertex.patch - importers: .: @@ -1640,18 +1635,12 @@ importers: packages/ai-gateway-provider: dependencies: - '@ai-sdk/openai-compatible': - specifier: ^2.0.0 - version: 2.0.0(zod@4.3.6) '@ai-sdk/provider': specifier: ^3.0.0 version: 3.0.0 '@ai-sdk/provider-utils': specifier: ^4.0.0 version: 4.0.0(zod@4.3.6) - ai: - specifier: ^6.0.0 - version: 6.0.1(zod@4.3.6) devDependencies: tsup: specifier: ^8.5.1 @@ -1659,58 +1648,6 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 - optionalDependencies: - '@ai-sdk/amazon-bedrock': - specifier: ^4.0.62 - version: 4.0.62(zod@4.3.6) - '@ai-sdk/anthropic': - specifier: ^3.0.46 - version: 3.0.46(zod@4.3.6) - '@ai-sdk/azure': - specifier: ^3.0.31 - version: 3.0.31(zod@4.3.6) - '@ai-sdk/cerebras': - specifier: ^2.0.34 - version: 2.0.34(zod@4.3.6) - '@ai-sdk/cohere': - specifier: ^3.0.21 - version: 3.0.21(zod@4.3.6) - '@ai-sdk/deepgram': - specifier: ^2.0.20 - version: 2.0.20(zod@4.3.6) - '@ai-sdk/deepseek': - specifier: ^2.0.20 - version: 2.0.20(zod@4.3.6) - '@ai-sdk/elevenlabs': - specifier: ^2.0.20 - version: 2.0.20(zod@4.3.6) - '@ai-sdk/fireworks': - specifier: ^2.0.34 - version: 2.0.34(zod@4.3.6) - '@ai-sdk/google': - specifier: ^3.0.30 - version: 3.0.30(zod@4.3.6) - '@ai-sdk/google-vertex': - specifier: ^4.0.61 - version: 4.0.61(patch_hash=5015d8c7ff40e99b6b6d204430f0750dda43beda4beda4e6817b38cd2b057184)(zod@4.3.6) - '@ai-sdk/groq': - specifier: ^3.0.24 - version: 3.0.24(zod@4.3.6) - '@ai-sdk/mistral': - specifier: ^3.0.20 - version: 3.0.20(zod@4.3.6) - '@ai-sdk/openai': - specifier: ^3.0.30 - version: 3.0.30(zod@4.3.6) - '@ai-sdk/perplexity': - specifier: ^3.0.19 - version: 3.0.19(zod@4.3.6) - '@ai-sdk/xai': - specifier: ^3.0.57 - version: 3.0.57(zod@4.3.6) - '@openrouter/ai-sdk-provider': - specifier: ^2.2.3 - version: 2.2.3(ai@6.0.1(zod@4.3.6))(zod@4.3.6) packages/tanstack-ai: dependencies: @@ -1801,120 +1738,18 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@ai-sdk/amazon-bedrock@4.0.62': - resolution: {integrity: sha512-d5ng22ROzhUgUZ4UTGHIAIWx/0q8Xen6NRB2JezKqJdctZgwS2YF0quqBRmk5qu6kZ00ZfifOfDtaHKhJ2A2SQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/anthropic@3.0.46': - resolution: {integrity: sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/azure@3.0.31': - resolution: {integrity: sha512-W9x6nt+yf+Ns0/Wx7U9TXHLmfu7mOUqy1b/drtVd3DvNfDudyruQM/YjM2268Q0FatSrPlA2RlnPVPGRH/4V8Q==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/cerebras@2.0.34': - resolution: {integrity: sha512-B/so5YrWypY3XXrtKoyfcWRtBX69kLsVDAaM1JGqay98DGGs8Ikh0i2P7UKOv/m0LsfOlff/mlYevPqGhwfuEw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/cohere@3.0.21': - resolution: {integrity: sha512-HDUvNX2UhGpX0VyeAdOStFy07ZvjFtL+hXT5cXcwK/TBNiIhPFovTYHKVNSdNAxbH2eO7zRzygjMVUEX1X0Btw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/deepgram@2.0.20': - resolution: {integrity: sha512-eCRwZt5LgpdbWJAMsBKxbIG0XsE0mhiziIGTCVBU3ZlhKEYozidoRkpgTirr7bmy8Qvj+4SvcKyel35M5vuliQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/deepseek@2.0.20': - resolution: {integrity: sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/elevenlabs@2.0.20': - resolution: {integrity: sha512-er+hlxKBNMP168sdAgzFkoH1sWNcJssg7SP7TMSWO3TjMrJmNgUsQpNMDSYzoaP6F+PlZwINaG/fkKz8h5SVuw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/fireworks@2.0.34': - resolution: {integrity: sha512-9SpijdfzyhS8XrutMu+hag4ST8LbDtoNaLuRMwfDR92JO4xyvSSVjhfuPvHsGQaXL+bcj0dUcQbB4bOyIv6z0A==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/gateway@3.0.0': - resolution: {integrity: sha512-JcjePYVpbezv+XOxkxPemwnorjWpgDiiKWMYy6FXTCG2rFABIK2Co1bFxIUSDT4vYO6f1448x9rKbn38vbhDiA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.52': resolution: {integrity: sha512-lYCXP8T3YnIDiz8DP7loAMT27wnblc3IAYzQ7igg89RCRyTUjk6ffbxHXXQ5Pmv8jrdLF0ZIJnH54Dsr1OCKHg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google-vertex@4.0.61': - resolution: {integrity: sha512-cTi/qcvqNmrOrCBekrJEXnn4yCTiWyBb9gO6Ofn0OqRboGJOCEfDp5RHWlRREWHhDOJPyZAk2mY8FDJW2PrtUA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/google@3.0.30': - resolution: {integrity: sha512-ZzG6dU0XUSSXbxQJJTQUFpWeKkfzdpR7IykEZwaiaW5d+3u3RZ/zkRiGwAOcUpLp6k0eMd+IJF4looJv21ecxw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/groq@3.0.24': - resolution: {integrity: sha512-J6UMMVKBDf1vxYN8TS4nBzCEImhon1vuqpJYkRYdbxul6Hlf0r0pT5/+1AD1nbQ1SJsOPlDqMRSYJuBnNYrNfQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/mistral@3.0.20': - resolution: {integrity: sha512-oZcx2pE6nJ+Qj/U6HFV5mJ52jXJPBSpvki/NtIocZkI/rKxphKBaecOH1h0Y7yK3HIbBxsMqefB1pb72cAHGVg==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/openai-compatible@2.0.0': - resolution: {integrity: sha512-dQITHbDWLtIVB0Kpb9AXRMAZC2n4azS2T/uaVq8HOgw2Uy9x19usS0DIoXlTbj3R4rJuJXpAUhYowQ+YcK16WQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/openai-compatible@2.0.30': - resolution: {integrity: sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.30': resolution: {integrity: sha512-YDht3t7TDyWKP+JYZp20VuYqSjyF2brHYh47GGFDUPf2wZiqNQ263ecL+quar2bP3GZ3BeQA8f0m2B7UwLPR+g==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/perplexity@3.0.19': - resolution: {integrity: sha512-7yxvyw0OFlHjCXgf+BDgmjefQmSk9FxSF5DPiFtLrow1zzLcvZvh6fkKEx+kgRQXNKhV9vvtH0U4NyVXgGMr0g==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@4.0.0': resolution: {integrity: sha512-HyCyOls9I3a3e38+gtvOJOEjuw9KRcvbBnCL5GBuSmJvS9Jh9v3fz7pRC6ha1EUo/ZH1zwvLWYXBMtic8MTguA==} engines: {node: '>=18'} @@ -1941,12 +1776,6 @@ packages: peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 - '@ai-sdk/xai@3.0.57': - resolution: {integrity: sha512-fY8MpcU1akfQStB/vDAAjJqJRWWGfHpRsNa31GNMlLLwHvwdyNhQVW8NtmIMrHDE+38pz/b0aMENJ4cb75qGPA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@anthropic-ai/sdk@0.71.2': resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} hasBin: true @@ -1969,17 +1798,6 @@ packages: resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/types@3.901.0': - resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} - engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3220,13 +3038,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@openrouter/ai-sdk-provider@2.2.3': - resolution: {integrity: sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g==} - engines: {node: '>=18'} - peerDependencies: - ai: ^6.0.0 - zod: ^3.25.0 || ^4.0.0 - '@openrouter/sdk@0.3.15': resolution: {integrity: sha512-tmiMQGu6L1fHD9NpIABN9LALEZitqM27CFebSVyJTQDcxCcR3m1F6v1O1MnOlLmedCFU+/BojniRGFAXo1F3Bw==} @@ -3711,42 +3522,6 @@ packages: resolution: {integrity: sha512-RoygyteJeFswxDPJjUMESn9dldWVMD2xUcHHd9DenVavSfVC6FeVnSdDerOO7m8LLvw4Q132nQM4hX8JiF7dng==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@smithy/eventstream-codec@4.2.0': - resolution: {integrity: sha512-XE7CtKfyxYiNZ5vz7OvyTf1osrdbJfmUy+rbh+NLQmZumMGvY0mT0Cq1qKSfhrvLtRYzMsOBuRpi10dyI0EBPg==} - engines: {node: '>=18.0.0'} - - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.6.0': - resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -4016,10 +3791,6 @@ packages: '@types/wait-on@5.3.4': resolution: {integrity: sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==} - '@vercel/oidc@3.0.5': - resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} - engines: {node: '>= 20'} - '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} @@ -4139,12 +3910,6 @@ packages: viem: optional: true - ai@6.0.1: - resolution: {integrity: sha512-g/jPakC6h4vUJKDww0d6+VaJmfMC38UqH3kKsngiP+coT0uvCUdQ7lpFDJ0mNmamaOyRMaY2zwEB2RnTAaJU/w==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ai@6.0.94: resolution: {integrity: sha512-/F9wh262HbK05b/5vILh38JvPiheonT+kBj1L97712E7VPchqmcx7aJuZN3QSk5Pj6knxUJLm2FFpYJI1pHXUA==} engines: {node: '>=18'} @@ -4231,9 +3996,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - aws4fetch@1.0.20: - resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -6811,83 +6573,6 @@ snapshots: '@adraffy/ens-normalize@1.11.1': optional: true - '@ai-sdk/amazon-bedrock@4.0.62(zod@4.3.6)': - dependencies: - '@ai-sdk/anthropic': 3.0.46(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - '@smithy/eventstream-codec': 4.2.0 - '@smithy/util-utf8': 4.2.0 - aws4fetch: 1.0.20 - zod: 4.3.6 - optional: true - - '@ai-sdk/anthropic@3.0.46(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/azure@3.0.31(zod@4.3.6)': - dependencies: - '@ai-sdk/openai': 3.0.30(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/cerebras@2.0.34(zod@4.3.6)': - dependencies: - '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/cohere@3.0.21(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/deepgram@2.0.20(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/deepseek@2.0.20(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/elevenlabs@2.0.20(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/fireworks@2.0.34(zod@4.3.6)': - dependencies: - '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/gateway@3.0.0(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) - '@vercel/oidc': 3.0.5 - zod: 4.3.6 - '@ai-sdk/gateway@3.0.52(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -6895,65 +6580,12 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.6 - '@ai-sdk/google-vertex@4.0.61(patch_hash=5015d8c7ff40e99b6b6d204430f0750dda43beda4beda4e6817b38cd2b057184)(zod@4.3.6)': - dependencies: - '@ai-sdk/anthropic': 3.0.46(zod@4.3.6) - '@ai-sdk/google': 3.0.30(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - google-auth-library: 10.5.0 - zod: 4.3.6 - transitivePeerDependencies: - - supports-color - optional: true - - '@ai-sdk/google@3.0.30(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/groq@3.0.24(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/mistral@3.0.20(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - - '@ai-sdk/openai-compatible@2.0.0(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) - zod: 4.3.6 - - '@ai-sdk/openai-compatible@2.0.30(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - '@ai-sdk/openai@3.0.30(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/perplexity@3.0.19(zod@4.3.6)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - '@ai-sdk/provider-utils@4.0.0(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.0 @@ -6986,14 +6618,6 @@ snapshots: transitivePeerDependencies: - zod - '@ai-sdk/xai@3.0.57(zod@4.3.6)': - dependencies: - '@ai-sdk/openai-compatible': 2.0.30(zod@4.3.6) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.15(zod@4.3.6) - zod: 4.3.6 - optional: true - '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -7014,26 +6638,6 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.901.0 - tslib: 2.8.1 - optional: true - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.901.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - optional: true - - '@aws-sdk/types@3.901.0': - dependencies: - '@smithy/types': 4.6.0 - tslib: 2.8.1 - optional: true - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -8300,12 +7904,6 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openrouter/ai-sdk-provider@2.2.3(ai@6.0.1(zod@4.3.6))(zod@4.3.6)': - dependencies: - ai: 6.0.1(zod@4.3.6) - zod: 4.3.6 - optional: true - '@openrouter/sdk@0.3.15': dependencies: zod: 4.3.6 @@ -8638,58 +8236,6 @@ snapshots: transitivePeerDependencies: - debug - '@smithy/eventstream-codec@4.2.0': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.6.0 - '@smithy/util-hex-encoding': 4.2.0 - tslib: 2.8.1 - optional: true - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@smithy/types@4.6.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - optional: true - - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - tslib: 2.8.1 - optional: true - - '@smithy/util-hex-encoding@4.2.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.1 - optional: true - - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - optional: true - '@speed-highlight/core@1.2.7': {} '@standard-schema/spec@1.1.0': {} @@ -8945,8 +8491,6 @@ snapshots: dependencies: '@types/node': 25.3.0 - '@vercel/oidc@3.0.5': {} - '@vercel/oidc@3.1.0': {} '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sugarss@5.0.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.7.1))': @@ -9066,14 +8610,6 @@ snapshots: - '@cloudflare/workers-types' - supports-color - ai@6.0.1(zod@4.3.6): - dependencies: - '@ai-sdk/gateway': 3.0.0(zod@4.3.6) - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.3.6) - '@opentelemetry/api': 1.9.0 - zod: 4.3.6 - ai@6.0.94(zod@4.3.6): dependencies: '@ai-sdk/gateway': 3.0.52(zod@4.3.6) @@ -9151,9 +8687,6 @@ snapshots: asynckit@0.4.0: {} - aws4fetch@1.0.20: - optional: true - axios@1.13.2: dependencies: follow-redirects: 1.15.9 From 420734829e9c48544270194ab119b49f164436d8 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sun, 22 Feb 2026 10:23:23 +0000 Subject: [PATCH 3/3] Remove CODEOWNERS file Delete the CODEOWNERS file which previously assigned /packages/ai-gateway-provider/* to @g4brym, removing that ownership entry and any associated automatic reviews/notifications. --- CODEOWNERS | 1 - 1 file changed, 1 deletion(-) delete mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 687524a9c..000000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -/packages/ai-gateway-provider/* @g4brym