Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Versions use [Se

## [Unreleased]

### Added
- **First-class `cfGateway` config (#97)** — `OpenAIConfig`, `AnthropicConfig`, `CerebrasConfig`, `GroqConfig`, and `NvidiaConfig` accept `cfGateway?: { accountId, gatewayId }`. When set (and no explicit `baseUrl` override is present), the provider derives its base URL as `https://gateway.ai.cloudflare.com/v1/{accountId}/{gatewayId}/{suffix}` per provider (`openai/v1`, `anthropic`, `cerebras/v1`, `groq/openai/v1`, `nvidia-nim/v1`) and injects `cf-aig-cache-ttl`, `cf-aig-cache-key`, and `cf-aig-skip-cache` headers from `LLMRequest.gatewayMetadata` (each header only when its field is defined). An explicit `baseUrl` always wins and disables gateway-active header injection. Empty `accountId`/`gatewayId` throws `CfGatewayInvalidConfigError` (`CF_GATEWAY_INVALID_CONFIG`) synchronously in the constructor. New `GatewayMetadata.skipCache?: boolean` field and exported `CfGatewayConfig` type.

## [1.18.0] — 2026-06-19

### Added
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,48 @@ const llm = LLMProviders.fromEnv(env, {
{ apiKey: 'nvapi-...' }
```

## Cloudflare AI Gateway Passthrough

Each HTTP provider (OpenAI, Anthropic, Cerebras, Groq, NVIDIA) accepts a `cfGateway`
option that routes requests through a [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/)
without you hand-building the gateway URL:

```typescript
const provider = new OpenAIProvider({
apiKey: 'sk-...',
cfGateway: { accountId: 'YOUR_ACCOUNT_ID', gatewayId: 'my-gateway' },
});
// baseUrl is derived as:
// https://gateway.ai.cloudflare.com/v1/YOUR_ACCOUNT_ID/my-gateway/openai/v1
```

Per-provider gateway suffixes: `openai/v1`, `anthropic`, `cerebras/v1`,
`groq/openai/v1`, `nvidia-nim/v1`.

When `cfGateway` is active, the provider injects `cf-aig-*` headers from
`request.gatewayMetadata` on each call (each header only when its field is set):

| `gatewayMetadata` field | Header | Wire value |
| ----------------------- | ------------------ | ----------------- |
| `cacheTtl` (number) | `cf-aig-cache-ttl` | `"300"` |
| `cacheKey` (string) | `cf-aig-cache-key` | passthrough |
| `skipCache` (boolean) | `cf-aig-skip-cache`| `"true"`/`"false"`|

An explicit `baseUrl` always wins: if you set both `baseUrl` and `cfGateway`, the
`baseUrl` is used verbatim and no `cf-aig-*` headers are injected. Empty
`accountId` or `gatewayId` throws `CfGatewayInvalidConfigError`
(`CF_GATEWAY_INVALID_CONFIG`) synchronously in the constructor.

### Composable with semantic routing

`cfGateway` is network-layer infrastructure and composes with this package's
application-layer concerns rather than replacing them. Use **llm-providers** for
semantic routing (use-case → model selection) and per-provider circuit breakers;
use **Cloudflare AI Gateway** for network-layer load balancing, response caching,
and upstream 429 interception. The factory still picks the provider/model and
trips its breaker on failures; the gateway sits underneath each provider's
outbound HTTP and handles caching and rate-limit absorption transparently.

## Vision Inputs

Vision requests use `request.images`. The factory routes image inputs only to providers that advertise `supportsVision === true`; providers that cannot see images reject the request instead of silently dropping image content.
Expand Down
209 changes: 209 additions & 0 deletions src/__tests__/cf-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* Cloudflare AI Gateway passthrough (cfGateway) tests
* Covers URL derivation, constructor validation, header injection/omission,
* and the baseUrl override regression across all five HTTP providers.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { OpenAIProvider } from '../providers/openai';
import { AnthropicProvider } from '../providers/anthropic';
import { CerebrasProvider } from '../providers/cerebras';
import { GroqProvider } from '../providers/groq';
import { NvidiaProvider } from '../providers/nvidia';
import { CfGatewayInvalidConfigError } from '../errors';
import { defaultCircuitBreakerManager } from '../utils/circuit-breaker';
import type { LLMRequest } from '../types';

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

const ACCOUNT = 'acct-123';
const GATEWAY = 'gw-abc';

const chatCompletionBody = (model: string) => ({
ok: true,
json: async () => ({
id: 'chatcmpl-123',
object: 'chat.completion',
created: 1700000000,
model,
choices: [{ index: 0, message: { role: 'assistant', content: 'Hi!' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}),
headers: new Headers({ 'content-type': 'application/json' }),
});

const anthropicBody = () => ({
ok: true,
json: async () => ({
id: 'msg_123',
type: 'message',
role: 'assistant',
model: 'claude-3-5-haiku-20241022',
content: [{ type: 'text', text: 'Hi!' }],
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
}),
headers: new Headers({ 'content-type': 'application/json' }),
});

const baseRequest: LLMRequest = {
messages: [{ role: 'user', content: 'Hello' }],
maxTokens: 100,
};

beforeEach(() => {
vi.clearAllMocks();
defaultCircuitBreakerManager.resetAll();
});

describe('cfGateway URL derivation', () => {
const expectedUrl = (suffix: string) =>
`https://gateway.ai.cloudflare.com/v1/${ACCOUNT}/${GATEWAY}/${suffix}`;

it('derives the OpenAI gateway URL', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'gpt-4o-mini' });
expect(mockFetch.mock.calls[0][0]).toBe(`${expectedUrl('openai/v1')}/chat/completions`);
});

it('derives the Anthropic gateway URL (no version segment)', async () => {
mockFetch.mockResolvedValueOnce(anthropicBody());
const provider = new AnthropicProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'claude-3-5-haiku-20241022' });
expect(mockFetch.mock.calls[0][0]).toBe(`${expectedUrl('anthropic')}/v1/messages`);
});

