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/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 687524a9c..000000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -/packages/ai-gateway-provider/* @g4brym 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/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); + }); + }); +}); 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