From ee78bcfa392d4069ad25fe2c0fbf9114f8b0a402 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:53:05 -0300 Subject: [PATCH 01/10] fix(ai-gateway): strip cf-* headers and skip body on GET in OpenAI proxy cf-connecting-ip, cf-ipcountry, cf-ray, cf-visitor were forwarded to upstream in direct mode. Also prevents sending a request body on GET /v1/models requests. --- workers/ai-gateway/src/providers/openai.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/workers/ai-gateway/src/providers/openai.ts b/workers/ai-gateway/src/providers/openai.ts index 08e3d70..d767fe8 100644 --- a/workers/ai-gateway/src/providers/openai.ts +++ b/workers/ai-gateway/src/providers/openai.ts @@ -17,6 +17,11 @@ export async function proxyOpenAI( // Replace auth token with OpenAI API key headers.set('Authorization', `Bearer ${apiKey}`) + // Strip Cloudflare-injected metadata headers that shouldn't reach upstream providers + for (const key of [...headers.keys()]) { + if (key.startsWith('cf-')) headers.delete(key) + } + // Set provider-config headers (e.g. cf-aig-authorization for gateway mode, // X-Proxy-Auth for egress proxy) if (config.headers) { @@ -26,16 +31,13 @@ export async function proxyOpenAI( } // When egress proxy is configured, wrap the target URL in the proxy URL - // and strip CF-injected headers that shouldn't reach the upstream + // and strip additional proxy-revealing headers const url = config.egressProxyUrl ? `${config.egressProxyUrl}?_proxyUpstreamURL_=${encodeURIComponent(targetUrl)}` : targetUrl if (config.egressProxyUrl) { - for (const h of [ - 'host', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', - 'x-real-ip', 'x-forwarded-proto', 'x-forwarded-for', - ]) { + for (const h of ['host', 'x-real-ip', 'x-forwarded-proto', 'x-forwarded-for']) { headers.delete(h) } } @@ -48,6 +50,6 @@ export async function proxyOpenAI( return fetch(url, { method: request.method, headers, - body, + body: request.method !== 'GET' ? body : undefined, }) } From ccf7ccedf494c7a8611bf9805e817e5ff6b8aa67 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:07:58 -0300 Subject: [PATCH 02/10] feat(ai-gateway): add generic provider proxy for 11 OpenAI-compatible providers Generalize the AI Gateway Worker from 3 hardcoded providers to support 11 additional OpenAI-compatible providers (DeepSeek, Groq, Mistral, Together, xAI, OpenRouter, Perplexity, Cohere, Fireworks, MiniMax, Moonshot) via /proxy/{provider}/v1/... routes. - routing: GENERIC_PROVIDERS whitelist, GENERIC_ENDPOINTS whitelist, matchGenericRoute() parser - config: PROVIDER_DEFAULTS with verified base URLs, getGenericProviderConfig() lookup - keys: getGenericApiKey() reads from creds.providers[provider].apiKey - providers/generic: OpenAI-compatible passthrough proxy - index: generic route handling with auth, key lookup, llemtry telemetry - admin: mergeCredentials/maskCredentials extended for providers field - config-ui: collapsible Additional Providers section with 11 API key fields, 3-segment data-field path support in buildUpdate() - llemtry: widen ReportOptions.provider to string for generic names - types: LegacyProvider alias, GenericRouteMatch, providers on UserCredentials --- workers/ai-gateway/src/admin.ts | 35 +++++++++ workers/ai-gateway/src/config-ui.ts | 84 ++++++++++++++++++++- workers/ai-gateway/src/config.ts | 24 ++++++ workers/ai-gateway/src/index.ts | 79 +++++++++++++++---- workers/ai-gateway/src/keys.ts | 29 ++++++- workers/ai-gateway/src/llemtry.ts | 4 +- workers/ai-gateway/src/providers/generic.ts | 32 ++++++++ workers/ai-gateway/src/routing.ts | 59 +++++++++++++++ workers/ai-gateway/src/types.ts | 6 ++ 9 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 workers/ai-gateway/src/providers/generic.ts diff --git a/workers/ai-gateway/src/admin.ts b/workers/ai-gateway/src/admin.ts index 53eab25..27f5249 100644 --- a/workers/ai-gateway/src/admin.ts +++ b/workers/ai-gateway/src/admin.ts @@ -380,6 +380,14 @@ function maskCredentials(creds: UserCredentials): Record { if (Object.keys(o).length > 0) result.openai = o } + if (creds.providers) { + const p: Record = {} + for (const [name, entry] of Object.entries(creds.providers)) { + if (entry.apiKey) p[name] = { apiKey: maskString(entry.apiKey) } + } + if (Object.keys(p).length > 0) result.providers = p + } + return result } @@ -437,6 +445,33 @@ function mergeCredentials( } } + if ('providers' in update) { + if (update.providers === null) { + delete result.providers + } else { + const u = update.providers as Record + if (!result.providers) result.providers = {} + for (const [name, value] of Object.entries(u)) { + if (value === null) { + delete result.providers[name] + } else { + const entry = value as Record + if ('apiKey' in entry) { + if (entry.apiKey === null) { + delete result.providers[name] + } else { + result.providers[name] = { apiKey: entry.apiKey as string } + } + } + } + } + // Clean up empty providers section + if (Object.keys(result.providers).length === 0) { + delete result.providers + } + } + } + return result } diff --git a/workers/ai-gateway/src/config-ui.ts b/workers/ai-gateway/src/config-ui.ts index 5010104..72c1960 100644 --- a/workers/ai-gateway/src/config-ui.ts +++ b/workers/ai-gateway/src/config-ui.ts @@ -124,6 +124,67 @@ const CONFIG_HTML = /* html */ ` +
+ Additional Providers +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
@@ -281,7 +342,26 @@ const CONFIG_HTML = /* html */ ` const state = fieldState[path] || {}; const newVal = input.value.trim(); - const [provider, key] = path.split('.'); + const segments = path.split('.'); + + if (segments.length === 3) { + // 3-segment path: providers.{name}.apiKey + const [root, name, key] = segments; + if (!update[root]) update[root] = {}; + if (!update[root][name]) update[root][name] = {}; + if (state.cleared) { + update[root][name][key] = null; + } else if (newVal) { + update[root][name][key] = newVal; + } + // Clean up empty nested objects + if (Object.keys(update[root][name]).length === 0) delete update[root][name]; + if (Object.keys(update[root]).length === 0) delete update[root]; + return; + } + + // 2-segment path: {provider}.{key} + const [provider, key] = segments; if (!update[provider]) update[provider] = {}; if (state.cleared) { @@ -305,7 +385,7 @@ const CONFIG_HTML = /* html */ ` // Remove provider keys with no actual changes for (const provider of Object.keys(update)) { - if (Object.keys(update[provider]).length === 0) { + if (typeof update[provider] === 'object' && Object.keys(update[provider]).length === 0) { delete update[provider]; } } diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 84b9772..86afbed 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -59,3 +59,27 @@ export function getProviderConfig(provider: string): ProviderConfig { return { baseUrl: '' } } } + +// --- Generic provider defaults --- + +/** Verified base URLs for generic OpenAI-compatible providers. + * Base URLs do NOT include /v1 — the directPath from route matching provides it. + * e.g. Groq: baseUrl="https://api.groq.com/openai" + "/v1/chat/completions" */ +const PROVIDER_DEFAULTS: Record = { + cohere: { baseUrl: 'https://api.cohere.ai/compatibility' }, + deepseek: { baseUrl: 'https://api.deepseek.com' }, + fireworks: { baseUrl: 'https://api.fireworks.ai/inference' }, + groq: { baseUrl: 'https://api.groq.com/openai' }, + minimax: { baseUrl: 'https://api.minimax.io' }, + mistral: { baseUrl: 'https://api.mistral.ai' }, + moonshot: { baseUrl: 'https://api.moonshot.ai' }, + openrouter: { baseUrl: 'https://openrouter.ai/api' }, + perplexity: { baseUrl: 'https://api.perplexity.ai' }, + together: { baseUrl: 'https://api.together.xyz' }, + xai: { baseUrl: 'https://api.x.ai' }, +} + +/** Look up the base URL config for a generic provider. Returns null for unknown providers. */ +export function getGenericProviderConfig(provider: string): { baseUrl: string } | null { + return PROVIDER_DEFAULTS[provider] ?? null +} diff --git a/workers/ai-gateway/src/index.ts b/workers/ai-gateway/src/index.ts index d69a488..9582115 100644 --- a/workers/ai-gateway/src/index.ts +++ b/workers/ai-gateway/src/index.ts @@ -1,11 +1,11 @@ import { authenticateRequest, validateAdminToken } from './auth' -import { getProviderConfig } from './config' +import { getProviderConfig, getGenericProviderConfig } from './config' import { handlePreflight, addCorsHeaders } from './cors' import { jsonError } from './errors' import { isLlemtryEnabled, isLlmRoute, reportGeneration } from './llemtry' import { createLog, logInboundRequest } from './log' -import { matchProviderRoute } from './routing' -import { getProviderApiKey } from './keys' +import { matchProviderRoute, matchGenericRoute } from './routing' +import { getProviderApiKey, getGenericApiKey } from './keys' import { handleAdminRequest, handleTokenRotation, @@ -16,6 +16,7 @@ import { import { serveConfigPage } from './config-ui' import { proxyOpenAI } from './providers/openai' import { proxyAnthropic } from './providers/anthropic' +import { proxyGeneric } from './providers/generic' export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -93,37 +94,87 @@ export default { return addCorsHeaders(jsonError('Invalid or missing auth credentials', 401)) } - // Route to provider + // Route to provider — try legacy routes first, then generic /proxy/{provider}/... const route = matchProviderRoute(request.method, pathname) - if (!route) { + const genericRoute = route ? null : matchGenericRoute(request.method, pathname) + + if (!route && !genericRoute) { console.error(`No route match: ${request.method} ${pathname}`) return addCorsHeaders(jsonError(`Route not implemented in AI Gateway: ${pathname}`, 404)) } - const apiKey = await getProviderApiKey(route.provider, userId, env.AUTH_KV, log) + // --- Generic provider route --- + if (genericRoute) { + const apiKey = await getGenericApiKey(genericRoute.provider, userId, env.AUTH_KV, log) + if (!apiKey) { + return addCorsHeaders(jsonError(`No API key configured for ${genericRoute.provider}`, 401)) + } + + const providerConfig = getGenericProviderConfig(genericRoute.provider) + if (!providerConfig) { + return addCorsHeaders(jsonError(`Unknown provider: ${genericRoute.provider}`, 404)) + } + + const llemtryActive = isLlemtryEnabled(env, log) && isLlmRoute(genericRoute.directPath) + const startTime = llemtryActive ? new Date() : undefined + const requestBody = llemtryActive ? await request.text() : undefined + + let response = await proxyGeneric( + apiKey, + request, + providerConfig.baseUrl, + genericRoute.directPath, + log, + genericRoute.provider, + requestBody + ) + + if (llemtryActive && response.ok && response.body) { + const statusCode = response.status + const responseHeaders = new Headers(response.headers) + const [clientStream, reportStream] = response.body.tee() + response = new Response(clientStream, response) + + ctx.waitUntil( + reportGeneration(env, log, { + provider: genericRoute.provider, + requestBody: requestBody!, + responseStream: reportStream, + responseHeaders, + statusCode, + startTime: startTime!, + }) + ) + } + + return addCorsHeaders(response) + } + + // --- Legacy provider route --- + const apiKey = await getProviderApiKey(route!.provider, userId, env.AUTH_KV, log) if (!apiKey) { - console.error(`No API key configured for ${route.provider}: ${request.method} ${route}`) - return addCorsHeaders(jsonError(`No API key configured for ${route.provider}`, 500)) + console.error(`No API key configured for ${route!.provider}: ${request.method} ${route}`) + return addCorsHeaders(jsonError(`No API key configured for ${route!.provider}`, 500)) } if (env.LOG_LEVEL === 'debug') { - logInboundRequest(log, request, route, apiKey) + logInboundRequest(log, request, route!, apiKey) } - const providerConfig = getProviderConfig(route.provider) + const providerConfig = getProviderConfig(route!.provider) // CF AI Gateway uses a different path format (strips /v1/, adds provider prefix) const isGateway = providerConfig.baseUrl.includes('gateway.ai.cloudflare.com') - const upstreamPath = isGateway ? route.gatewayPath : route.directPath + const upstreamPath = isGateway ? route!.gatewayPath : route!.directPath // When llemtry is enabled for an LLM route, pre-read the request body // so it can be shared with both the proxy function and llemtry reporting - const llemtryActive = isLlemtryEnabled(env, log) && isLlmRoute(route.directPath) + const llemtryActive = isLlemtryEnabled(env, log) && isLlmRoute(route!.directPath) const startTime = llemtryActive ? new Date() : undefined const requestBody = llemtryActive ? await request.text() : undefined let response: Response - if (route.provider === 'anthropic') { + if (route!.provider === 'anthropic') { response = await proxyAnthropic( apiKey, request, @@ -163,7 +214,7 @@ export default { ctx.waitUntil( reportGeneration(env, log, { - provider: route.provider, + provider: route!.provider, requestBody: requestBody!, responseStream: reportStream, responseHeaders, diff --git a/workers/ai-gateway/src/keys.ts b/workers/ai-gateway/src/keys.ts index 989f467..b21ac48 100644 --- a/workers/ai-gateway/src/keys.ts +++ b/workers/ai-gateway/src/keys.ts @@ -1,5 +1,4 @@ -import type { Log, Provider } from './types' -import type { UserCredentials } from './types' +import type { Log, Provider, UserCredentials } from './types' import { refreshOpenAIToken } from './openai-oauth' const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min before expiry @@ -84,3 +83,29 @@ async function resolveOpenAIKey( if (!key) log.warn('[keys] no OpenAI credentials found for user') return key } + +/** Resolve the API key for a generic provider from the user's KV credentials. */ +export async function getGenericApiKey( + provider: string, + userId: string, + kv: KVNamespace, + log: Log +): Promise { + const raw = await kv.get(`creds:${userId}`) + if (!raw) { + log.warn(`[keys] no credentials in KV for user ${userId}`) + return undefined + } + + let creds: UserCredentials + try { + creds = JSON.parse(raw) + } catch { + log.error(`[keys] failed to parse credentials for user ${userId}`) + return undefined + } + + const key = creds.providers?.[provider]?.apiKey + if (!key) log.warn(`[keys] no ${provider} credentials found for user ${userId}`) + return key +} diff --git a/workers/ai-gateway/src/llemtry.ts b/workers/ai-gateway/src/llemtry.ts index 7a684da..b876957 100644 --- a/workers/ai-gateway/src/llemtry.ts +++ b/workers/ai-gateway/src/llemtry.ts @@ -1,4 +1,4 @@ -import type { Log, Provider } from './types' +import type { Log } from './types' const MAX_OUTPUT_BYTES = 100 * 1024 // 100KB @@ -265,7 +265,7 @@ async function parseOpenAIStream(stream: ReadableStream): Promise responseHeaders: Headers diff --git a/workers/ai-gateway/src/providers/generic.ts b/workers/ai-gateway/src/providers/generic.ts new file mode 100644 index 0000000..11c5cde --- /dev/null +++ b/workers/ai-gateway/src/providers/generic.ts @@ -0,0 +1,32 @@ +import type { Log } from '../types' +import { sanitizeHeaders, truncateBody } from '../log' + +/** Proxy the request to a generic OpenAI-compatible provider. */ +export async function proxyGeneric( + apiKey: string, + request: Request, + baseUrl: string, + directPath: string, + log: Log, + provider: string, + preReadBody?: string +): Promise { + const targetUrl = `${baseUrl}/${directPath}` + + const headers = new Headers() + headers.set('content-type', request.headers.get('content-type') || 'application/json') + headers.set('authorization', `Bearer ${apiKey}`) + const accept = request.headers.get('accept') + if (accept) headers.set('accept', accept) + + const body = preReadBody ?? await request.text() + log.debug(`[${provider}] url=${targetUrl}`) + log.debug(`[${provider}] upstream headers`, sanitizeHeaders(headers)) + log.debug(`[${provider}] request body`, truncateBody(body)) + + return fetch(targetUrl, { + method: request.method, + headers, + body: request.method !== 'GET' ? body : undefined, + }) +} diff --git a/workers/ai-gateway/src/routing.ts b/workers/ai-gateway/src/routing.ts index 2b0e8a0..25ea788 100644 --- a/workers/ai-gateway/src/routing.ts +++ b/workers/ai-gateway/src/routing.ts @@ -8,6 +8,13 @@ export interface RouteMatch { directPath: string } +/** Route match for generic OpenAI-compatible providers via /proxy/{provider}/... */ +export interface GenericRouteMatch { + provider: string + /** Direct API path including v1/ prefix (e.g. "v1/chat/completions") */ + directPath: string +} + /** Method allowed per provider-prefixed route (pathname without leading slash). */ const ROUTES: Record = { // Anthropic @@ -64,3 +71,55 @@ export function matchProviderRoute(method: string, pathname: string): RouteMatch directPath: override?.directPath ?? toDirectPath(key), } } + +// --- Generic provider routing (OpenAI-compatible) --- + +/** Known generic providers. Requests to unknown providers are rejected. */ +export const GENERIC_PROVIDERS = new Set([ + 'cohere', + 'deepseek', + 'fireworks', + 'groq', + 'minimax', + 'mistral', + 'moonshot', + 'openrouter', + 'perplexity', + 'together', + 'xai', +]) + +/** Whitelisted endpoints for generic providers: directPath → allowed method. */ +const GENERIC_ENDPOINTS: Record = { + 'v1/chat/completions': 'POST', + 'v1/embeddings': 'POST', + 'v1/models': 'GET', +} + +/** + * Match a /proxy/{provider}/{rest} request to a generic provider route. + * Returns null if the provider is unknown or the endpoint is not whitelisted. + */ +export function matchGenericRoute(method: string, pathname: string): GenericRouteMatch | null { + // Strip leading slash, split into segments: ["proxy", provider, ...rest] + const path = pathname.startsWith('/') ? pathname.slice(1) : pathname + const firstSlash = path.indexOf('/') + if (firstSlash === -1) return null + + const prefix = path.slice(0, firstSlash) + if (prefix !== 'proxy') return null + + const rest = path.slice(firstSlash + 1) + const secondSlash = rest.indexOf('/') + if (secondSlash === -1) return null + + const provider = rest.slice(0, secondSlash) + const directPath = rest.slice(secondSlash + 1) + + if (!GENERIC_PROVIDERS.has(provider)) return null + + const allowed = GENERIC_ENDPOINTS[directPath] + if (!allowed || allowed !== method) return null + + return { provider, directPath } +} diff --git a/workers/ai-gateway/src/types.ts b/workers/ai-gateway/src/types.ts index 0d50b23..d2b2e86 100644 --- a/workers/ai-gateway/src/types.ts +++ b/workers/ai-gateway/src/types.ts @@ -1,5 +1,6 @@ export type { Log } from './log' export type { Provider, RouteMatch } from './routing' +export type { GenericRouteMatch } from './routing' export type { ProviderConfig } from './config' // Use `wrangler types --env-file .dev.vars.example` to generate Env var types @@ -10,6 +11,9 @@ export type { ProviderConfig } from './config' // --- KV schema types --- +/** The 3 original providers with dedicated route handling. */ +export type LegacyProvider = 'anthropic' | 'openai' | 'openai-codex' + export interface UserCredentials { anthropic?: { apiKey?: string // sk-ant-api-* (regular API key) @@ -23,6 +27,8 @@ export interface UserCredentials { expiresAt: number // epoch ms } } + /** Generic OpenAI-compatible providers (deepseek, groq, mistral, etc.) */ + providers?: Record } export interface UserEntry { From 9569b364778211956986bf9b1a538ecddef95b8d Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:15:53 -0300 Subject: [PATCH 03/10] docs: add generic providers proposal --- docs/PROPOSAL-GENERIC-PROVIDERS.md | 147 +++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/PROPOSAL-GENERIC-PROVIDERS.md diff --git a/docs/PROPOSAL-GENERIC-PROVIDERS.md b/docs/PROPOSAL-GENERIC-PROVIDERS.md new file mode 100644 index 0000000..43f7554 --- /dev/null +++ b/docs/PROPOSAL-GENERIC-PROVIDERS.md @@ -0,0 +1,147 @@ +# Proposal: feat/generic-providers — Universal Multi-Provider LLM Gateway + +**Status:** Implemented +**Branch:** `feat/generic-providers` +**Depends on:** None + +--- + +## Problem + +The AI Gateway Worker routes requests to 3 hardcoded providers (Anthropic, OpenAI, OpenAI-Codex). Adding a new provider requires code changes. + +--- + +## Goal + +Generalize the Worker into a universal authenticated LLM proxy — 11 additional OpenAI-compatible providers, zero code changes per provider. + +--- + +## Value + +- **11 additional providers** (DeepSeek, Groq, Mistral, Together, xAI, OpenRouter, Perplexity, Cohere, Fireworks, MiniMax, Moonshot) available through a single authenticated endpoint +- **Centralized credential management** — all provider API keys in Cloudflare KV, managed via the existing `/config` UI +- **Single gateway token** — clients authenticate once; the Worker resolves the real provider key per request +- **Unified telemetry** — all provider traffic logged through Llemtry, regardless of upstream provider +- **Security boundary** — real API keys never leave KV; clients only see the gateway token +- **No external dependencies** — works immediately with OpenClaw's existing `models.providers` config + +Users can add a DeepSeek or Groq key via the Config UI and start using those providers from OpenClaw within minutes. + +--- + +## Architecture + +``` +OpenClaw agent + → AI Gateway Worker (Cloudflare) + [validates gateway token, fetches provider key from KV] + → Real provider API (DeepSeek, Groq, Mistral, etc.) +``` + +OpenClaw's `models.providers` config points at `/proxy/{provider}` routes on the Worker: + +```jsonc +// openclaw.jsonc +"models": { + "providers": { + "deepseek": { + "baseUrl": "${AI_GATEWAY_URL}/proxy/deepseek", + "models": [] + } + } +} +``` + +--- + +## Provider Support + +11 generic providers (all OpenAI-compatible). Z.AI excluded — its path (`/api/paas/v4/`) is incompatible with the standard `/v1/` pattern. + +| Provider | Base URL | Auth | Notes | +|-------------|---------------------------------------|--------|-------------------------| +| cohere | `https://api.cohere.ai/compatibility` | Bearer | `/compatibility` prefix | +| deepseek | `https://api.deepseek.com` | Bearer | Standard | +| fireworks | `https://api.fireworks.ai/inference` | Bearer | `/inference` prefix | +| groq | `https://api.groq.com/openai` | Bearer | `/openai` prefix | +| minimax | `https://api.minimax.io` | Bearer | Standard | +| mistral | `https://api.mistral.ai` | Bearer | Standard | +| moonshot | `https://api.moonshot.ai` | Bearer | `.ai` not `.cn` | +| openrouter | `https://openrouter.ai/api` | Bearer | `/api` prefix | +| perplexity | `https://api.perplexity.ai` | Bearer | No `/v1/models` | +| together | `https://api.together.xyz` | Bearer | Standard | +| xai | `https://api.x.ai` | Bearer | Standard | + +All 11 use `Authorization: Bearer `. No provider-specific headers required. + +Plus 3 existing legacy providers (anthropic, openai, openai-codex) on their existing static routes. + +### URL Construction + +``` +Request: POST /proxy/groq/v1/chat/completions + ──── ───────────────────────── + provider directPath + +Target: https://api.groq.com/openai/v1/chat/completions + ───────────────────────────── ───────────────────── + PROVIDER_DEFAULTS["groq"] directPath +``` + +Base URLs do NOT include `/v1` — the `directPath` from route matching provides it. + +### Allowed Endpoints (whitelist) + +- `POST /proxy/{provider}/v1/chat/completions` +- `POST /proxy/{provider}/v1/embeddings` +- `GET /proxy/{provider}/v1/models` + +No `v1/responses` (OpenAI-specific). No `v1/messages` (Anthropic-specific). + +--- + +## Implementation + +### Files changed + +| File | Change | +|------|--------| +| `types.ts` | `LegacyProvider` type alias, `GenericRouteMatch` export, `providers` field on `UserCredentials` | +| `routing.ts` | `GENERIC_PROVIDERS` set, `GENERIC_ENDPOINTS` whitelist, `matchGenericRoute()` | +| `config.ts` | `PROVIDER_DEFAULTS` record (11 base URLs), `getGenericProviderConfig()` | +| `keys.ts` | `getGenericApiKey()` reads from `creds.providers[provider].apiKey` | +| `providers/generic.ts` | New — OpenAI-compatible passthrough proxy | +| `index.ts` | Generic route handling: auth → key lookup → `proxyGeneric()` → Llemtry | +| `admin.ts` | `mergeCredentials`/`maskCredentials` extended for `providers` field | +| `config-ui.ts` | Collapsible "Additional Providers" section, 11 API key fields | +| `llemtry.ts` | `ReportOptions.provider` widened to `string` | + +### KV Schema (additive, no migration) + +```json +{ + "anthropic": { "apiKey": "...", "oauthToken": "..." }, + "openai": { "apiKey": "...", "oauth": { ... } }, + "providers": { + "deepseek": { "apiKey": "..." }, + "groq": { "apiKey": "..." } + } +} +``` + +### Security + +- **Endpoint whitelist:** 3 paths only — no arbitrary upstream path probing +- **Provider whitelist:** 11 known providers — no proxying to arbitrary hosts +- **No key, no call:** 401 if `apiKey` is falsy after KV lookup +- **Token isolation:** Gateway token never forwarded — fresh `Authorization` built from KV key +- **KV isolation:** `providers.*` namespace can't collide with legacy keys + +### Backward Compatibility + +- Existing static routes unchanged +- Existing KV credential format unchanged — `providers` is additive +- Existing OAuth flows unchanged +- Legacy code paths untouched — generic uses separate functions From e8a6123b7f29cbc1206a47fbeca0be391732fff8 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:22:27 -0300 Subject: [PATCH 04/10] fix(ai-gateway): preserve request headers and support egress proxy in generic provider - generic.ts: preserve all request headers (matching openai.ts pattern), only rewrite Authorization; support ProviderConfig with egress proxy URL wrapping and CF header stripping - config.ts: getGenericProviderConfig returns full ProviderConfig with egressProxyUrl and proxy auth headers when configured - index.ts: pass ProviderConfig to proxyGeneric; fix error code for missing API key from 500 to 401 (consistency with generic route) --- workers/ai-gateway/src/config.ts | 21 +++++++++-- workers/ai-gateway/src/index.ts | 4 +- workers/ai-gateway/src/providers/generic.ts | 42 ++++++++++++++++----- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 86afbed..494a5b9 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -79,7 +79,22 @@ const PROVIDER_DEFAULTS: Record = { xai: { baseUrl: 'https://api.x.ai' }, } -/** Look up the base URL config for a generic provider. Returns null for unknown providers. */ -export function getGenericProviderConfig(provider: string): { baseUrl: string } | null { - return PROVIDER_DEFAULTS[provider] ?? null +/** Look up the config for a generic provider. Includes egress proxy if configured. Returns null for unknown providers. */ +export function getGenericProviderConfig(provider: string): ProviderConfig | null { + const defaults = PROVIDER_DEFAULTS[provider] + if (!defaults) return null + + return { + baseUrl: defaults.baseUrl, + egressProxyUrl: env.EGRESS_PROXY_URL || undefined, + headers: env.EGRESS_PROXY_AUTH_TOKEN + ? { + 'X-Proxy-Auth': `Bearer ${env.EGRESS_PROXY_AUTH_TOKEN}`, + ...(env.CF_ACCESS_CLIENT_ID && { + 'CF-Access-Client-Id': env.CF_ACCESS_CLIENT_ID, + 'CF-Access-Client-Secret': env.CF_ACCESS_CLIENT_SECRET, + }), + } + : undefined, + } } diff --git a/workers/ai-gateway/src/index.ts b/workers/ai-gateway/src/index.ts index 9582115..90ba8f7 100644 --- a/workers/ai-gateway/src/index.ts +++ b/workers/ai-gateway/src/index.ts @@ -122,7 +122,7 @@ export default { let response = await proxyGeneric( apiKey, request, - providerConfig.baseUrl, + providerConfig, genericRoute.directPath, log, genericRoute.provider, @@ -154,7 +154,7 @@ export default { const apiKey = await getProviderApiKey(route!.provider, userId, env.AUTH_KV, log) if (!apiKey) { console.error(`No API key configured for ${route!.provider}: ${request.method} ${route}`) - return addCorsHeaders(jsonError(`No API key configured for ${route!.provider}`, 500)) + return addCorsHeaders(jsonError(`No API key configured for ${route!.provider}`, 401)) } if (env.LOG_LEVEL === 'debug') { diff --git a/workers/ai-gateway/src/providers/generic.ts b/workers/ai-gateway/src/providers/generic.ts index 11c5cde..27aa3d9 100644 --- a/workers/ai-gateway/src/providers/generic.ts +++ b/workers/ai-gateway/src/providers/generic.ts @@ -1,30 +1,52 @@ -import type { Log } from '../types' +import type { ProviderConfig, Log } from '../types' import { sanitizeHeaders, truncateBody } from '../log' /** Proxy the request to a generic OpenAI-compatible provider. */ export async function proxyGeneric( apiKey: string, request: Request, - baseUrl: string, + config: ProviderConfig, directPath: string, log: Log, provider: string, preReadBody?: string ): Promise { - const targetUrl = `${baseUrl}/${directPath}` + const targetUrl = `${config.baseUrl}/${directPath}` - const headers = new Headers() - headers.set('content-type', request.headers.get('content-type') || 'application/json') - headers.set('authorization', `Bearer ${apiKey}`) - const accept = request.headers.get('accept') - if (accept) headers.set('accept', accept) + const headers = new Headers(request.headers) + + // Replace gateway auth token with the real provider API key + headers.set('Authorization', `Bearer ${apiKey}`) + + // Set provider-config headers (e.g. cf-aig-authorization for gateway mode, + // X-Proxy-Auth for egress proxy) + if (config.headers) { + for (const [key, value] of Object.entries(config.headers)) { + headers.set(key, value) + } + } + + // When egress proxy is configured, wrap the target URL in the proxy URL + // and strip CF-injected headers that shouldn't reach the upstream + const url = config.egressProxyUrl + ? `${config.egressProxyUrl}?_proxyUpstreamURL_=${encodeURIComponent(targetUrl)}` + : targetUrl + + if (config.egressProxyUrl) { + for (const h of [ + 'host', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', + 'x-real-ip', 'x-forwarded-proto', 'x-forwarded-for', + ]) { + headers.delete(h) + } + } const body = preReadBody ?? await request.text() - log.debug(`[${provider}] url=${targetUrl}`) + log.debug(`[${provider}] url=${url}`) log.debug(`[${provider}] upstream headers`, sanitizeHeaders(headers)) log.debug(`[${provider}] request body`, truncateBody(body)) - return fetch(targetUrl, { + return fetch(url, { method: request.method, headers, body: request.method !== 'GET' ? body : undefined, From b6d8615b753eacf3e9a4a27b72f653fccab2fe26 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:27:04 -0300 Subject: [PATCH 05/10] fix(ai-gateway): scope egress proxy to openai-codex only, remove redundant log - config.ts: getGenericProviderConfig no longer inherits EGRESS_PROXY_URL; the egress sidecar is a chatgpt.com WAF workaround scoped to openai-codex and should not add an extra hop for generic providers - index.ts: remove redundant console.error on missing API key (already logged by getProviderApiKey via log.warn) --- workers/ai-gateway/src/config.ts | 19 +++++-------------- workers/ai-gateway/src/index.ts | 1 - 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 494a5b9..57afae1 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -79,22 +79,13 @@ const PROVIDER_DEFAULTS: Record = { xai: { baseUrl: 'https://api.x.ai' }, } -/** Look up the config for a generic provider. Includes egress proxy if configured. Returns null for unknown providers. */ +/** Look up the config for a generic provider. Returns null for unknown providers. + * Generic providers do not inherit the egress proxy — EGRESS_PROXY_URL is scoped + * to openai-codex (chatgpt.com WAF workaround) and should not add an extra hop + * for providers that accept requests from CF Worker IPs directly. */ export function getGenericProviderConfig(provider: string): ProviderConfig | null { const defaults = PROVIDER_DEFAULTS[provider] if (!defaults) return null - return { - baseUrl: defaults.baseUrl, - egressProxyUrl: env.EGRESS_PROXY_URL || undefined, - headers: env.EGRESS_PROXY_AUTH_TOKEN - ? { - 'X-Proxy-Auth': `Bearer ${env.EGRESS_PROXY_AUTH_TOKEN}`, - ...(env.CF_ACCESS_CLIENT_ID && { - 'CF-Access-Client-Id': env.CF_ACCESS_CLIENT_ID, - 'CF-Access-Client-Secret': env.CF_ACCESS_CLIENT_SECRET, - }), - } - : undefined, - } + return { baseUrl: defaults.baseUrl } } diff --git a/workers/ai-gateway/src/index.ts b/workers/ai-gateway/src/index.ts index 90ba8f7..a161022 100644 --- a/workers/ai-gateway/src/index.ts +++ b/workers/ai-gateway/src/index.ts @@ -153,7 +153,6 @@ export default { // --- Legacy provider route --- const apiKey = await getProviderApiKey(route!.provider, userId, env.AUTH_KV, log) if (!apiKey) { - console.error(`No API key configured for ${route!.provider}: ${request.method} ${route}`) return addCorsHeaders(jsonError(`No API key configured for ${route!.provider}`, 401)) } From f6f24753d439ab976996ec092606731886fb6db5 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:32:59 -0300 Subject: [PATCH 06/10] fix(ai-gateway): add CF AI Gateway support for generic providers, remove dead code - config.ts: getGenericProviderConfig now returns CF AI Gateway config when CF_AI_GATEWAY_* env vars are set, matching legacy provider behavior - index.ts: compute gateway path for generic routes ({provider}/chat/completions format, stripping v1/ prefix) - generic.ts: remove dead egress proxy URL wrapping and CF header stripping (egress proxy is scoped to openai-codex only); keep config.headers handling for CF AI Gateway auth header --- workers/ai-gateway/src/config.ts | 13 ++++++++--- workers/ai-gateway/src/index.ts | 8 ++++++- workers/ai-gateway/src/providers/generic.ts | 26 ++++----------------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 57afae1..ec1f3ab 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -80,12 +80,19 @@ const PROVIDER_DEFAULTS: Record = { } /** Look up the config for a generic provider. Returns null for unknown providers. - * Generic providers do not inherit the egress proxy — EGRESS_PROXY_URL is scoped - * to openai-codex (chatgpt.com WAF workaround) and should not add an extra hop - * for providers that accept requests from CF Worker IPs directly. */ + * Uses CF AI Gateway when configured (same as legacy providers). + * Does NOT inherit the egress proxy — EGRESS_PROXY_URL is scoped to openai-codex. */ export function getGenericProviderConfig(provider: string): ProviderConfig | null { const defaults = PROVIDER_DEFAULTS[provider] if (!defaults) return null + const useCfGateway = env.CF_AI_GATEWAY_TOKEN && env.CF_AI_GATEWAY_ID && env.CF_AI_GATEWAY_ACCOUNT_ID + if (useCfGateway) { + return { + baseUrl: `https://gateway.ai.cloudflare.com/v1/${env.CF_AI_GATEWAY_ACCOUNT_ID}/${env.CF_AI_GATEWAY_ID}`, + headers: { 'cf-aig-authorization': `Bearer ${env.CF_AI_GATEWAY_TOKEN}` }, + } + } + return { baseUrl: defaults.baseUrl } } diff --git a/workers/ai-gateway/src/index.ts b/workers/ai-gateway/src/index.ts index a161022..e1036a2 100644 --- a/workers/ai-gateway/src/index.ts +++ b/workers/ai-gateway/src/index.ts @@ -115,6 +115,12 @@ export default { return addCorsHeaders(jsonError(`Unknown provider: ${genericRoute.provider}`, 404)) } + // CF AI Gateway uses a different path format: {provider}/chat/completions (no /v1/) + const isGateway = providerConfig.baseUrl.includes('gateway.ai.cloudflare.com') + const upstreamPath = isGateway + ? `${genericRoute.provider}/${genericRoute.directPath.replace('v1/', '')}` + : genericRoute.directPath + const llemtryActive = isLlemtryEnabled(env, log) && isLlmRoute(genericRoute.directPath) const startTime = llemtryActive ? new Date() : undefined const requestBody = llemtryActive ? await request.text() : undefined @@ -123,7 +129,7 @@ export default { apiKey, request, providerConfig, - genericRoute.directPath, + upstreamPath, log, genericRoute.provider, requestBody diff --git a/workers/ai-gateway/src/providers/generic.ts b/workers/ai-gateway/src/providers/generic.ts index 27aa3d9..bfc7db0 100644 --- a/workers/ai-gateway/src/providers/generic.ts +++ b/workers/ai-gateway/src/providers/generic.ts @@ -6,47 +6,31 @@ export async function proxyGeneric( apiKey: string, request: Request, config: ProviderConfig, - directPath: string, + path: string, log: Log, provider: string, preReadBody?: string ): Promise { - const targetUrl = `${config.baseUrl}/${directPath}` + const targetUrl = `${config.baseUrl}/${path}` const headers = new Headers(request.headers) // Replace gateway auth token with the real provider API key headers.set('Authorization', `Bearer ${apiKey}`) - // Set provider-config headers (e.g. cf-aig-authorization for gateway mode, - // X-Proxy-Auth for egress proxy) + // Set provider-config headers (e.g. cf-aig-authorization for CF AI Gateway mode) if (config.headers) { for (const [key, value] of Object.entries(config.headers)) { headers.set(key, value) } } - // When egress proxy is configured, wrap the target URL in the proxy URL - // and strip CF-injected headers that shouldn't reach the upstream - const url = config.egressProxyUrl - ? `${config.egressProxyUrl}?_proxyUpstreamURL_=${encodeURIComponent(targetUrl)}` - : targetUrl - - if (config.egressProxyUrl) { - for (const h of [ - 'host', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', - 'x-real-ip', 'x-forwarded-proto', 'x-forwarded-for', - ]) { - headers.delete(h) - } - } - const body = preReadBody ?? await request.text() - log.debug(`[${provider}] url=${url}`) + log.debug(`[${provider}] url=${targetUrl}`) log.debug(`[${provider}] upstream headers`, sanitizeHeaders(headers)) log.debug(`[${provider}] request body`, truncateBody(body)) - return fetch(url, { + return fetch(targetUrl, { method: request.method, headers, body: request.method !== 'GET' ? body : undefined, From adef8b60da14d320e7305579ad708701455c152d Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 21:38:01 -0300 Subject: [PATCH 07/10] fix(ai-gateway): strip cf-* headers, clean up config-ui, update docs - generic.ts: strip Cloudflare-injected metadata headers (cf-*) before forwarding to upstream providers (defense in depth) - config-ui.ts: convert early return in buildUpdate to if/else for clarity - README.md: document generic provider routes and 14-provider support - AI-GATEWAY-CONFIG.md: add generic provider credential types, route pattern, and verification example --- docs/AI-GATEWAY-CONFIG.md | 18 +++++++++-- workers/ai-gateway/README.md | 9 ++++-- workers/ai-gateway/src/config-ui.ts | 36 +++++++++------------ workers/ai-gateway/src/providers/generic.ts | 5 +++ 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/docs/AI-GATEWAY-CONFIG.md b/docs/AI-GATEWAY-CONFIG.md index ac944c6..8c6d2a2 100644 --- a/docs/AI-GATEWAY-CONFIG.md +++ b/docs/AI-GATEWAY-CONFIG.md @@ -1,6 +1,6 @@ # AI Gateway Proxy — Configuration Guide -The AI Gateway Worker proxies LLM requests from the OpenClaw gateway to Anthropic and OpenAI. Provider credentials are stored per-user in Cloudflare KV and managed via the self-service `/config` UI. +The AI Gateway Worker proxies LLM requests from the OpenClaw gateway to upstream LLM providers. Supports Anthropic, OpenAI, and 11 additional OpenAI-compatible providers (DeepSeek, Groq, Mistral, Together, xAI, OpenRouter, Perplexity, Cohere, Fireworks, MiniMax, Moonshot). Provider credentials are stored per-user in Cloudflare KV and managed via the self-service `/config` UI. The worker supports two upstream routing modes, auto-detected based on which secrets are configured. @@ -32,7 +32,7 @@ The worker auto-detects which mode to use: if `CF_AI_GATEWAY_TOKEN`, `CF_AI_GATE Visit the config UI at `https:///config` and authenticate with your gateway token. -The config page supports four credential types: +The config page supports four credential types for the legacy providers, plus API key fields for 11 additional providers under the collapsible "Additional Providers" section: | Provider | Credential | Field | Notes | |----------|-----------|-------|-------| @@ -40,10 +40,13 @@ The config page supports four credential types: | Anthropic | OAuth Token | `sk-ant-oat-*` | Claude Code subscription token (takes priority over API key) | | OpenAI | API Key | `sk-*` | Standard API key | | OpenAI | Codex OAuth | Paste `.codex/auth.json` | Codex subscription (takes priority over API key, auto-refreshes) | +| Additional | API Key | Per-provider | Cohere, DeepSeek, Fireworks, Groq, MiniMax, Mistral, Moonshot, OpenRouter, Perplexity, Together, xAI | Credentials are stored in Cloudflare KV — they never touch the VPS. Changes take effect immediately. -For each provider, OAuth/subscription credentials take priority over static API keys. You can have both configured as a fallback. +For Anthropic and OpenAI, OAuth/subscription credentials take priority over static API keys. You can have both configured as a fallback. + +Additional providers use `/proxy/{provider}/v1/...` routes (e.g., `/proxy/deepseek/v1/chat/completions`). --- @@ -116,6 +119,15 @@ curl -s https:///openai/v1/chat/completions \ -d '{"model":"gpt-4o-mini","max_tokens":10,"messages":[{"role":"user","content":"Say hi"}]}' ``` +### Test a generic provider (e.g., DeepSeek) + +```bash +curl -s https:///proxy/deepseek/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"deepseek-chat","max_tokens":10,"messages":[{"role":"user","content":"Say hi"}]}' +``` + ### Verify CF AI Gateway analytics (CF AI Gateway mode only) 1. Go to **Cloudflare Dashboard** -> **AI** -> **AI Gateway** -> your gateway diff --git a/workers/ai-gateway/README.md b/workers/ai-gateway/README.md index 047a78f..75025b3 100644 --- a/workers/ai-gateway/README.md +++ b/workers/ai-gateway/README.md @@ -1,13 +1,13 @@ # AI Gateway Proxy Worker -Cloudflare Worker that proxies LLM API calls to Anthropic and OpenAI. Sits between the OpenClaw gateway and providers without changing request/response formats. Routes directly to provider APIs by default, or optionally through [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) for observability, caching, and rate limiting. +Cloudflare Worker that proxies LLM API calls to upstream providers. Supports 3 legacy providers (Anthropic, OpenAI, OpenAI-Codex) on static routes and 11 generic OpenAI-compatible providers (DeepSeek, Groq, Mistral, Together, xAI, OpenRouter, Perplexity, Cohere, Fireworks, MiniMax, Moonshot) via `/proxy/{provider}/...` routes. Routes directly to provider APIs by default, or optionally through [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/) for observability, caching, and rate limiting. ``` Direct mode (default): - OpenClaw Gateway → Worker (auth, key swap) → Anthropic / OpenAI + OpenClaw Gateway → Worker (auth, key swap) → Provider API CF AI Gateway mode (optional): - OpenClaw Gateway → Worker (auth, URL rewrite) → Cloudflare AI Gateway → Anthropic / OpenAI + OpenClaw Gateway → Worker (auth, URL rewrite) → Cloudflare AI Gateway → Provider API ``` Streaming works transparently — request and response bodies are passed through as `ReadableStream` without parsing. @@ -29,6 +29,9 @@ Streaming works transparently — request and response bodies are passed through | `/openai/v1/embeddings` | POST | User token | OpenAI proxy | | `/openai/v1/models` | GET | User token | OpenAI proxy | | `/anthropic/v1/messages` | POST | User token | Anthropic proxy | +| `/proxy/{provider}/v1/chat/completions` | POST | User token | Generic provider proxy | +| `/proxy/{provider}/v1/embeddings` | POST | User token | Generic provider proxy | +| `/proxy/{provider}/v1/models` | GET | User token | Generic provider proxy | ## Auth diff --git a/workers/ai-gateway/src/config-ui.ts b/workers/ai-gateway/src/config-ui.ts index 72c1960..b1b6b56 100644 --- a/workers/ai-gateway/src/config-ui.ts +++ b/workers/ai-gateway/src/config-ui.ts @@ -357,30 +357,26 @@ const CONFIG_HTML = /* html */ ` // Clean up empty nested objects if (Object.keys(update[root][name]).length === 0) delete update[root][name]; if (Object.keys(update[root]).length === 0) delete update[root]; - return; - } + } else { + // 2-segment path: {provider}.{key} + const [provider, key] = segments; + if (!update[provider]) update[provider] = {}; - // 2-segment path: {provider}.{key} - const [provider, key] = segments; - if (!update[provider]) update[provider] = {}; - - if (state.cleared) { - // Send null to delete - update[provider][key] = null; - } else if (newVal) { - // Parse Codex auth JSON for openai.oauth - if (path === 'openai.oauth') { - const parsed = parseCodexAuth(newVal); - if (parsed.error) { - showStatus('#save-status', parsed.error, 'error'); - return; + if (state.cleared) { + update[provider][key] = null; + } else if (newVal) { + if (path === 'openai.oauth') { + const parsed = parseCodexAuth(newVal); + if (parsed.error) { + showStatus('#save-status', parsed.error, 'error'); + return; + } + update[provider][key] = parsed.value; + } else { + update[provider][key] = newVal; } - update[provider][key] = parsed.value; - } else { - update[provider][key] = newVal; } } - // Absent = no change (omit from update) }); // Remove provider keys with no actual changes diff --git a/workers/ai-gateway/src/providers/generic.ts b/workers/ai-gateway/src/providers/generic.ts index bfc7db0..47036b8 100644 --- a/workers/ai-gateway/src/providers/generic.ts +++ b/workers/ai-gateway/src/providers/generic.ts @@ -18,6 +18,11 @@ export async function proxyGeneric( // Replace gateway auth token with the real provider API key headers.set('Authorization', `Bearer ${apiKey}`) + // Strip Cloudflare-injected metadata headers that shouldn't reach upstream providers + for (const key of [...headers.keys()]) { + if (key.startsWith('cf-')) headers.delete(key) + } + // Set provider-config headers (e.g. cf-aig-authorization for CF AI Gateway mode) if (config.headers) { for (const [key, value] of Object.entries(config.headers)) { From 9eb7811e6b9dc8286bc624a7a88024fa0075d237 Mon Sep 17 00:00:00 2001 From: Nim G Date: Sun, 8 Mar 2026 22:00:04 -0300 Subject: [PATCH 08/10] refactor(ai-gateway): remove dead exports, deduplicate KV loader, single provider list - Remove unused LegacyProvider type and GenericRouteMatch re-export from types.ts - Extract shared loadCredentials() helper in keys.ts, eliminating duplicate KV read/parse boilerplate - Derive GENERIC_PROVIDERS from exported PROVIDER_DEFAULTS so the provider list is defined once --- workers/ai-gateway/src/config.ts | 2 +- workers/ai-gateway/src/keys.ts | 36 +++++++++++++++---------------- workers/ai-gateway/src/routing.ts | 18 ++++------------ workers/ai-gateway/src/types.ts | 4 ---- 4 files changed, 22 insertions(+), 38 deletions(-) diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index ec1f3ab..1a2e72a 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -65,7 +65,7 @@ export function getProviderConfig(provider: string): ProviderConfig { /** Verified base URLs for generic OpenAI-compatible providers. * Base URLs do NOT include /v1 — the directPath from route matching provides it. * e.g. Groq: baseUrl="https://api.groq.com/openai" + "/v1/chat/completions" */ -const PROVIDER_DEFAULTS: Record = { +export const PROVIDER_DEFAULTS: Record = { cohere: { baseUrl: 'https://api.cohere.ai/compatibility' }, deepseek: { baseUrl: 'https://api.deepseek.com' }, fireworks: { baseUrl: 'https://api.fireworks.ai/inference' }, diff --git a/workers/ai-gateway/src/keys.ts b/workers/ai-gateway/src/keys.ts index b21ac48..a0fbdff 100644 --- a/workers/ai-gateway/src/keys.ts +++ b/workers/ai-gateway/src/keys.ts @@ -3,26 +3,35 @@ import { refreshOpenAIToken } from './openai-oauth' const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min before expiry -/** Resolve the upstream API key for a provider from the user's KV credentials. */ -export async function getProviderApiKey( - provider: Provider, +/** Load and parse user credentials from KV. Returns undefined on missing or invalid data. */ +async function loadCredentials( userId: string, kv: KVNamespace, log: Log -): Promise { +): Promise { const raw = await kv.get(`creds:${userId}`) if (!raw) { log.warn(`[keys] no credentials in KV for user ${userId}`) return undefined } - let creds: UserCredentials try { - creds = JSON.parse(raw) + return JSON.parse(raw) } catch { log.error(`[keys] failed to parse credentials for user ${userId}`) return undefined } +} + +/** Resolve the upstream API key for a provider from the user's KV credentials. */ +export async function getProviderApiKey( + provider: Provider, + userId: string, + kv: KVNamespace, + log: Log +): Promise { + const creds = await loadCredentials(userId, kv, log) + if (!creds) return undefined if (provider === 'anthropic') { return resolveAnthropicKey(creds, log) @@ -91,19 +100,8 @@ export async function getGenericApiKey( kv: KVNamespace, log: Log ): Promise { - const raw = await kv.get(`creds:${userId}`) - if (!raw) { - log.warn(`[keys] no credentials in KV for user ${userId}`) - return undefined - } - - let creds: UserCredentials - try { - creds = JSON.parse(raw) - } catch { - log.error(`[keys] failed to parse credentials for user ${userId}`) - return undefined - } + const creds = await loadCredentials(userId, kv, log) + if (!creds) return undefined const key = creds.providers?.[provider]?.apiKey if (!key) log.warn(`[keys] no ${provider} credentials found for user ${userId}`) diff --git a/workers/ai-gateway/src/routing.ts b/workers/ai-gateway/src/routing.ts index 25ea788..85dd4a0 100644 --- a/workers/ai-gateway/src/routing.ts +++ b/workers/ai-gateway/src/routing.ts @@ -74,20 +74,10 @@ export function matchProviderRoute(method: string, pathname: string): RouteMatch // --- Generic provider routing (OpenAI-compatible) --- -/** Known generic providers. Requests to unknown providers are rejected. */ -export const GENERIC_PROVIDERS = new Set([ - 'cohere', - 'deepseek', - 'fireworks', - 'groq', - 'minimax', - 'mistral', - 'moonshot', - 'openrouter', - 'perplexity', - 'together', - 'xai', -]) +import { PROVIDER_DEFAULTS } from './config' + +/** Known generic providers (derived from PROVIDER_DEFAULTS). Requests to unknown providers are rejected. */ +export const GENERIC_PROVIDERS = new Set(Object.keys(PROVIDER_DEFAULTS)) /** Whitelisted endpoints for generic providers: directPath → allowed method. */ const GENERIC_ENDPOINTS: Record = { diff --git a/workers/ai-gateway/src/types.ts b/workers/ai-gateway/src/types.ts index d2b2e86..eac06e8 100644 --- a/workers/ai-gateway/src/types.ts +++ b/workers/ai-gateway/src/types.ts @@ -1,6 +1,5 @@ export type { Log } from './log' export type { Provider, RouteMatch } from './routing' -export type { GenericRouteMatch } from './routing' export type { ProviderConfig } from './config' // Use `wrangler types --env-file .dev.vars.example` to generate Env var types @@ -11,9 +10,6 @@ export type { ProviderConfig } from './config' // --- KV schema types --- -/** The 3 original providers with dedicated route handling. */ -export type LegacyProvider = 'anthropic' | 'openai' | 'openai-codex' - export interface UserCredentials { anthropic?: { apiKey?: string // sk-ant-api-* (regular API key) From 4c02f8611f26d36192c36882d776b61e051fd1e7 Mon Sep 17 00:00:00 2001 From: Nim G Date: Mon, 9 Mar 2026 12:25:34 -0300 Subject: [PATCH 09/10] fix(ai-gateway): build codex headers independently of egress proxy auth CF Access headers were silently dropped when EGRESS_PROXY_AUTH_TOKEN was unset because the entire headers object was gated on that var. Extract buildCodexHeaders() that builds each header set independently. Also fix buildUpdate() in config-ui: a parse error in the forEach callback only exited that iteration, allowing a partial update to be submitted. Add hasError flag to abort the entire build and null guard in save(). --- workers/ai-gateway/src/config-ui.ts | 9 +++++++++ workers/ai-gateway/src/config.ts | 25 +++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/workers/ai-gateway/src/config-ui.ts b/workers/ai-gateway/src/config-ui.ts index b1b6b56..6f18695 100644 --- a/workers/ai-gateway/src/config-ui.ts +++ b/workers/ai-gateway/src/config-ui.ts @@ -300,6 +300,10 @@ const CONFIG_HTML = /* html */ ` try { const update = buildUpdate(); + if (!update) { + btn.disabled = false; + return; + } if (Object.keys(update).length === 0) { showStatus('#save-status', 'No changes to save', 'error'); btn.disabled = false; @@ -335,8 +339,10 @@ const CONFIG_HTML = /* html */ ` function buildUpdate() { const update = {}; + let hasError = false; document.querySelectorAll('[data-field]').forEach(el => { + if (hasError) return; const path = el.dataset.field; const input = el.querySelector('input, textarea'); const state = fieldState[path] || {}; @@ -369,6 +375,7 @@ const CONFIG_HTML = /* html */ ` const parsed = parseCodexAuth(newVal); if (parsed.error) { showStatus('#save-status', parsed.error, 'error'); + hasError = true; return; } update[provider][key] = parsed.value; @@ -379,6 +386,8 @@ const CONFIG_HTML = /* html */ ` } }); + if (hasError) return null; + // Remove provider keys with no actual changes for (const provider of Object.keys(update)) { if (typeof update[provider] === 'object' && Object.keys(update[provider]).length === 0) { diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 1a2e72a..8f455c3 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -16,6 +16,20 @@ export interface ProviderConfig { * Change these to any upstream provider or proxy endpoints: Azure, AWS Bedrock, etc. * Defaults to using Cloudflare AI Gateway if env vars are configured. */ +export function buildCodexHeaders(vars: { + EGRESS_PROXY_AUTH_TOKEN?: string + CF_ACCESS_CLIENT_ID?: string + CF_ACCESS_CLIENT_SECRET?: string +}): Record | undefined { + const h: Record = {} + if (vars.EGRESS_PROXY_AUTH_TOKEN) h['X-Proxy-Auth'] = `Bearer ${vars.EGRESS_PROXY_AUTH_TOKEN}` + if (vars.CF_ACCESS_CLIENT_ID) { + h['CF-Access-Client-Id'] = vars.CF_ACCESS_CLIENT_ID + h['CF-Access-Client-Secret'] = vars.CF_ACCESS_CLIENT_SECRET! + } + return Object.keys(h).length > 0 ? h : undefined +} + export function getProviderConfig(provider: string): ProviderConfig { // Cloudflare AI Gateway (optional): // Provides observability and token cost estimates, LLM routing, and more. @@ -43,16 +57,7 @@ export function getProviderConfig(provider: string): ProviderConfig { return { baseUrl: 'https://chatgpt.com/backend-api', egressProxyUrl: env.EGRESS_PROXY_URL || undefined, - headers: env.EGRESS_PROXY_AUTH_TOKEN - ? { - 'X-Proxy-Auth': `Bearer ${env.EGRESS_PROXY_AUTH_TOKEN}`, - // CF Access service token — authenticates to Cloudflare Zero Trust - ...(env.CF_ACCESS_CLIENT_ID && { - 'CF-Access-Client-Id': env.CF_ACCESS_CLIENT_ID, - 'CF-Access-Client-Secret': env.CF_ACCESS_CLIENT_SECRET, - }), - } - : undefined, + headers: buildCodexHeaders(env), } default: From fdb996fb1278d2694a2c060e9a95ba1883a8357d Mon Sep 17 00:00:00 2001 From: Nim G Date: Mon, 9 Mar 2026 12:42:22 -0300 Subject: [PATCH 10/10] fix(ai-gateway): require both CF Access vars for codex headers buildCodexHeaders() used a non-null assertion for CF_ACCESS_CLIENT_SECRET when only CF_ACCESS_CLIENT_ID was checked. Now both must be set or neither Access header is emitted. --- workers/ai-gateway/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workers/ai-gateway/src/config.ts b/workers/ai-gateway/src/config.ts index 8f455c3..727e33c 100644 --- a/workers/ai-gateway/src/config.ts +++ b/workers/ai-gateway/src/config.ts @@ -23,9 +23,9 @@ export function buildCodexHeaders(vars: { }): Record | undefined { const h: Record = {} if (vars.EGRESS_PROXY_AUTH_TOKEN) h['X-Proxy-Auth'] = `Bearer ${vars.EGRESS_PROXY_AUTH_TOKEN}` - if (vars.CF_ACCESS_CLIENT_ID) { + if (vars.CF_ACCESS_CLIENT_ID && vars.CF_ACCESS_CLIENT_SECRET) { h['CF-Access-Client-Id'] = vars.CF_ACCESS_CLIENT_ID - h['CF-Access-Client-Secret'] = vars.CF_ACCESS_CLIENT_SECRET! + h['CF-Access-Client-Secret'] = vars.CF_ACCESS_CLIENT_SECRET } return Object.keys(h).length > 0 ? h : undefined }