it('derives the Cerebras gateway URL', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('llama-3.1-8b'));
const provider = new CerebrasProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'llama-3.1-8b' });
expect(mockFetch.mock.calls[0][0]).toBe(`${expectedUrl('cerebras/v1')}/chat/completions`);
});

it('derives the Groq gateway URL', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('llama-3.3-70b-versatile'));
const provider = new GroqProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'llama-3.3-70b-versatile' });
expect(mockFetch.mock.calls[0][0]).toBe(`${expectedUrl('groq/openai/v1')}/chat/completions`);
});

it('derives the NVIDIA gateway URL', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('meta/llama-3.3-70b-instruct'));
const provider = new NvidiaProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'meta/llama-3.3-70b-instruct' });
expect(mockFetch.mock.calls[0][0]).toBe(`${expectedUrl('nvidia-nim/v1')}/chat/completions`);
});
});

describe('cfGateway constructor validation', () => {
it('throws synchronously on empty accountId', () => {
expect(() => new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: '', gatewayId: GATEWAY } }))
.toThrow(CfGatewayInvalidConfigError);
try {
new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: '', gatewayId: GATEWAY } });
} catch (err) {
expect((err as CfGatewayInvalidConfigError).code).toBe('CF_GATEWAY_INVALID_CONFIG');
expect((err as CfGatewayInvalidConfigError).field).toBe('accountId');
expect((err as CfGatewayInvalidConfigError).provider).toBe('openai');
expect((err as Error).message).toContain('openai');
}
});

it('throws synchronously on empty gatewayId', () => {
try {
new GroqProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: '' } });
throw new Error('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(CfGatewayInvalidConfigError);
expect((err as CfGatewayInvalidConfigError).field).toBe('gatewayId');
expect((err as CfGatewayInvalidConfigError).provider).toBe('groq');
}
});
});

describe('cfGateway baseUrl override', () => {
it('explicit baseUrl wins and disables header injection', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({
apiKey: 'k',
baseUrl: 'https://custom.example.com/v1',
cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY },
});
await provider.generateResponse({
...baseRequest,
model: 'gpt-4o-mini',
gatewayMetadata: { cacheTtl: 300, cacheKey: 'k1', skipCache: false },
});
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://custom.example.com/v1/chat/completions');
expect(options.headers['cf-aig-cache-ttl']).toBeUndefined();
expect(options.headers['cf-aig-cache-key']).toBeUndefined();
expect(options.headers['cf-aig-skip-cache']).toBeUndefined();
});
});

describe('cfGateway header injection', () => {
it('injects all cf-aig-* headers when gatewayMetadata fields are set', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({
...baseRequest,
model: 'gpt-4o-mini',
gatewayMetadata: { cacheTtl: 300, cacheKey: 'my-key', skipCache: true },
});
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers['cf-aig-cache-ttl']).toBe('300');
expect(headers['cf-aig-cache-key']).toBe('my-key');
expect(headers['cf-aig-skip-cache']).toBe('true');
});

it('serializes skipCache=false as the string "false"', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({
...baseRequest,
model: 'gpt-4o-mini',
gatewayMetadata: { skipCache: false },
});
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers['cf-aig-skip-cache']).toBe('false');
});

