cloudflare / cloudflare/ai

ai-gateway-provider: BYOK fails for OpenRouter due to case-sensitive header key mismatch

Open
#355 1 comment 1 reaction 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.2k
Forks
345
Avg merge
13h 31m
Merged PRs (30d)
1

Description

> ✨ Disclaimer: Debugged and authored with Claude.

## Summary

When using `createOpenRouter()` from `ai-gateway-provider` without passing an API key (expecting AI Gateway BYOK to handle authentication), requests fail with a 401 error from OpenRouter: `"No cookie auth credentials found"`.

**Root cause:** The header removal logic uses lowercase `"authorization"` but the OpenRouter SDK sends `"Authorization"` (capital A). Since JavaScript object keys are case-sensitive, the deletion silently fails.

## Environment

- `ai-gateway-provider`: 3.0.2
- `@openrouter/ai-sdk-provider`: 1.5.4
- `ai` (Vercel AI SDK): 6.0.24

Steps to Reproduce

1. Configure AI Gateway with OpenRouter BYOK credentials in the Cloudflare dashboard
2. Use the following code:

```typescript
import { createAiGateway } from "ai-gateway-provider";
import { createOpenRouter } from "ai-gateway-provider/providers/openrouter";
import { generateText } from "ai";

const aigateway = createAiGateway({
accountId: "your-account-id",
gateway: "default",
apiKey: "your-ai-gateway-token",
});

// No API key passed - expecting BYOK to handle it
const openrouter = createOpenRouter();
const model = openrouter.chat("perplexity/sonar-pro");

const { text } = await generateText({
model: aigateway(model),
prompt: "Hello",
});
```

3. Observe the error:
```
APICallError [AI_APICallError]: No cookie auth credentials found
statusCode: 401,
responseBody: '{"error":{"message":"No cookie auth credentials found","code":401}}'
```

Root Cause Analysis

### Step 1: CF_TEMP_TOKEN placeholder is set correctly

The `authWrapper` in `src/auth.ts` correctly sets `CF_TEMP_TOKEN` as a placeholder when no API key is provided.

### Step 2: OpenRouter SDK uses capital "Authorization"

The `@openrouter/ai-sdk-provider` sets the header with a capital A:

```typescript
// From @openrouter/ai-sdk-provider
headers: () => ({
Authorization: `Bearer ${...}` // Capital A!
})
```

### Step 3: ai-gateway-provider looks for lowercase "authorization"

In `src/index.ts`, the header removal logic uses lowercase:

```typescript
const authHeader = providerConfig.headerKey ?? "authorization"; // lowercase!
const authValue = "get" in req.request.headers
? req.request.headers.get(authHeader)
: req.request.headers[authHeader]; // Looking for "authorization"

if (authValue?.indexOf(CF_TEMP_TOKEN) !== -1) {
// ...
delete req.request.headers[authHeader]; // Deleting "authorization"
}
```

### Step 4: Case mismatch causes silent failure

Since JavaScript object keys are case-sensitive:

```javascript
const headers = { "Authorization": "Bearer CF_TEMP_TOKEN" };

headers["authorization"] // undefined - key doesn't exist!
delete headers["authorization"]; // No-op - deletes nothing
headers["Authorization"] // Still "Bearer CF_TEMP_TOKEN"
```

### Step 5: The condition passes incorrectly

```javascript
const authValue = undefined; // Because "authorization" doesn't exist
authValue?.indexOf("CF_TEMP_TOKEN") // undefined
undefined !== -1 // true! Condition passes but deletion targets wrong key
```

Proof of Concept

```javascript
// Simulating the bug
const headers = { "Authorization": "Bearer CF_TEMP_TOKEN", "Content-Type": "application/json" };
const authHeader = "authorization"; // lowercase

console.log("Before:", headers);
// { Authorization: 'Bearer CF_TEMP_TOKEN', 'Content-Type': 'application/json' }

const authValue = headers[authHeader]; // undefined

// Condition check - passes incorrectly!
console.log("undefined !== -1:", undefined !== -1); // true

delete headers[authHeader]; // Deletes nothing - wrong key!

console.log("After:", headers);
// { Authorization: 'Bearer CF_TEMP_TOKEN', 'Content-Type': 'application/json' }
// Header is STILL there!
```

Verification via curl

**Works (empty headers):**
```bash
curl -X POST "https://gateway.ai.cloudflare.com/v1/{account_id}/default" \
-H "Content-Type: application/json" \
-H "cf-aig-authorization: Bearer {token}" \
-d '[{
"provider": "openrouter",
"endpoint": "v1/chat/completions",
"headers": {},
"query": {"model": "perplexity/sonar-pro", "messages": [{"role": "user", "content": "Hi"}]}
}]'
# Returns successful response
```

**Fails (with Authorization header):**
```bash
curl -X POST "https://gateway.ai.cloudflare.com/v1/{account_id}/default" \
-H "Content-Type: application/json" \
-H "cf-aig-authorization: Bearer {token}" \
-d '[{
"provider": "openrouter",
"endpoint": "v1/chat/completions",
"headers": {"Authorization": "Bearer CF_TEMP_TOKEN"},
"query": {"model": "perplexity/sonar-pro", "messages": [{"role": "user", "content": "Hi"}]}
}]'
# Returns: {"error":{"message":"No cookie auth credentials found","code":401}}
```

## Workaround

Use the `unified` provider with the compat endpoint:

```typescript
import { createAiGateway } from "ai-gateway-provider";
import { createUnified } from "ai-gateway-provider/providers/unified";
import { generateText } from "ai";

const aigateway = createAiGateway({
accountId: "your-account-id",
gateway: "default",
apiKey: "your-ai-gateway-token",
});

const unified = createUnified();
const model = unified.chatModel("openrouter/perplexity/sonar-pro");

const { text } = await generateText({
model: aigateway(model),
prompt: "Hello",
});
// Works correctly!
```

## Suggested Fix

Use case-insensitive header key matching:

```typescript
// Find the actual key in the headers object (case-insensitive)
const findHeaderKey = (headers: Record, targetKey: string): string | undefined => {
const lowerTarget = targetKey.toLowerCase();
return Object.keys(headers).find(key => key.toLowerCase() === lowerTarget);
};

const authHeaderKey = providerConfig.headerKey ?? "authorization";
const actualKey = findHeaderKey(req.request.headers, authHeaderKey);

if (actualKey) {
const authValue = req.request.headers[actualKey];
if (authValue?.includes(CF_TEMP_TOKEN)) {
delete req.request.headers[actualKey];
}
}
```

## Impact

This bug likely affects **all providers** using BYOK, since most SDKs use `Authorization` (capital A) which is the standard HTTP convention. HTTP headers are case-insensitive per RFC 7230, so the code should handle both cases.

Contributor guide

Open the contributing guide

Research direction

Start in src/index.ts at the header removal logic described in the issue, then reproduce the BYOK request with an Authorization header from the OpenRouter provider. Verify that temporary authentication is removed regardless of header-key casing and that the resulting OpenRouter request no longer returns the 401 error.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, authentication
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.