it('omits headers whose gatewayMetadata fields are undefined', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({
...baseRequest,
model: 'gpt-4o-mini',
gatewayMetadata: { cacheTtl: 60 },
});
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers['cf-aig-cache-ttl']).toBe('60');
expect(headers['cf-aig-cache-key']).toBeUndefined();
expect(headers['cf-aig-skip-cache']).toBeUndefined();
});

it('omits all cf-aig-* headers when gatewayMetadata is absent', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k', cfGateway: { accountId: ACCOUNT, gatewayId: GATEWAY } });
await provider.generateResponse({ ...baseRequest, model: 'gpt-4o-mini' });
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers['cf-aig-cache-ttl']).toBeUndefined();
expect(headers['cf-aig-cache-key']).toBeUndefined();
expect(headers['cf-aig-skip-cache']).toBeUndefined();
});

it('does not inject cf-aig-* headers when gateway is inactive (default base URL)', async () => {
mockFetch.mockResolvedValueOnce(chatCompletionBody('gpt-4o-mini'));
const provider = new OpenAIProvider({ apiKey: 'k' });
await provider.generateResponse({
...baseRequest,
model: 'gpt-4o-mini',
gatewayMetadata: { cacheTtl: 300, cacheKey: 'k1', skipCache: true },
});
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers['cf-aig-skip-cache']).toBeUndefined();
});
});
99 changes: 57 additions & 42 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,22 @@ export class ConfigurationError extends LLMProviderError {
}
}

export class CircuitBreakerOpenError extends LLMProviderError {
export class CfGatewayInvalidConfigError extends LLMProviderError {
/** The specific cfGateway field that was empty or missing. */
field: 'accountId' | 'gatewayId';

constructor(provider: string, field: 'accountId' | 'gatewayId') {
super(
`cfGateway.${field} must be a non-empty string for the ${provider} provider`,
'CF_GATEWAY_INVALID_CONFIG',
provider,
false
);
this.field = field;
}
}

export class CircuitBreakerOpenError extends LLMProviderError {
retryAfterSec: number;
consecutiveFailures: number;

Expand All @@ -111,47 +126,47 @@ export class CircuitBreakerOpenError extends LLMProviderError {
);
this.retryAfterSec = retryAfterSec;
this.consecutiveFailures = consecutiveFailures;
}
}

export class ToolLoopLimitError extends LLMProviderError {
constructor(provider: string, message: string = 'Tool loop limit exceeded') {
super(message, 'TOOL_LOOP_LIMIT', provider, false, 400);
}
}

export class ToolLoopAbortedError extends LLMProviderError {
constructor(provider: string, message: string = 'Tool loop aborted') {
super(message, 'TOOL_LOOP_ABORTED', provider, false, 400);
}
}

/**
* Thrown when a provider's response envelope fails runtime schema validation.
*
* Indicates the upstream API silently changed shape - a field was renamed,
* removed, or had its type changed. Non-retryable: retrying hits the same
* broken shape. The factory treats this as fallback-eligible so traffic
* routes to a healthy provider while the drift is investigated.
*/
export class SchemaDriftError extends LLMProviderError {
path: string;
expected: string;
actual: string;

constructor(provider: string, path: string, expected: string, actual: string) {
super(
`Response schema drift at ${path}: expected ${expected}, got ${actual}`,
'SCHEMA_DRIFT',
provider,
false,
422
);
this.path = path;
this.expected = expected;
this.actual = actual;
}
}
}
}
export class ToolLoopLimitError extends LLMProviderError {
constructor(provider: string, message: string = 'Tool loop limit exceeded') {
super(message, 'TOOL_LOOP_LIMIT', provider, false, 400);
}
}
export class ToolLoopAbortedError extends LLMProviderError {
constructor(provider: string, message: string = 'Tool loop aborted') {
super(message, 'TOOL_LOOP_ABORTED', provider, false, 400);
}
}
/**
* Thrown when a provider's response envelope fails runtime schema validation.
*
* Indicates the upstream API silently changed shape - a field was renamed,
* removed, or had its type changed. Non-retryable: retrying hits the same
* broken shape. The factory treats this as fallback-eligible so traffic
* routes to a healthy provider while the drift is investigated.
*/
export class SchemaDriftError extends LLMProviderError {
path: string;
expected: string;
actual: string;
constructor(provider: string, path: string, expected: string, actual: string) {
super(
`Response schema drift at ${path}: expected ${expected}, got ${actual}`,
'SCHEMA_DRIFT',
provider,
false,
422
);
this.path = path;
this.expected = expected;
this.actual = actual;
}
}

/**
* Error factory for creating provider-specific errors from HTTP responses
Expand Down
Loading