From 7cf42247b32883c94f3a8c0e489145ea56164618 Mon Sep 17 00:00:00 2001 From: Rob Knight <12484127+rknightion@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:03:56 +0100 Subject: [PATCH 1/5] feat: support Cloudflare AI Gateway as a saved provider Cloudflare serves Jev as a Workers AI model at /workers-ai/run/typesafe/jev with Unified Billing. Auth saves the account's gateway URL (gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY or a custom domain) alongside the token. The evaluator adapts that one request: path from the saved URL, token as cf-aig-authorization, model taken from the path, {state, result} envelope unwrapped, redirects refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016dpmZNjz5QApmRBSwvz2Ne --- README.md | 2 +- apps/cli/README.md | 14 +++- apps/cli/src/args.ts | 27 +++++-- apps/cli/src/auth.ts | 61 ++++++++++++--- apps/cli/src/index.ts | 2 +- apps/cli/test/args.test.ts | 13 ++++ docs/architecture.md | 5 +- packages/core/src/evaluator.ts | 50 +++++++++++- packages/core/src/providers.ts | 34 +++++++++ test/evaluator.test.ts | 134 +++++++++++++++++++++++++++++++++ 10 files changed, 318 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6865c2b..d13596f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ jg skill jg "How are telemetry events recorded and sent?" ./my-project ``` -Requires **Node.js 22+**, **macOS or Linux**, and a key for **Vercel AI Gateway, TypeSafe, or OpenRouter**. +Requires **Node.js 22+**, **macOS or Linux**, and a key for **Vercel AI Gateway, TypeSafe, OpenRouter, or Cloudflare AI Gateway**. No separate Python, Bun, or ripgrep installation is required to use `jg`. Provider selection requires **0.3.0 or newer**. Upgrade an older installation with diff --git a/apps/cli/README.md b/apps/cli/README.md index ae5d0f6..c103a4b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -13,7 +13,7 @@ jg doctor jg "How are telemetry events recorded and sent?" ./my-project ``` -`auth` asks for Vercel AI Gateway, TypeSafe, or OpenRouter, then saves its key in +`auth` asks for Vercel AI Gateway, TypeSafe, OpenRouter, or Cloudflare AI Gateway, then saves its key in an owner-only config file. Searches use that provider until you run auth again. `doctor` verifies access with synthetic input and names the selected provider. For unattended setup, pipe the key from your secret manager to: @@ -22,7 +22,17 @@ For unattended setup, pipe the key from your secret manager to: jg auth --provider openrouter --stdin ``` -Both options are required for piped setup. Credentials are saved under +Both options are required for piped setup. Cloudflare AI Gateway also needs your +gateway URL, saved with the token: either +`https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY` or the gateway's custom domain. +The token needs the account's AI Gateway Run permission; Jev is billed through +Cloudflare Unified Billing and served on the gateway's Workers AI route. + +```sh +jg auth --provider cloudflare --gateway-url https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY --stdin +``` + +Credentials are saved under `$XDG_CONFIG_HOME/jevgrep/credentials.json`, or `~/.config/jevgrep/credentials.json`. Existing saved records without a provider still mean Vercel, without a migration. API-key, endpoint, and model environment overrides are ignored; users who only diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index f335288..1daadda 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,13 +1,13 @@ import { parseArgs } from "node:util"; import type { SearchInput } from "@repo/core"; -import { isProviderId, type ProviderId } from "@repo/core/providers"; +import { isProviderId, requiresGatewayURL, type ProviderId } from "@repo/core/providers"; import { CliError } from "./errors"; import { DEFAULT_MAX_SOURCE_BYTES } from "./render"; export type Command = | { kind: "help" | "version" | "doctor" | "cache-clear" } | { kind: "skill"; agents: string[]; global: boolean; yes: boolean } - | { kind: "auth"; provider?: ProviderId } + | { kind: "auth"; provider?: ProviderId; gatewayURL?: string } | { kind: "search"; query: string; @@ -29,6 +29,7 @@ export function parseCommand(args: string[]): Command { version: { type: "boolean" }, stdin: { type: "boolean" }, provider: { type: "string" }, + "gateway-url": { type: "string" }, agent: { type: "string", multiple: true }, global: { type: "boolean" }, yes: { type: "boolean" }, @@ -51,12 +52,23 @@ export function parseCommand(args: string[]): Command { if (values.help || values.version) throw new CliError("Use --help or --version alone."); const first = positionals[0]; if (first === "auth") { - if (positionals.length !== 1 || keys.some((key) => !["stdin", "provider"].includes(key))) + if ( + positionals.length !== 1 || + keys.some((key) => !["stdin", "provider", "gateway-url"].includes(key)) + ) throw new CliError("Usage: jg auth OR jg auth --provider NAME --stdin"); if (!keys.length) return { kind: "auth" }; if (!values.stdin || !isProviderId(values.provider)) - throw new CliError("Use auth --provider vercel|typesafe|openrouter --stdin for a piped key."); - return { kind: "auth", provider: values.provider }; + throw new CliError( + "Use auth --provider vercel|typesafe|openrouter|cloudflare --stdin for a piped key.", + ); + const provider = values.provider; + const gatewayURL = values["gateway-url"]; + if (requiresGatewayURL(provider) !== (gatewayURL !== undefined)) + throw new CliError("Use --gateway-url URL with, and only with, --provider cloudflare."); + return gatewayURL === undefined + ? { kind: "auth", provider } + : { kind: "auth", provider, gatewayURL }; } if (first === "skill") { const agents = values.agent ?? []; @@ -82,7 +94,7 @@ export function parseCommand(args: string[]): Command { !first?.trim() || positionals.length > 2 || values.stdin || - keys.some((key) => ["agent", "global", "yes", "provider"].includes(key)) + keys.some((key) => ["agent", "global", "yes", "provider", "gateway-url"].includes(key)) ) throw new CliError('Usage: jg "question" [root]. Run jg --help.'); const rawBudget = values["max-source-bytes"]; @@ -122,7 +134,10 @@ Commands: Auth automation: auth --provider vercel|typesafe|openrouter --stdin + auth --provider cloudflare --gateway-url URL --stdin Save one provider/key from a pipe. Re-running auth replaces your setup. + Cloudflare URL: https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY + or your gateway's custom domain. Saved credentials only; provider key/URL environment variables are ignored. Skill installation options: diff --git a/apps/cli/src/auth.ts b/apps/cli/src/auth.ts index a7d4aa4..43c7392 100644 --- a/apps/cli/src/auth.ts +++ b/apps/cli/src/auth.ts @@ -2,8 +2,14 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { homedir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; -import { isCancel, password, select } from "@clack/prompts"; -import { providers, isProviderId, type ProviderId } from "@repo/core/providers"; +import { isCancel, password, select, text } from "@clack/prompts"; +import { + isProviderId, + parseGatewayURL, + providers, + requiresGatewayURL, + type ProviderId, +} from "@repo/core/providers"; import { CliError } from "./errors"; export function configDirectory() { @@ -18,11 +24,26 @@ function validateKey(raw: string): string { return key; } -export type Credentials = { provider: ProviderId; apiKey: string }; +export type Credentials = { provider: ProviderId; apiKey: string; gatewayURL?: string }; -export async function authenticate(provider: ProviderId | undefined, signal: AbortSignal) { +function validateGatewayURL(raw: unknown): string { + const url = parseGatewayURL(raw); + if (!url) + throw new CliError( + "Provide your Cloudflare AI Gateway URL, such as https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY, without a query or credentials.", + ); + return url; +} + +export async function authenticate( + provider: ProviderId | undefined, + signal: AbortSignal, + gatewayArgument?: string, +) { let key: string; + let gatewayURL: string | undefined; if (provider !== undefined) { + if (requiresGatewayURL(provider)) gatewayURL = validateGatewayURL(gatewayArgument); const chunks: Buffer[] = []; let bytes = 0; const abort = () => process.stdin.destroy(new DOMException("Interrupted", "AbortError")); @@ -41,7 +62,7 @@ export async function authenticate(provider: ProviderId | undefined, signal: Abo } else { if (!process.stdin.isTTY) throw new CliError( - "Use auth --provider vercel|typesafe|openrouter --stdin to read a piped key.", + "Use auth --provider vercel|typesafe|openrouter|cloudflare --stdin to read a piped key.", ); const selected = await select({ message: "Choose your Jev provider", @@ -54,6 +75,17 @@ export async function authenticate(provider: ProviderId | undefined, signal: Abo }); if (isCancel(selected)) throw new DOMException("Interrupted", "AbortError"); provider = selected; + if (requiresGatewayURL(provider)) { + const url = await text({ + message: "Enter your Cloudflare AI Gateway URL", + placeholder: "https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY", + validate: (value) => (parseGatewayURL(value) ? undefined : "Enter an https gateway URL"), + output: process.stdout, + signal, + }); + if (isCancel(url)) throw new DOMException("Interrupted", "AbortError"); + gatewayURL = validateGatewayURL(url); + } const answer = await password({ message: `Paste your ${providers[provider].label} API key`, output: process.stdout, @@ -70,10 +102,16 @@ export async function authenticate(provider: ProviderId | undefined, signal: Abo await chmod(directory, 0o700); const temporary = join(directory, `.credentials-${randomUUID()}.json`); try { - await writeFile(temporary, JSON.stringify({ provider, apiKey: key }) + "\n", { - mode: 0o600, - flag: "wx", - }); + await writeFile( + temporary, + JSON.stringify( + gatewayURL ? { provider, apiKey: key, gatewayURL } : { provider, apiKey: key }, + ) + "\n", + { + mode: 0o600, + flag: "wx", + }, + ); signal.throwIfAborted(); await rename(temporary, join(directory, "credentials.json")); } finally { @@ -92,6 +130,11 @@ export async function loadCredentials(): Promise { } const provider = Object.hasOwn(credentials, "provider") ? credentials.provider : "vercel"; if (!isProviderId(provider)) throw new CliError("Invalid provider. Run jg auth again."); + if (requiresGatewayURL(provider)) { + const gatewayURL = parseGatewayURL(credentials.gatewayURL); + if (!gatewayURL) throw new CliError("Invalid gateway URL. Run jg auth again."); + return { provider, apiKey: validateKey(credentials.apiKey), gatewayURL }; + } return { provider, apiKey: validateKey(credentials.apiKey) }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 0393439..e1b504f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -47,7 +47,7 @@ async function main() { process.exitCode = await installSkill(command, controller.signal); return; case "auth": - return authenticate(command.provider, controller.signal); + return authenticate(command.provider, controller.signal, command.gatewayURL); case "cache-clear": { const { createEvaluationCache } = await import("@repo/core"); const cache = createEvaluationCache({ directory: cacheDirectory() }); diff --git a/apps/cli/test/args.test.ts b/apps/cli/test/args.test.ts index 389a470..55e1db6 100644 --- a/apps/cli/test/args.test.ts +++ b/apps/cli/test/args.test.ts @@ -55,7 +55,20 @@ test("auth requires explicit provider for stdin and keeps provider selection out kind: "auth", provider: "openrouter", }); + expect( + parseCommand([ + "auth", + "--provider", + "cloudflare", + "--gateway-url", + "https://ai.example.com", + "--stdin", + ]), + ).toEqual({ kind: "auth", provider: "cloudflare", gatewayURL: "https://ai.example.com" }); for (const args of [ + ["auth", "--provider", "cloudflare", "--stdin"], + ["auth", "--provider", "vercel", "--gateway-url", "https://ai.example.com", "--stdin"], + ["query", "--gateway-url", "https://ai.example.com"], ["auth", "--stdin"], ["auth", "--provider", "vercel"], ["auth", "--provider", "unknown", "--stdin"], diff --git a/docs/architecture.md b/docs/architecture.md index c5e9521..8cb6148 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,10 @@ does not guarantee that the coding agent tests the right behavioral boundary. ## Integration and acceptance Use TypeScript and the AI SDK evaluation interface through one TypeSafe-compatible -adapter, with fixed Vercel, TypeSafe and OpenRouter presets. Pass native +adapter, with fixed Vercel, TypeSafe and OpenRouter presets, and a Cloudflare AI Gateway +preset whose account gateway URL is saved by auth. Cloudflare serves Jev on its +Workers AI route, so the evaluator adapts that one request's path, auth header and +result envelope. Pass native `state` and `questions` objects; source is a string field within that data. The SDK handles HTTP serialization. Keep provider authentication and model access at the core boundary so future providers do not reshape traversal or stdout. diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts index ff4475b..dd6e0d9 100644 --- a/packages/core/src/evaluator.ts +++ b/packages/core/src/evaluator.ts @@ -1,6 +1,6 @@ import { APICallError, experimental_evaluate as evaluate } from "ai"; import { createTypeSafeAi } from "@ai-sdk/typesafe-ai"; -import { providers, type ProviderId } from "./providers"; +import { cloudflareRunURL, parseGatewayURL, providers, type ProviderId } from "./providers"; import { type createEvaluationCache, type CacheInput } from "./cache"; import { setTimeout as delay } from "node:timers/promises"; @@ -27,6 +27,8 @@ export class EvaluationFailure extends Error { export function createEvaluator(options: { provider: ProviderId; apiKey: string; + /** Saved Cloudflare AI Gateway base URL; required for, and only used by, cloudflare. */ + gatewayURL?: string; cache?: ReturnType; policyVersion?: string; fetch?: typeof fetch; @@ -35,6 +37,10 @@ export function createEvaluator(options: { timeoutMs?: number; }) { const preset = providers[options.provider]; + const cloudflare = options.provider === "cloudflare"; + const gatewayURL = cloudflare ? parseGatewayURL(options.gatewayURL) : undefined; + if (cloudflare && !gatewayURL) throw new EvaluationFailure("authentication"); + const endpoint = gatewayURL ? cloudflareRunURL(gatewayURL) : preset.baseURL; let requests = 0; let cacheHits = 0; let cooldownUntil = 0; @@ -51,7 +57,10 @@ export function createEvaluator(options: { if (requests >= (options.requestLimit ?? 50_000)) throw new EvaluationFailure("request-limit"); requests++; - const response = await (options.fetch ?? fetch)(input, init); + const transport = options.fetch ?? fetch; + const response = gatewayURL + ? await cloudflareTransport(transport, endpoint, init) + : await transport(input, init); if (response.status === 429) { const raw = response.headers.get("retry-after"); const seconds = raw === null ? NaN : Number(raw); @@ -87,8 +96,8 @@ export function createEvaluator(options: { namespace: { model: preset.model, provider: options.provider, - endpoint: preset.baseURL, - protocol: "typesafe-ai-3.0.8", + endpoint, + protocol: cloudflare ? "typesafe-ai-3.0.8+workers-ai-run" : "typesafe-ai-3.0.8", policyVersion: options.policyVersion ?? "1", parserVersion: "cpython-3.11.3-pyodide-0.25.1-ts-5.9.3", promptVersion: "unit-locators-1", @@ -183,3 +192,36 @@ export function createEvaluator(options: { }, }; } + +/** + * Cloudflare AI Gateway serves Jev as a Workers AI model at /workers-ai/run/typesafe/jev: + * the model comes from the path, the gateway token travels as cf-aig-authorization, and the + * TypeSafe answer is wrapped as {state, result}. Error responses pass through unchanged so + * status handling (401/403, 429, 5xx) stays with the evaluator. + */ +async function cloudflareTransport( + transport: typeof fetch, + endpoint: string, + init: RequestInit | undefined, +): Promise { + const { model: _model, ...input } = JSON.parse(String(init?.body)); + const headers = new Headers(init?.headers); + const authorization = headers.get("authorization"); + headers.delete("authorization"); + if (authorization) headers.set("cf-aig-authorization", authorization); + // Fetch drops Authorization on a cross-origin redirect but would forward cf-aig-authorization. + const response = await transport(endpoint, { + ...init, + headers, + body: JSON.stringify(input), + redirect: "error", + }); + if (!response.ok) return response; + const envelope: unknown = await response.json(); + const answer = + envelope && typeof envelope === "object" && "result" in envelope ? envelope.result : envelope; + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-length"); + responseHeaders.delete("content-encoding"); + return Response.json(answer, { status: response.status, headers: responseHeaders }); +} diff --git a/packages/core/src/providers.ts b/packages/core/src/providers.ts index afd7a55..41d4c42 100644 --- a/packages/core/src/providers.ts +++ b/packages/core/src/providers.ts @@ -14,6 +14,13 @@ export const providers = { baseURL: "https://openrouter.ai/api/v1", model: "jev-1.13", }, + // Cloudflare serves Jev as a Workers AI model; each account has its own gateway URL, + // which auth saves with the token. The URL below is only the SDK's placeholder base. + cloudflare: { + label: "Cloudflare AI Gateway", + baseURL: "https://gateway.ai.cloudflare.com", + model: "typesafe/jev", + }, } as const; export type ProviderId = keyof typeof providers; @@ -21,3 +28,30 @@ export type ProviderId = keyof typeof providers; export function isProviderId(value: unknown): value is ProviderId { return typeof value === "string" && Object.hasOwn(providers, value); } + +export function requiresGatewayURL(provider: ProviderId): provider is "cloudflare" { + return provider === "cloudflare"; +} + +/** + * Normalizes a Cloudflare AI Gateway base URL, either + * https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY or a custom gateway domain. + * Plain HTTP is accepted only for loopback hosts. Returns undefined when invalid. + */ +export function parseGatewayURL(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw.trim() || raw.length > 2048) return undefined; + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return undefined; + } + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return undefined; + if (url.username || url.password || url.search || url.hash) return undefined; + return url.href.replace(/\/+$/, ""); +} + +export function cloudflareRunURL(gatewayURL: string) { + return `${gatewayURL}/workers-ai/run/${providers.cloudflare.model}`; +} diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index e5c609b..cf179ab 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -1,6 +1,7 @@ import { routeProviderFetch } from "./fixtures/provider-route.mjs"; import { expect, test } from "bun:test"; import { createEvaluator } from "../packages/core/src/evaluator"; +import { parseGatewayURL } from "../packages/core/src/providers"; test("Jev uses native state and validated boolean probabilities through real HTTP", async () => { const state = { @@ -325,3 +326,136 @@ test("native HTTP-date Retry-After is honored before another attempt", async () server.stop(true); } }, 5000); + +test("Cloudflare AI Gateway uses the Workers AI route, gateway auth and unwraps the result", async () => { + const seen: { path: string; auth: string | null; gatewayAuth: string | null; body: unknown }[] = + []; + const server = Bun.serve({ + port: 0, + async fetch(request) { + seen.push({ + path: new URL(request.url).pathname, + auth: request.headers.get("authorization"), + gatewayAuth: request.headers.get("cf-aig-authorization"), + body: await request.json(), + }); + return Response.json({ + state: "Completed", + result: { model: "jev-1.13.0", answers: { useful: { type: "noul", noul: 0.7 } } }, + }); + }, + }); + try { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + gatewayURL: `http://127.0.0.1:${server.port}/v1/account/gateway/`, + signal: new AbortController().signal, + }); + const result = await evaluator.evaluate({ + state: "recordEvent()", + questions: { useful: { type: "boolean", instructions: "Is the source useful?" } }, + }); + expect(result).toEqual({ useful: 0.7 }); + expect(seen).toEqual([ + { + path: "/v1/account/gateway/workers-ai/run/typesafe/jev", + auth: null, + gatewayAuth: "Bearer cf-token", + body: { + state: "recordEvent()", + questions: { useful: { type: "noul", instructions: "Is the source useful?" } }, + }, + }, + ]); + } finally { + server.stop(true); + } +}); + +test("Cloudflare gateway rejection is an authentication failure, and a missing URL never calls out", async () => { + let calls = 0; + const server = Bun.serve({ + port: 0, + fetch() { + calls++; + return Response.json({ success: false, errors: [{ code: 10000 }] }, { status: 401 }); + }, + }); + const request = { + state: "test", + questions: { q: { type: "boolean" as const, instructions: "Relevant?" } }, + }; + try { + const evaluator = createEvaluator({ + apiKey: "bad", + provider: "cloudflare", + gatewayURL: `http://127.0.0.1:${server.port}`, + signal: new AbortController().signal, + }); + await expect(evaluator.evaluate(request)).rejects.toMatchObject({ kind: "authentication" }); + expect(calls).toBe(1); + for (const gatewayURL of [undefined, "http://gateway.example/v1/a/g"]) + expect(() => + createEvaluator({ + apiKey: "x", + provider: "cloudflare", + gatewayURL, + signal: new AbortController().signal, + }), + ).toThrow(); + expect(calls).toBe(1); + } finally { + server.stop(true); + } +}); + +test("gateway URLs must be https (or loopback http) without query, fragment or credentials", () => { + expect(parseGatewayURL("https://gateway.ai.cloudflare.com/v1/acct/gw/")).toBe( + "https://gateway.ai.cloudflare.com/v1/acct/gw", + ); + expect(parseGatewayURL("https://ai.example.com")).toBe("https://ai.example.com"); + for (const raw of [ + "", + "gateway.ai.cloudflare.com/v1/acct/gw", + "http://ai.example.com", + "https://ai.example.com/?x=1", + "https://ai.example.com/#x", + "https://user:pass@ai.example.com", + "ftp://ai.example.com", + ]) + expect(parseGatewayURL(raw)).toBeUndefined(); +}); + +test("Cloudflare transport never follows a redirect with the gateway token", async () => { + let leaked = 0; + const target = Bun.serve({ + port: 0, + fetch() { + leaked++; + return Response.json({ result: { answers: { q: { type: "noul", noul: 0.9 } } } }); + }, + }); + const gateway = Bun.serve({ + port: 0, + fetch: () => Response.redirect(`http://127.0.0.1:${target.port}/elsewhere`, 307), + }); + try { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + gatewayURL: `http://127.0.0.1:${gateway.port}`, + signal: new AbortController().signal, + }); + await expect( + evaluator.evaluate({ + state: "test", + questions: { q: { type: "boolean", instructions: "Relevant?" } }, + }), + ).rejects.toMatchObject({ kind: "provider" }); + expect(leaked).toBe(0); + } finally { + gateway.stop(true); + target.stop(true); + } +}); From 7acfadd785e8e92b0bf24aab2d649e9bc187ace6 Mon Sep 17 00:00:00 2001 From: Rob Knight <12484127+rknightion@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:55:39 +0100 Subject: [PATCH 2/5] test: cover Cloudflare in replay, installed journeys and auth; record the spec Adds cloudflare to the reference transport and provider replay, the installed saved-provider journey, conflicting-environment and invalid-record cases, and the interactive auth prompt, all through a canonical fixture gateway routed by the test-only preload. Only a {state: "Completed", result} envelope yields an answer. specs/done/cloudflare-provider records the route evidence, choices and verification. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016dpmZNjz5QApmRBSwvz2Ne --- apps/cli/test/process.test.ts | 28 +++++-- docs/architecture.md | 4 +- packages/core/src/evaluator.ts | 10 ++- specs/done/cloudflare-provider/README.md | 77 +++++++++++++++++++ .../assets/verification.md | 27 +++++++ specs/done/cloudflare-provider/choices.md | 32 ++++++++ specs/done/cloudflare-provider/research.md | 31 ++++++++ test/evaluator.test.ts | 30 ++++++++ test/fixtures/provider-route.mjs | 1 + test/installed.test.mjs | 60 +++++++++++---- test/reference/replay.ts | 5 +- test/reference/transport.test.ts | 11 ++- test/reference/transport.ts | 50 ++++++++---- 13 files changed, 321 insertions(+), 45 deletions(-) create mode 100644 specs/done/cloudflare-provider/README.md create mode 100644 specs/done/cloudflare-provider/assets/verification.md create mode 100644 specs/done/cloudflare-provider/choices.md create mode 100644 specs/done/cloudflare-provider/research.md diff --git a/apps/cli/test/process.test.ts b/apps/cli/test/process.test.ts index d412095..f6e4e83 100644 --- a/apps/cli/test/process.test.ts +++ b/apps/cli/test/process.test.ts @@ -42,13 +42,15 @@ testInDocker("interactive auth hides input and exits 130 on interruption", async await withCli(async ({ home }) => { for (const mode of [ "save", + "save-cloudflare", "interrupt-provider", "interrupt-key", "cancel-provider", "cancel-key", ]) { const file = join(home, "jevgrep", "credentials.json"); - const previous = mode === "save" ? undefined : await readFile(file, "utf8"); + const saves = mode.startsWith("save"); + const previous = saves ? undefined : await readFile(file, "utf8"); const { stdout } = await execute( "python3", [ @@ -72,10 +74,15 @@ try: output += part wait_for(b"Choose your Jev provider") mode = sys.argv[2] - if not mode.endswith("provider"): + if mode == "save-cloudflare": + os.write(master, b"\\x1b[B\\x1b[B\\x1b[B\\r") + wait_for(b"Cloudflare AI Gateway URL") + os.write(master, b"https://gateway.ai.cloudflare.com/v1/pty-account/pty-gateway\\r") + wait_for(b"API key") + elif not mode.endswith("provider"): os.write(master, b"\\x1b[B\\x1b[B\\r") wait_for(b"API key") - if mode == "save": + if mode.startswith("save"): os.write(master, b"pty-fixture-secret\\r") elif mode.startswith("cancel"): os.write(master, b"\\x03") @@ -103,11 +110,20 @@ finally: { env: { ...process.env, HOME: home, XDG_CONFIG_HOME: home } }, ); const result = JSON.parse(stdout); - expect(result.code).toBe(mode === "save" ? 0 : 130); + expect(result.code).toBe(saves ? 0 : 130); expect(result.stderr).toBe(""); expect(result.stdout + result.echo).not.toContain("pty-fixture-secret"); - expect(result.stdout).toContain(mode === "save" ? "key saved" : "Interrupted"); - if (mode === "save") { + expect(result.stdout).toContain(saves ? "key saved" : "Interrupted"); + if (mode === "save-cloudflare") { + expect(JSON.parse(await readFile(file, "utf8"))).toEqual({ + provider: "cloudflare", + apiKey: "pty-fixture-secret", + gatewayURL: "https://gateway.ai.cloudflare.com/v1/pty-account/pty-gateway", + }); + expect(result.stdout.indexOf("OpenRouter")).toBeLessThan( + result.stdout.indexOf("Cloudflare AI Gateway"), + ); + } else if (mode === "save") { expect(JSON.parse(await readFile(file, "utf8"))).toEqual({ provider: "openrouter", apiKey: "pty-fixture-secret", diff --git a/docs/architecture.md b/docs/architecture.md index 8cb6148..0e1f627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,7 +107,9 @@ The CLI auth module owns one saved provider/key. The core preset owner resolves its endpoint/model; the evaluator owns retries and answer-cache identity. Provider selection never changes traversal or source rendering. See the [provider support record](../specs/done/provider-support/README.md) for the setup -constraints and transport preservation evidence. +constraints and transport preservation evidence, and the +[Cloudflare provider record](../specs/done/cloudflare-provider/README.md) for its +gateway URL and Workers AI route. Acceptance measures the entire downstream Sol task: an official solve at a lower full task cost than its fixed baseline, while preserving baseline solves. Jev diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts index dd6e0d9..c6e7a8b 100644 --- a/packages/core/src/evaluator.ts +++ b/packages/core/src/evaluator.ts @@ -196,7 +196,7 @@ export function createEvaluator(options: { /** * Cloudflare AI Gateway serves Jev as a Workers AI model at /workers-ai/run/typesafe/jev: * the model comes from the path, the gateway token travels as cf-aig-authorization, and the - * TypeSafe answer is wrapped as {state, result}. Error responses pass through unchanged so + * TypeSafe answer is wrapped as {state: "Completed", result}. Error responses pass through unchanged so * status handling (401/403, 429, 5xx) stays with the evaluator. */ async function cloudflareTransport( @@ -218,8 +218,12 @@ async function cloudflareTransport( }); if (!response.ok) return response; const envelope: unknown = await response.json(); - const answer = - envelope && typeof envelope === "object" && "result" in envelope ? envelope.result : envelope; + // Only a Completed envelope carries an answer; anything else fails answer validation. + const completed = + envelope && typeof envelope === "object" && "state" in envelope && "result" in envelope + ? envelope.state === "Completed" + : false; + const answer = completed ? (envelope as { result: unknown }).result : null; const responseHeaders = new Headers(response.headers); responseHeaders.delete("content-length"); responseHeaders.delete("content-encoding"); diff --git a/specs/done/cloudflare-provider/README.md b/specs/done/cloudflare-provider/README.md new file mode 100644 index 0000000..a5eda98 --- /dev/null +++ b/specs/done/cloudflare-provider/README.md @@ -0,0 +1,77 @@ +# Cloudflare AI Gateway provider + +Jevgrep supports Cloudflare AI Gateway as a fourth saved provider, next to +Vercel AI Gateway, native TypeSafe and OpenRouter. It follows the +[saved-provider record](../provider-support/README.md) and keeps every invariant +listed there. This record covers only what Cloudflare needs beyond that. + +## Why it is shaped this way + +Cloudflare serves Jev (`typesafe/jev`) with Unified Billing, but only on the +gateway's Workers AI route, `/workers-ai/run/typesafe/jev`. There is no +TypeSafe provider-native path. The probes in [research](research.md) record the +routes that fail. So Cloudflare cannot be one more fixed base URL for the same +adapter. It needs two things the other presets do not: + +- **An account-specific endpoint.** The gateway URL contains the account and gateway + IDs, or it is the gateway's custom domain. `jg auth` saves it with the token in the + same single private record. Search and doctor still take no endpoint, provider or + environment override. The URL is setup, like the key. `createEvaluator` itself throws + for a Cloudflare provider without a valid URL, before any request. The CLI validates + the saved record first, so users see the auth message instead. +- **A different wire shape for one request.** The route takes the model from its + path, authenticates the gateway token from `cf-aig-authorization`, and wraps the + TypeSafe answer as `{state, result}`. The evaluator adapts that single request + inside its existing fetch hook. The SDK adapter, question mapping, answer + validation, retries, cooldown, attempt guard and error classification stay the + same as for the other presets. + +## What must stay true + +- Only a Cloudflare record carries `gatewayURL`, and a Cloudflare record without a + valid URL is invalid credentials. It never falls back to another provider. Other + providers' records are unchanged and still accept a legacy record without a provider. +- The gateway URL must be `https`. Plain `http` is accepted only for loopback hosts. + The URL can't carry a query, fragment or userinfo, or exceed 2,048 characters. Auth + normalizes a trailing slash away. +- The token travels only as `cf-aig-authorization`. The request omits `Authorization`, + and redirects are refused, because fetch would drop `Authorization` on a + cross-origin redirect but would forward the custom header. +- Error responses pass through unchanged, so 401/403 still aborts as an + authentication failure and 408/429/5xx keep their retry semantics. Only a success + body of `{state: "Completed", result}` yields an answer. Any other state, a missing + `result` or a bare unwrapped answer fails validation as an invalid answer. It is never + treated as negative evidence. +- Answer-cache identity uses the full Workers AI endpoint and a distinct protocol tag + (`typesafe-ai-3.0.8+workers-ai-run`). Answers from another provider or another + gateway never certify this route. + +## Pointers + +The [preset owner](../../../packages/core/src/providers.ts) holds the preset, +`parseGatewayURL` and `cloudflareRunURL`. The +[evaluator](../../../packages/core/src/evaluator.ts) holds `cloudflareTransport`, +and the [auth module](../../../apps/cli/src/auth.ts) and +[argument parser](../../../apps/cli/src/args.ts) own `--gateway-url`. + +Tests route through a canonical fixture gateway, +`https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway`, which the +[test-only preload](../../../test/fixtures/provider-route.mjs) redirects to local +HTTP. The product has no transport override. +[Provider replay](../../../test/reference/provider-parity.test.ts) pins the frozen +request multiset and complete stdout for Cloudflare. +[Installed journeys](../../../test/installed.test.mjs) run auth, doctor, search, +cache and replacement against the packed CLI. +[Evaluator tests](../../../test/evaluator.test.ts) pin the route, headers, body, +unwrapping, non-Completed envelopes, authentication failure, refused redirects and +URL validation, and +[process tests](../../../apps/cli/test/process.test.ts) cover the interactive +gateway prompt. + +## Evidence and limits + +[Verification](assets/verification.md) records the gates and the live checks +against a real gateway. The live checks prove transport through Cloudflare's +route. They do not establish identical probabilities to other providers, solve +quality, cost savings or latency claims. [Choices](choices.md) records the +decisions the implementation made. diff --git a/specs/done/cloudflare-provider/assets/verification.md b/specs/done/cloudflare-provider/assets/verification.md new file mode 100644 index 0000000..45350aa --- /dev/null +++ b/specs/done/cloudflare-provider/assets/verification.md @@ -0,0 +1,27 @@ +# Cloudflare provider verification + +2026-09-27, macOS arm64, Node v26.9.0, bun 1.4.2. No npm release or paid benchmark was +run. The reference manifest, covered files, corpus and frozen research skill are +unchanged. + +| Gate | Result | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `bun run verify` | Exit 0: types, lint, 132 core/CLI/reference, 18 Node protocol, 23 parser, 4 release, 27 maintained harness and 33 installed Docker tests | +| `bun run format:check` | Exit 0 | +| After the Completed-envelope change | `bun run test` exit 0 (133 core/CLI/reference, 18 protocol, 23 parser, 4 release, 27 harness); installed cloudflare, saved-credential, invalid-record, legacy and authentication cases 11/11; live doctor verified | +| Installed journey `cloudflare` | Pass: auth with `--gateway-url`, doctor, cold and warm search, replacement by Vercel, unauthorized doctor without retry | +| Provider replay `cloudflare` | Pass: frozen request multiset and complete stdout; missing and invalid answers stay incomplete | +| Evaluator mutation checks | Removing the envelope unwrap or `redirect: "error"` fails the matching evaluator test; accepting a `Queued` envelope failed the Completed-only test before it was enforced | + +Live checks used the development CLI with an isolated `XDG_CONFIG_HOME` and a token +scoped to Account > AI Gateway Run only: + +| Check | Result | +| ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `jg auth --provider cloudflare --gateway-url --stdin`, `jg doctor` | Saved; `Jev connection verified through Cloudflare AI Gateway.` | +| Same with `https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` | Saved; doctor verified | +| Doctor with an invalid token | Exit 1, connection check failed message | +| Search over `apps` for the gateway URL auth flow | Exit 0 in 5.6s; `cli/src/auth.ts` first with its declarations | + +A live check proves the route and authentication, not probability equality with +other providers, solve quality or cost. diff --git a/specs/done/cloudflare-provider/choices.md b/specs/done/cloudflare-provider/choices.md new file mode 100644 index 0000000..df0b467 --- /dev/null +++ b/specs/done/cloudflare-provider/choices.md @@ -0,0 +1,32 @@ +# Cloudflare provider choices + +## Sound: high confidence + +### The gateway URL is saved by auth, not supplied per search + +The provider-support record rules out endpoint overrides on search and doctor. +A Cloudflare gateway URL is account-specific, so some input is unavoidable. Saving +it in the one auth record keeps the rule intact: a human chooses provider, key and +gateway once, and agents search without routing decisions. An environment variable +or a search flag would reopen the precedence questions that record closed. + +### Adapt the request in the evaluator's fetch hook + +The alternatives were a separate evaluation model for Cloudflare or an unpublished +change to the TypeSafe SDK adapter. Adapting the one request inside the existing +fetch hook keeps a single adapter. It also keeps retries, the attempt guard and +answer validation on one path. The adaptation is limited to destination, auth +header, the `model` field and the envelope. + +### `cf-aig-authorization` only, redirects refused + +Both headers authenticated during research. The gateway header is the one +Cloudflare documents for gateway authentication and Unified Billing, so the token +is sent once, under that name. Refusing redirects closes the one case where fetch +forwards a custom header across origins. + +## Within the specification + +Loopback `http` exists only so tests and local proxies can reach a fixture. Tests +still use a canonical `https` gateway URL through the test-only preload wherever the +packed CLI is exercised. diff --git a/specs/done/cloudflare-provider/research.md b/specs/done/cloudflare-provider/research.md new file mode 100644 index 0000000..cf945b9 --- /dev/null +++ b/specs/done/cloudflare-provider/research.md @@ -0,0 +1,31 @@ +# Cloudflare route evidence + +Inspected 2026-09-27 against a live gateway with Unified Billing and a stored +TypeSafe BYOK key. A token scoped to Account > AI Gateway Run only was used. These are +live observations, recorded here because Cloudflare's provider list does not +document a TypeSafe route. + +Cloudflare's model page documents Jev as `typesafe/jev`, with `state` and +`questions` as input and `{model, answers, usage}` as output +([Cloudflare Jev model](https://developers.cloudflare.com/ai/models/typesafe/jev/)). +The [provider-native list](https://developers.cloudflare.com/ai-gateway/usage/providers/) +has no TypeSafe entry. + +| Request (POST, gateway token) | Result | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `/workers-ai/run/typesafe/jev`, body `{state, questions}` | 200, `{"state":"Completed","result":{model, answers, usage}}` | +| Same, token as `Authorization` or as `cf-aig-authorization` | Both 200 | +| Same, custom domain or `gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` | Both 200 | +| Same, with `cf-aig-byok-alias: default` | 200, `gatewayMetadata.keySource` still `Unified` | +| `/typesafe/v1/systemone` and `/typesafe/systemone` | 400, `2008 Invalid provider` (either header) | +| `typesafe-ai`, `typesafeai`, `type-safe` path slugs | 400, `2008 Invalid provider` | +| `/compat/chat/completions`, models `typesafe/jev`, `typesafe/jev-1.13.0`, `typesafe/typesafe/jev` | 400, `2008 Invalid provider` | +| `/compat/chat/completions`, model `workers-ai/typesafe/jev` | `7003`: reaches Jev, rejects `messages`, requires `questions` | +| Universal endpoint, provider `typesafe` | 400, `2008 Invalid provider` | +| `/workers-ai/typesafe/jev` (no `run`) | `7003`/`7000`: no route for that URI | +| `/workers-ai/run`, model in body | `10000 Authentication error` | + +The SDK adapter posts `{model, state, questions}` to `/systemone` with `noul` +question types, and its response schema accepts the unwrapped `result`. The +adapter therefore keeps its question mapping and validation. Only the destination, +auth header, model field and envelope differ. diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index cf179ab..4d83a63 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -459,3 +459,33 @@ test("Cloudflare transport never follows a redirect with the gateway token", asy target.stop(true); } }); + +test("Cloudflare answers count only from a Completed envelope, never as negative evidence", async () => { + const bodies = [ + { state: "Queued", result: { answers: { q: { type: "noul", noul: 0.9 } } } }, + { state: "Completed" }, + { answers: { q: { type: "noul", noul: 0.9 } } }, + ]; + let calls = 0; + const server = Bun.serve({ port: 0, fetch: () => Response.json(bodies[calls++]) }); + try { + for (const _ of bodies) { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + gatewayURL: `http://127.0.0.1:${server.port}`, + signal: new AbortController().signal, + requestLimit: 1, + }); + await expect( + evaluator.evaluate({ + state: "test", + questions: { q: { type: "boolean", instructions: "Relevant?" } }, + }), + ).rejects.toBeInstanceOf(Error); + } + expect(calls).toBe(3); + } finally { + server.stop(true); + } +}); diff --git a/test/fixtures/provider-route.mjs b/test/fixtures/provider-route.mjs index e8a3e8b..a62be26 100644 --- a/test/fixtures/provider-route.mjs +++ b/test/fixtures/provider-route.mjs @@ -3,6 +3,7 @@ const endpoints = new Set([ "https://ai-gateway.vercel.sh/typesafe/v1/systemone", "https://api.typesafe.ai/v1/systemone", "https://openrouter.ai/api/v1/systemone", + "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway/workers-ai/run/typesafe/jev", ]); export function routeProviderFetch(originalFetch, origin) { return async (input, init) => { diff --git a/test/installed.test.mjs b/test/installed.test.mjs index 77484f7..317beee 100644 --- a/test/installed.test.mjs +++ b/test/installed.test.mjs @@ -41,6 +41,12 @@ const providers = { url: "https://openrouter.ai/api/v1/systemone", model: "jev-1.13", }, + cloudflare: { + label: "Cloudflare AI Gateway", + url: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway/workers-ai/run/typesafe/jev", + model: "typesafe/jev", + gatewayURL: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway", + }, }; const fixtureKey = "installed-http-fixture-key"; const forbidden = "INSTALLED_FIXTURE_IGNORED_CONTENT_MUST_NEVER_UPLOAD"; @@ -113,10 +119,14 @@ async function context(t, mode = "healthy", executable = binary) { const preset = providers[expectedProvider]; assert.equal(request.url, new URL(preset.url).pathname); assert.equal(request.headers["x-jevgrep-original-url"], preset.url); + // Cloudflare's Workers AI route takes the gateway token and the model from its path. + const cloudflare = expectedProvider === "cloudflare"; assert.ok( - request.headers.authorization === `Bearer ${expectedKey}`, + request.headers[cloudflare ? "cf-aig-authorization" : "authorization"] === + `Bearer ${expectedKey}`, "Saved key must authenticate the request", ); + if (cloudflare) assert.equal(request.headers.authorization, undefined); assert.match(request.headers["content-type"] ?? "", /^application\/json/); const chunks = []; let bytes = 0; @@ -128,8 +138,11 @@ async function context(t, mode = "healthy", executable = binary) { const raw = Buffer.concat(chunks).toString("utf8"); assert.ok(!raw.includes(forbidden), "Ignored/hidden source reached the provider"); const body = JSON.parse(raw); - assert.deepEqual(Object.keys(body).sort(), ["model", "questions", "state"]); - assert.equal(body.model, preset.model); + assert.deepEqual( + Object.keys(body).sort(), + cloudflare ? ["questions", "state"] : ["model", "questions", "state"], + ); + if (!cloudflare) assert.equal(body.model, preset.model); assert.equal( typeof body.state, "object", @@ -211,15 +224,14 @@ async function context(t, mode = "healthy", executable = binary) { const ids = Object.keys(body.questions); assert.equal(probabilities.length, ids.length); response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - answers: Object.fromEntries( - ids.map((id, index) => [id, { type: "noul", noul: probabilities[index] }]), - ), - usage: { input_tokens: 1, output_tokens: 1 }, - warnings: [{ type: "other", message: "installed-fixture-warning" }], - }), - ); + const answer = { + answers: Object.fromEntries( + ids.map((id, index) => [id, { type: "noul", noul: probabilities[index] }]), + ), + usage: { input_tokens: 1, output_tokens: 1 }, + warnings: [{ type: "other", message: "installed-fixture-warning" }], + }; + response.end(JSON.stringify(cloudflare ? { state: "Completed", result: answer } : answer)); })().catch((error) => { protocolErrors.push(error.message); response.writeHead(400, { "content-type": "application/json" }); @@ -904,8 +916,9 @@ for (const [provider, preset] of Object.entries(providers)) await fixture.removeCredentials(); const key = `installed-${provider}-saved-key`; fixture.expectProvider(provider, key); + const gateway = preset.gatewayURL ? ["--gateway-url", preset.gatewayURL] : []; const auth = await fixture.run( - ["auth", "--provider", provider, "--stdin"], + ["auth", "--provider", provider, ...gateway, "--stdin"], {}, false, key + "\n", @@ -917,6 +930,7 @@ for (const [provider, preset] of Object.entries(providers)) assert.deepEqual(JSON.parse(await readFile(fixture.credentials, "utf8")), { provider, apiKey: key, + ...(preset.gatewayURL ? { gatewayURL: preset.gatewayURL } : {}), }); assert.equal((await stat(fixture.credentials)).mode & 0o777, 0o600); assert.equal((await stat(fixture.credentialDirectory)).mode & 0o777, 0o700); @@ -1005,9 +1019,16 @@ test("installed saved credentials defeat conflicting environment and environment AI_GATEWAY_MODEL: "environment-model", TYPESAFE_MODEL: "environment-model", OPENROUTER_MODEL: "environment-model", + CLOUDFLARE_API_TOKEN: "environment-cloudflare-key", + CF_AIG_TOKEN: "environment-cloudflare-gateway-key", + CLOUDFLARE_GATEWAY_URL: "http://127.0.0.1:1/forbidden", }; for (const provider of Object.keys(providers)) { - await writeFile(fixture.credentials, JSON.stringify({ provider, apiKey: fixtureKey })); + const gatewayURL = providers[provider].gatewayURL; + await writeFile( + fixture.credentials, + JSON.stringify({ provider, apiKey: fixtureKey, ...(gatewayURL ? { gatewayURL } : {}) }), + ); fixture.expectProvider(provider); const doctor = await fixture.run(["doctor"], conflicts); assert.equal(doctor.code, 0, doctor.stdout); @@ -1027,8 +1048,15 @@ test("installed saved credentials defeat conflicting environment and environment test("installed invalid saved providers fail before HTTP without rewriting credentials", async (t) => { const fixture = await context(t); - for (const provider of [null, "", "unknown", false, 0, {}, []]) { - const original = JSON.stringify({ provider, apiKey: fixtureKey }) + "\n"; + const records = [null, "", "unknown", false, 0, {}, []].map((provider) => ({ + provider, + apiKey: fixtureKey, + })); + // A Cloudflare record is only valid with its https gateway URL; it never falls back. + for (const gatewayURL of [undefined, "", "http://gateway.example/v1/a/g", "not a url"]) + records.push({ provider: "cloudflare", apiKey: fixtureKey, gatewayURL }); + for (const record of records) { + const original = JSON.stringify(record) + "\n"; await writeFile(fixture.credentials, original); for (const args of [["doctor"], [query]]) { const result = await fixture.run(args); diff --git a/test/reference/replay.ts b/test/reference/replay.ts index 05b6a68..577f9d5 100644 --- a/test/reference/replay.ts +++ b/test/reference/replay.ts @@ -21,7 +21,8 @@ export async function replay( questions: Record; }; requests.push(body); - if (mode === "missing") return Response.json(wireResponse({ answers: {} }, production)); + if (mode === "missing") + return Response.json(wireResponse({ answers: {} }, production, provider)); if (mode === "invalid") return Response.json( wireResponse( @@ -31,6 +32,7 @@ export async function replay( ), }, production, + provider, ), ); return Response.json( @@ -57,6 +59,7 @@ export async function replay( warnings: [{ type: "other", message: "fixture warning" }], }, production, + provider, ), ); }, diff --git a/test/reference/transport.test.ts b/test/reference/transport.test.ts index 793ad91..5f2d416 100644 --- a/test/reference/transport.test.ts +++ b/test/reference/transport.test.ts @@ -8,10 +8,12 @@ test("wire normalization retains every corpus field and rejects unknown envelope const transport = await referenceTransport(provider); try { const preset = providers[provider]; + // Cloudflare's Workers AI route authenticates the gateway header and has no body model. + const cloudflare = provider === "cloudflare"; const request = new Request(preset.url, { method: "POST", headers: { - authorization: "Bearer fixture", + [cloudflare ? "cf-aig-authorization" : "authorization"]: "Bearer fixture", "content-type": "application/json", "x-jevgrep-original-url": preset.url, }, @@ -28,7 +30,7 @@ test("wire normalization retains every corpus field and rejects unknown envelope }); expect(JSON.stringify(transport.decode(historical, original, false))).toBe(serialized); const wire = { - model: preset.model, + ...(cloudflare ? {} : { model: preset.model }), state: original.state, questions: Object.fromEntries( Object.entries(original.questions).map(([id, question]) => [ @@ -54,6 +56,11 @@ test("wire normalization retains every corpus field and rejects unknown envelope ), ).toThrow(); expect(() => transport.decode(request, { ...wire, model: "wrong-model" }, true)).toThrow(); + if (cloudflare) { + const bearer = new Request(request); + bearer.headers.set("authorization", "Bearer fixture"); + expect(() => transport.decode(bearer, wire, true)).toThrow(); + } } } finally { await transport.cleanup(); diff --git a/test/reference/transport.ts b/test/reference/transport.ts index 44596a6..4fd1a4d 100644 --- a/test/reference/transport.ts +++ b/test/reference/transport.ts @@ -8,6 +8,11 @@ export const providers = { vercel: { url: "https://ai-gateway.vercel.sh/typesafe/v1/systemone", model: "typesafe-ai/jev" }, typesafe: { url: "https://api.typesafe.ai/v1/systemone", model: "jev-1.13.0" }, openrouter: { url: "https://openrouter.ai/api/v1/systemone", model: "jev-1.13" }, + cloudflare: { + url: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway/workers-ai/run/typesafe/jev", + model: "typesafe/jev", + gatewayURL: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway", + }, } as const; export type Provider = keyof typeof providers; @@ -16,7 +21,11 @@ export async function referenceTransport(provider: Provider = "vercel", key = "f await mkdir(join(home, "jevgrep")); await writeFile( join(home, "jevgrep/credentials.json"), - JSON.stringify({ provider, apiKey: key }), + JSON.stringify( + provider === "cloudflare" + ? { provider, apiKey: key, gatewayURL: providers.cloudflare.gatewayURL } + : { provider, apiKey: key }, + ), ); return { env(production: boolean, origin: string) { @@ -40,7 +49,12 @@ export async function referenceTransport(provider: Provider = "vercel", key = "f ) { expect(request.method).toBe("POST"); expect(request.headers.get("content-type")).toMatch(/^application\/json(?:;|$)/); - expect(request.headers.get("authorization")).toBe(`Bearer ${key}`); + // Cloudflare's Workers AI route takes the gateway token and the model from its path. + const cloudflare = production && provider === "cloudflare"; + expect(request.headers.get(cloudflare ? "cf-aig-authorization" : "authorization")).toBe( + `Bearer ${key}`, + ); + if (cloudflare) expect(request.headers.get("authorization")).toBeNull(); const body = raw as { model?: string; providerOptions?: Record; @@ -48,14 +62,18 @@ export async function referenceTransport(provider: Provider = "vercel", key = "f questions: Record; }; expect(Object.keys(body).sort()).toEqual( - production ? ["model", "questions", "state"] : ["providerOptions", "questions", "state"], + cloudflare + ? ["questions", "state"] + : production + ? ["model", "questions", "state"] + : ["providerOptions", "questions", "state"], ); expect(new URL(request.url).pathname).toBe( production ? new URL(providers[provider].url).pathname : "/v4/ai/evaluation-model", ); if (production) { expect(request.headers.get("x-jevgrep-original-url")).toBe(providers[provider].url); - expect(body.model).toBe(providers[provider].model); + if (!cloudflare) expect(body.model).toBe(providers[provider].model); } else { expect(request.headers.get("ai-model-id")).toBe("typesafe-ai/jev"); expect(body.providerOptions).toEqual({}); @@ -79,16 +97,16 @@ export async function referenceTransport(provider: Provider = "vercel", key = "f export function wireResponse< T extends { answers: Record }, ->(body: T, production: boolean) { - return production - ? { - ...body, - answers: Object.fromEntries( - Object.entries(body.answers).map(([id, answer]) => [ - id, - { type: "noul", noul: answer.probability }, - ]), - ), - } - : body; +>(body: T, production: boolean, provider: Provider = "vercel") { + if (!production) return body; + const native = { + ...body, + answers: Object.fromEntries( + Object.entries(body.answers).map(([id, answer]) => [ + id, + { type: "noul", noul: answer.probability }, + ]), + ), + }; + return provider === "cloudflare" ? { state: "Completed", result: native } : native; } From 0521143bc76078e991948696c6d931c523cdfdf2 Mon Sep 17 00:00:00 2001 From: Rob Knight <12484127+rknightion@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:15:24 +0100 Subject: [PATCH 3/5] docs: record the Workers AI rate limit seen on Cloudflare's Jev route Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016dpmZNjz5QApmRBSwvz2Ne --- specs/done/cloudflare-provider/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/specs/done/cloudflare-provider/README.md b/specs/done/cloudflare-provider/README.md index a5eda98..d6099ae 100644 --- a/specs/done/cloudflare-provider/README.md +++ b/specs/done/cloudflare-provider/README.md @@ -73,5 +73,12 @@ gateway prompt. [Verification](assets/verification.md) records the gates and the live checks against a real gateway. The live checks prove transport through Cloudflare's route. They do not establish identical probabilities to other providers, solve -quality, cost savings or latency claims. [Choices](choices.md) records the +quality, cost savings or latency claims. + +Workers AI applies its own rate limit to `typesafe/jev`, separate from any gateway +rate limit. One live search received 42 HTTP 429 responses (`Rate limited`, no +`retry-after`) and reported incomplete discovery. Three back-to-back uncached +repeats of that search completed with no 429s. The existing shared cooldown and +bounded retries handle it; a throttled run is reported as incomplete, never as +negative evidence. [Choices](choices.md) records the decisions the implementation made. From 5c77d7f821716ebf2c0736754c26a3e678d14fe4 Mon Sep 17 00:00:00 2001 From: Rob Knight <12484127+rknightion@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:36:27 +0100 Subject: [PATCH 4/5] Re-port Cloudflare AI Gateway onto the custom endpoint seams Cloudflare becomes a credential provider beside custom: its record is {provider, baseURL, apiKey}, auth takes the gateway URL through the shared --base-url flag and validateBaseURL, and endpointFor resolves it. The evaluator still adapts the one request to the Workers AI route (cf-aig-authorization, no model field, Completed envelope, no redirects). A gateway URL that already carries a /workers-ai or /compat route is rejected instead of doubled. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UsM3MRh2GKBUmaPwA5E322 --- README.md | 2 +- apps/cli/README.md | 11 ++ apps/cli/src/args.ts | 27 +++- apps/cli/src/auth.ts | 53 ++++++- apps/cli/test/args.test.ts | 32 ++++ apps/cli/test/process.test.ts | 21 ++- packages/core/src/evaluator.ts | 57 ++++++- packages/core/src/providers.ts | 42 ++++- packages/core/test/providers.test.ts | 26 ++++ specs/done/cloudflare-provider/README.md | 48 +++--- .../assets/verification.md | 45 +++--- specs/done/cloudflare-provider/choices.md | 14 ++ test/evaluator.test.ts | 146 ++++++++++++++++++ test/fixtures/provider-route.mjs | 1 + test/installed.test.mjs | 60 +++++-- 15 files changed, 505 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 4046fa3..08ab841 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ jg skill jg "How are telemetry events recorded and sent?" ./my-project ``` -Requires **Node.js 22+**, **macOS or Linux**, and a key for **Vercel AI Gateway, TypeSafe, OpenRouter, OpenCode Zen, or a custom TypeSafe-compatible endpoint**. +Requires **Node.js 22+**, **macOS or Linux**, and a key for **Vercel AI Gateway, TypeSafe, OpenRouter, OpenCode Zen, Cloudflare AI Gateway, or a custom TypeSafe-compatible endpoint**. No separate Python, Bun, or ripgrep installation is required to use `jg`. Provider selection requires **0.3.0 or newer**. Upgrade an older installation with diff --git a/apps/cli/README.md b/apps/cli/README.md index 12cb039..cf91598 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -66,6 +66,17 @@ These answers need neither `probabilities` nor `confidence`. A server that deman `criteria.true` and returns HTTP 422 is incompatible with these requests. Run `jg doctor` against a custom gateway before searching a repository. +Cloudflare AI Gateway serves Jev on its Workers AI route rather than a +TypeSafe-compatible path, so it is its own provider instead of a custom endpoint. +Choose “Cloudflare AI Gateway” in `auth` and enter the gateway URL, either +`https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY` or the gateway's custom +domain, then a token with the account's AI Gateway Run permission. Jev is billed +through Cloudflare Unified Billing. For piped setup: + +```sh +jg auth --provider cloudflare --base-url https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY --stdin +``` + Credentials are saved under `$XDG_CONFIG_HOME/jevgrep/credentials.json`, or `~/.config/jevgrep/credentials.json`. Existing saved records without a provider still mean Vercel, without a migration. diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index bf930d7..925204b 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,11 +1,16 @@ import { parseArgs } from "node:util"; import type { SearchInput } from "@repo/core"; -import { providers, customProviderId, isCredentialProvider } from "@repo/core/providers"; +import { + cloudflareProviderId, + customProviderId, + isCredentialProvider, + providers, +} from "@repo/core/providers"; import { CliError } from "./errors"; import { DEFAULT_MAX_SOURCE_BYTES } from "./render"; import type { AuthOptions } from "./auth"; -const credentialProviders = [...Object.keys(providers), customProviderId]; +const credentialProviders = [...Object.keys(providers), cloudflareProviderId, customProviderId]; export type Command = | { kind: "help" | "version" | "doctor" | "cache-clear" } @@ -80,8 +85,17 @@ export function parseCommand(args: string[]): Command { model: values.model, }; } + if (values.provider === cloudflareProviderId) { + if (!values["base-url"] || values.model !== undefined) + throw new CliError( + "Cloudflare AI Gateway needs --base-url GATEWAY_URL (and no --model) with --stdin.", + ); + return { kind: "auth", provider: cloudflareProviderId, baseURL: values["base-url"] }; + } if (values["base-url"] !== undefined || values.model !== undefined) - throw new CliError("--base-url and --model are only valid with --provider custom."); + throw new CliError( + "--base-url is only valid with --provider custom or cloudflare, --model only with custom.", + ); return { kind: "auth", provider: values.provider }; } if (first === "skill") { @@ -195,9 +209,12 @@ Commands: Auth automation: auth --provider ${credentialProviders.join("|")} --stdin auth --provider custom --base-url URL --model ID --stdin + auth --provider cloudflare --base-url GATEWAY_URL --stdin Save one provider/key from a pipe. Re-running auth replaces your setup. - Custom endpoints must use https://; http:// is allowed only for localhost - and 127.0.0.1. Auth and doctor name the endpoint host, never the key. + A Cloudflare GATEWAY_URL is https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY + or the gateway's custom domain. Base URLs must use https://; http:// is + allowed only for localhost and 127.0.0.1. Auth and doctor name the + endpoint host, never the key. Saved credentials only; provider key/URL environment variables are ignored. Skill installation options: diff --git a/apps/cli/src/auth.ts b/apps/cli/src/auth.ts index 9937994..9333bd2 100644 --- a/apps/cli/src/auth.ts +++ b/apps/cli/src/auth.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { isCancel, password, select, text } from "@clack/prompts"; import { + cloudflareProviderId, + cloudflareProviderLabel, customProviderId, customProviderLabel, endpointFor, @@ -30,7 +32,8 @@ function validateKey(raw: string): string { export type Credentials = | { provider: ProviderId; apiKey: string } - | { provider: typeof customProviderId; baseURL: string; model: string; apiKey: string }; + | { provider: typeof customProviderId; baseURL: string; model: string; apiKey: string } + | { provider: typeof cloudflareProviderId; baseURL: string; apiKey: string }; export type AuthOptions = { provider?: CredentialProvider; @@ -52,6 +55,15 @@ function customEndpoint(options: AuthOptions): CustomEndpoint { } } +/** Validate and normalize a Cloudflare gateway URL the way the evaluator will use it. */ +function cloudflareGateway(baseURL: unknown): string { + try { + return endpointFor({ provider: cloudflareProviderId, baseURL: baseURL as string }).baseURL; + } catch (error) { + throw new CliError(invalid(error)); + } +} + async function readKey(signal: AbortSignal): Promise { const chunks: Buffer[] = []; let bytes = 0; @@ -102,6 +114,24 @@ async function promptEndpoint(signal: AbortSignal): Promise { return customEndpoint({ baseURL, model }); } +async function promptGateway(signal: AbortSignal): Promise { + const baseURL = await text({ + message: "Cloudflare AI Gateway URL", + placeholder: "https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY", + validate: (value) => { + try { + cloudflareGateway(value); + } catch (error) { + return invalid(error); + } + }, + output: process.stdout, + signal, + }); + if (isCancel(baseURL)) throw new DOMException("Interrupted", "AbortError"); + return cloudflareGateway(baseURL); +} + async function save(credentials: Credentials, signal: AbortSignal) { const directory = configDirectory(); signal.throwIfAborted(); @@ -132,15 +162,20 @@ export async function authenticate(options: AuthOptions, signal: AbortSignal) { if (options.provider === customProviderId) { const endpoint = customEndpoint(options); credentials = { provider: customProviderId, ...endpoint, apiKey: await readKey(signal) }; + } else if (options.provider === cloudflareProviderId) { + const baseURL = cloudflareGateway(options.baseURL); + credentials = { provider: cloudflareProviderId, baseURL, apiKey: await readKey(signal) }; } else { credentials = { provider: options.provider, apiKey: await readKey(signal) }; } } else { if (!process.stdin.isTTY) throw new CliError( - `Use auth --provider ${[...Object.keys(providers), customProviderId].join( - "|", - )} --stdin to read a piped key.`, + `Use auth --provider ${[ + ...Object.keys(providers), + cloudflareProviderId, + customProviderId, + ].join("|")} --stdin to read a piped key.`, ); const selected = await select({ message: "Choose your Jev provider", @@ -149,6 +184,7 @@ export async function authenticate(options: AuthOptions, signal: AbortSignal) { value, label: providers[value].label, })), + { value: cloudflareProviderId, label: cloudflareProviderLabel }, { value: customProviderId, label: customProviderLabel }, ], output: process.stdout, @@ -162,6 +198,13 @@ export async function authenticate(options: AuthOptions, signal: AbortSignal) { ...endpoint, apiKey: await readPassword("Paste the API key for that endpoint", signal), }; + } else if (selected === cloudflareProviderId) { + const baseURL = await promptGateway(signal); + credentials = { + provider: cloudflareProviderId, + baseURL, + apiKey: await readPassword("Paste your Cloudflare AI Gateway token", signal), + }; } else { credentials = { provider: selected, @@ -194,6 +237,8 @@ export async function loadCredentials(): Promise { apiKey, }; } + if (provider === cloudflareProviderId) + return { provider, baseURL: cloudflareGateway(credentials.baseURL), apiKey }; return { provider, apiKey }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { diff --git a/apps/cli/test/args.test.ts b/apps/cli/test/args.test.ts index c46a1f0..e48efcf 100644 --- a/apps/cli/test/args.test.ts +++ b/apps/cli/test/args.test.ts @@ -164,3 +164,35 @@ test("exclude rejects odd trailing backslash runs but preserves escaped backslas } } }); + +test("Cloudflare auth carries its gateway URL as --base-url and rejects --model", () => { + expect( + parseCommand([ + "auth", + "--provider", + "cloudflare", + "--base-url", + "https://gateway.ai.cloudflare.com/v1/account/gateway", + "--stdin", + ]), + ).toEqual({ + kind: "auth", + provider: "cloudflare", + baseURL: "https://gateway.ai.cloudflare.com/v1/account/gateway", + }); + for (const args of [ + ["auth", "--provider", "cloudflare", "--stdin"], + [ + "auth", + "--provider", + "cloudflare", + "--base-url", + "https://ai.example.com", + "--model", + "typesafe/jev", + "--stdin", + ], + ["auth", "--provider", "cloudflare", "--base-url", "https://ai.example.com"], + ]) + expect(() => parseCommand(args)).toThrow(); +}); diff --git a/apps/cli/test/process.test.ts b/apps/cli/test/process.test.ts index 187c0e9..603c691 100644 --- a/apps/cli/test/process.test.ts +++ b/apps/cli/test/process.test.ts @@ -43,6 +43,7 @@ testInDocker("interactive auth hides input and exits 130 on interruption", async for (const mode of [ "save", "custom-save", + "cloudflare-save", "interrupt-provider", "interrupt-key", "cancel-provider", @@ -74,13 +75,19 @@ try: wait_for(b"Choose your Jev provider") mode = sys.argv[2] if mode == "custom-save": - os.write(master, b"\\x1b[B\\x1b[B\\x1b[B\\x1b[B\\r") + os.write(master, b"\\x1b[B\\x1b[B\\x1b[B\\x1b[B\\x1b[B\\r") wait_for(b"Base URL") os.write(master, b"http://127.0.0.1:8080/v1\\r") wait_for(b"Model ID") os.write(master, b"gateway/jev-2\\r") wait_for(b"API key") os.write(master, b"pty-fixture-secret\\r") + elif mode == "cloudflare-save": + os.write(master, b"\\x1b[B\\x1b[B\\x1b[B\\x1b[B\\r") + wait_for(b"Cloudflare AI Gateway URL") + os.write(master, b"https://gateway.ai.cloudflare.com/v1/pty-account/pty-gateway/\\r") + wait_for(b"token") + os.write(master, b"pty-fixture-secret\\r") else: if not mode.endswith("provider"): os.write(master, b"\\x1b[B\\x1b[B\\r") @@ -113,7 +120,7 @@ finally: { env: { ...process.env, HOME: home, XDG_CONFIG_HOME: home } }, ); const result = JSON.parse(stdout); - const saved = mode === "save" || mode === "custom-save"; + const saved = mode.endsWith("save"); expect(result.code).toBe(saved ? 0 : 130); expect(result.stderr).toBe(""); expect(result.stdout + result.echo).not.toContain("pty-fixture-secret"); @@ -135,6 +142,16 @@ finally: apiKey: "pty-fixture-secret", }); expect(result.stdout).toContain("Custom endpoint (127.0.0.1:8080)"); + } else if (mode === "cloudflare-save") { + expect(JSON.parse(await readFile(file, "utf8"))).toEqual({ + provider: "cloudflare", + baseURL: "https://gateway.ai.cloudflare.com/v1/pty-account/pty-gateway", + apiKey: "pty-fixture-secret", + }); + expect(result.stdout).toContain("Cloudflare AI Gateway (gateway.ai.cloudflare.com)"); + expect(result.stdout.indexOf("OpenCode Zen")).toBeLessThan( + result.stdout.indexOf("Cloudflare AI Gateway"), + ); } else expect(await readFile(file, "utf8")).toBe(previous); expect(await readdir(join(home, "jevgrep"))).toEqual(["credentials.json"]); } diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts index 360ddbc..84c0ca6 100644 --- a/packages/core/src/evaluator.ts +++ b/packages/core/src/evaluator.ts @@ -1,6 +1,11 @@ import { APICallError, experimental_evaluate as evaluate } from "ai"; import { createTypeSafeAi } from "@ai-sdk/typesafe-ai"; -import { endpointFor, type CredentialProvider } from "./providers"; +import { + cloudflareProviderId, + cloudflareRunURL, + endpointFor, + type CredentialProvider, +} from "./providers"; import { type createEvaluationCache, type CacheInput } from "./cache"; import { setTimeout as delay } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -42,6 +47,8 @@ export function createEvaluator(options: { concurrency?: number; }) { const endpoint = endpointFor(options); + const cloudflareRoute = + options.provider === cloudflareProviderId ? cloudflareRunURL(endpoint.baseURL) : undefined; const concurrency = options.concurrency ?? 32; const timeoutMs = options.timeoutMs ?? (options.provider === "typesafe" ? 60_000 : 15_000); if (!Number.isSafeInteger(concurrency) || concurrency < 1) @@ -87,7 +94,10 @@ export function createEvaluator(options: { if (requests >= (options.requestLimit ?? 50_000)) throw new EvaluationFailure("request-limit"); requests++; - const response = await (options.fetch ?? fetch)(input, init); + const transport = options.fetch ?? fetch; + const response = cloudflareRoute + ? await cloudflareTransport(transport, cloudflareRoute, init) + : await transport(input, init); if (response.status === 429) { const raw = response.headers.get("retry-after"); const seconds = raw === null ? NaN : Number(raw); @@ -123,8 +133,8 @@ export function createEvaluator(options: { namespace: { model: endpoint.model, provider: options.provider, - endpoint: endpoint.baseURL, - protocol: "typesafe-ai-3.0.8", + endpoint: cloudflareRoute ?? endpoint.baseURL, + protocol: cloudflareRoute ? "typesafe-ai-3.0.8+workers-ai-run" : "typesafe-ai-3.0.8", policyVersion: options.policyVersion ?? "1", parserVersion: "tree-sitter-0.27.0-python-0.25.0-go-0.25.0-rust-0.24.0-ts-5.9.3", promptVersion: "unit-locators-1", @@ -285,3 +295,42 @@ function providerDiagnostic(error: unknown, apiKey: string): EvaluationFailure[" return { statusCode: error.statusCode }; return { statusCode: error.statusCode, message: redacted.slice(0, 500) || undefined }; } + +/** + * Cloudflare's Workers AI route takes the model from its path and the gateway token as + * cf-aig-authorization, and wraps the TypeSafe answer as {state: "Completed", result}. + * Error responses pass through unchanged so status handling stays with the evaluator. + */ +async function cloudflareTransport( + transport: typeof fetch, + route: string, + init: RequestInit | undefined, +): Promise { + const { model: _model, ...input } = JSON.parse(String(init?.body)); + const headers = new Headers(init?.headers); + const authorization = headers.get("authorization"); + headers.delete("authorization"); + if (authorization) headers.set("cf-aig-authorization", authorization); + // Fetch drops Authorization on a cross-origin redirect but would forward cf-aig-authorization. + const response = await transport(route, { + ...init, + headers, + body: JSON.stringify(input), + redirect: "error", + }); + if (!response.ok) return response; + const envelope: unknown = await response.json(); + // Only a Completed envelope carries an answer; anything else fails answer validation. + const answer = + envelope && + typeof envelope === "object" && + "state" in envelope && + envelope.state === "Completed" && + "result" in envelope + ? envelope.result + : null; + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-length"); + responseHeaders.delete("content-encoding"); + return Response.json(answer, { status: response.status, headers: responseHeaders }); +} diff --git a/packages/core/src/providers.ts b/packages/core/src/providers.ts index 18f36fd..3ed66c5 100644 --- a/packages/core/src/providers.ts +++ b/packages/core/src/providers.ts @@ -27,7 +27,16 @@ export type ProviderId = keyof typeof providers; export const customProviderId = "custom"; export const customProviderLabel = "Custom endpoint"; -export type CredentialProvider = ProviderId | typeof customProviderId; +/** + * Cloudflare AI Gateway serves Jev only as a Workers AI model, at + * /workers-ai/run/typesafe/jev with its own auth header and result envelope, so + * its record saves the account's gateway URL and the evaluator adapts that one request. + */ +export const cloudflareProviderId = "cloudflare"; +export const cloudflareProviderLabel = "Cloudflare AI Gateway"; +const cloudflareModel = "typesafe/jev"; + +export type CredentialProvider = ProviderId | typeof customProviderId | typeof cloudflareProviderId; export type ProviderEndpoint = { label: string; baseURL: string; model: string }; @@ -42,14 +51,13 @@ export function isProviderId(value: unknown): value is ProviderId { } export function isCredentialProvider(value: unknown): value is CredentialProvider { - return value === customProviderId || isProviderId(value); + return value === customProviderId || value === cloudflareProviderId || isProviderId(value); } const loopbackHosts = new Set(["localhost", "127.0.0.1"]); export function validateBaseURL(value: unknown): string { - if (typeof value !== "string" || !value.trim()) - throw new Error("Provide a base URL for the custom endpoint."); + if (typeof value !== "string" || !value.trim()) throw new Error("Provide a base URL for the custom endpoint."); const baseURL = value.trim(); let url: URL; try { @@ -84,9 +92,29 @@ function customEndpoint(selection: ProviderSelection): ProviderEndpoint { }; } +function cloudflareEndpoint(selection: ProviderSelection): ProviderEndpoint { + // The route is appended to the gateway URL, so a trailing slash would double it. + if (typeof selection.baseURL !== "string" || !selection.baseURL.trim()) + throw new Error("Provide your Cloudflare AI Gateway URL."); + const baseURL = validateBaseURL(selection.baseURL).replace(/\/+$/, ""); + // A URL copied from a provider route would double the route Jevgrep appends. + if (/\/(workers-ai|compat)(\/|$)/.test(new URL(baseURL).pathname)) + throw new Error("Enter the gateway URL itself, without a /workers-ai or /compat route."); + return { + label: `${cloudflareProviderLabel} (${new URL(baseURL).host})`, + baseURL, + model: cloudflareModel, + }; +} + +/** The Workers AI route that serves Jev behind a Cloudflare gateway base URL. */ +export function cloudflareRunURL(baseURL: string) { + return `${baseURL}/workers-ai/run/${cloudflareModel}`; +} + /** Resolve the transport settings for a saved record without exposing its key. */ export function endpointFor(selection: ProviderSelection): ProviderEndpoint { - return selection.provider === customProviderId - ? customEndpoint(selection) - : providers[selection.provider]; + if (selection.provider === customProviderId) return customEndpoint(selection); + if (selection.provider === cloudflareProviderId) return cloudflareEndpoint(selection); + return providers[selection.provider]; } diff --git a/packages/core/test/providers.test.ts b/packages/core/test/providers.test.ts index 1ce4ee4..f92c5d1 100644 --- a/packages/core/test/providers.test.ts +++ b/packages/core/test/providers.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { + cloudflareRunURL, customProviderId, endpointFor, isCredentialProvider, @@ -64,3 +65,28 @@ test("custom is a selectable credential provider and unknown names stay invalid" for (const value of ["unknown", "Custom", "", undefined, 7]) expect(isCredentialProvider(value)).toBe(false); }); + +test("Cloudflare resolves its gateway URL without a trailing slash onto the Workers AI route", () => { + for (const baseURL of [ + "https://gateway.ai.cloudflare.com/v1/account/gateway", + " https://gateway.ai.cloudflare.com/v1/account/gateway/ ", + ]) + expect(endpointFor({ provider: "cloudflare", baseURL })).toEqual({ + label: "Cloudflare AI Gateway (gateway.ai.cloudflare.com)", + baseURL: "https://gateway.ai.cloudflare.com/v1/account/gateway", + model: "typesafe/jev", + }); + expect(cloudflareRunURL("https://ai.example.com")).toBe( + "https://ai.example.com/workers-ai/run/typesafe/jev", + ); + expect(isCredentialProvider("cloudflare")).toBe(true); + for (const baseURL of [ + undefined, + "", + "http://ai.example.com", + "https://ai.example.com/?x=1", + "https://gateway.ai.cloudflare.com/v1/account/gateway/workers-ai/run/typesafe/jev", + "https://ai.example.com/compat", + ]) + expect(() => endpointFor({ provider: "cloudflare", baseURL })).toThrow(); +}); diff --git a/specs/done/cloudflare-provider/README.md b/specs/done/cloudflare-provider/README.md index d6099ae..7e48720 100644 --- a/specs/done/cloudflare-provider/README.md +++ b/specs/done/cloudflare-provider/README.md @@ -1,7 +1,7 @@ # Cloudflare AI Gateway provider -Jevgrep supports Cloudflare AI Gateway as a fourth saved provider, next to -Vercel AI Gateway, native TypeSafe and OpenRouter. It follows the +Jevgrep supports Cloudflare AI Gateway as a saved provider, next to the fixed +presets and the custom TypeSafe-compatible endpoint. It follows the [saved-provider record](../provider-support/README.md) and keeps every invariant listed there. This record covers only what Cloudflare needs beyond that. @@ -11,14 +11,18 @@ Cloudflare serves Jev (`typesafe/jev`) with Unified Billing, but only on the gateway's Workers AI route, `/workers-ai/run/typesafe/jev`. There is no TypeSafe provider-native path. The probes in [research](research.md) record the routes that fail. So Cloudflare cannot be one more fixed base URL for the same -adapter. It needs two things the other presets do not: +adapter, and it cannot be a custom endpoint either: the custom provider appends +`/systemone` and sends a TypeSafe body, which this route rejects. It needs two +things the other providers do not combine: - **An account-specific endpoint.** The gateway URL contains the account and gateway - IDs, or it is the gateway's custom domain. `jg auth` saves it with the token in the - same single private record. Search and doctor still take no endpoint, provider or - environment override. The URL is setup, like the key. `createEvaluator` itself throws - for a Cloudflare provider without a valid URL, before any request. The CLI validates - the saved record first, so users see the auth message instead. + IDs, or it is the gateway's custom domain. `jg auth --base-url` saves it as the + record's `baseURL`, the same field and flag the custom provider uses, with the + token in the same single private record. Search and doctor still take no endpoint, + provider or environment override. `endpointFor` validates it with the shared + `validateBaseURL`, so `createEvaluator` throws for a Cloudflare provider without a + valid URL, before any request. The CLI validates the saved record first, so users + see the auth message instead. - **A different wire shape for one request.** The route takes the model from its path, authenticates the gateway token from `cf-aig-authorization`, and wraps the TypeSafe answer as `{state, result}`. The evaluator adapts that single request @@ -28,12 +32,16 @@ adapter. It needs two things the other presets do not: ## What must stay true -- Only a Cloudflare record carries `gatewayURL`, and a Cloudflare record without a - valid URL is invalid credentials. It never falls back to another provider. Other - providers' records are unchanged and still accept a legacy record without a provider. -- The gateway URL must be `https`. Plain `http` is accepted only for loopback hosts. - The URL can't carry a query, fragment or userinfo, or exceed 2,048 characters. Auth - normalizes a trailing slash away. +- A Cloudflare record is `{provider, baseURL, apiKey}` with no `model`; the model is + fixed by the route. A Cloudflare record without a valid URL is invalid credentials. + It never falls back to another provider. Other providers' records are unchanged. +- The gateway URL follows the custom endpoint's rules: `https`, with plain `http` + only for `localhost` and `127.0.0.1`, and no query, fragment or userinfo. A + trailing slash is normalized away because the route is appended to it, and a URL + that already contains a `/workers-ai` or `/compat` segment is rejected rather than + doubled. +- Auth and doctor name the provider and the gateway host, never the key or the + account path. - The token travels only as `cf-aig-authorization`. The request omits `Authorization`, and redirects are refused, because fetch would drop `Authorization` on a cross-origin redirect but would forward the custom header. @@ -48,23 +56,23 @@ adapter. It needs two things the other presets do not: ## Pointers -The [preset owner](../../../packages/core/src/providers.ts) holds the preset, -`parseGatewayURL` and `cloudflareRunURL`. The +The [provider owner](../../../packages/core/src/providers.ts) holds +`cloudflareProviderId`, its `endpointFor` branch and `cloudflareRunURL`. The [evaluator](../../../packages/core/src/evaluator.ts) holds `cloudflareTransport`, and the [auth module](../../../apps/cli/src/auth.ts) and -[argument parser](../../../apps/cli/src/args.ts) own `--gateway-url`. +[argument parser](../../../apps/cli/src/args.ts) accept `--base-url` for it and +reject `--model`. Tests route through a canonical fixture gateway, `https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway`, which the [test-only preload](../../../test/fixtures/provider-route.mjs) redirects to local HTTP. The product has no transport override. -[Provider replay](../../../test/reference/provider-parity.test.ts) pins the frozen -request multiset and complete stdout for Cloudflare. [Installed journeys](../../../test/installed.test.mjs) run auth, doctor, search, cache and replacement against the packed CLI. [Evaluator tests](../../../test/evaluator.test.ts) pin the route, headers, body, unwrapping, non-Completed envelopes, authentication failure, refused redirects and -URL validation, and +URL validation, [provider tests](../../../packages/core/test/providers.test.ts) +pin endpoint resolution, and [process tests](../../../apps/cli/test/process.test.ts) cover the interactive gateway prompt. diff --git a/specs/done/cloudflare-provider/assets/verification.md b/specs/done/cloudflare-provider/assets/verification.md index 45350aa..75f63e2 100644 --- a/specs/done/cloudflare-provider/assets/verification.md +++ b/specs/done/cloudflare-provider/assets/verification.md @@ -1,27 +1,30 @@ # Cloudflare provider verification -2026-09-27, macOS arm64, Node v26.9.0, bun 1.4.2. No npm release or paid benchmark was -run. The reference manifest, covered files, corpus and frozen research skill are -unchanged. +Re-ported onto upstream's custom-endpoint seams after merging upstream main (0.6.0 +and later). 2026-09-29, macOS arm64, the pinned bun 1.3.14 and Docker for +the suite. No npm release or paid benchmark was run. -| Gate | Result | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `bun run verify` | Exit 0: types, lint, 132 core/CLI/reference, 18 Node protocol, 23 parser, 4 release, 27 maintained harness and 33 installed Docker tests | -| `bun run format:check` | Exit 0 | -| After the Completed-envelope change | `bun run test` exit 0 (133 core/CLI/reference, 18 protocol, 23 parser, 4 release, 27 harness); installed cloudflare, saved-credential, invalid-record, legacy and authentication cases 11/11; live doctor verified | -| Installed journey `cloudflare` | Pass: auth with `--gateway-url`, doctor, cold and warm search, replacement by Vercel, unauthorized doctor without retry | -| Provider replay `cloudflare` | Pass: frozen request multiset and complete stdout; missing and invalid answers stay incomplete | -| Evaluator mutation checks | Removing the envelope unwrap or `redirect: "error"` fails the matching evaluator test; accepting a `Queued` envelope failed the Completed-only test before it was enforced | +| Gate | Result | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `bun run check-types`, `lint` | Exit 0 (one upstream warning in `packages/core/src/filesystem.ts`) | +| `bun run test` (Docker) | Exit 0: 125 core/CLI, 18 provider protocol, 39 parser, 4 release, 30 maintained harness | +| `bun run test:installed` | Exit 0: 42/42, including the `cloudflare` saved-provider journey and the invalid Cloudflare records | +| Installed journey `cloudflare` | Pass: auth with `--base-url`, doctor, cold and warm search, replacement by Vercel, unauthorized doctor | +| Evaluator mutation checks | Sending the token as `Authorization`, dropping `redirect: "error"`, or accepting any envelope state each fails its own evaluator test | -Live checks used the development CLI with an isolated `XDG_CONFIG_HOME` and a token -scoped to Account > AI Gateway Run only: +`oxfmt --check .` reports only upstream files outside this change. -| Check | Result | -| ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `jg auth --provider cloudflare --gateway-url --stdin`, `jg doctor` | Saved; `Jev connection verified through Cloudflare AI Gateway.` | -| Same with `https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` | Saved; doctor verified | -| Doctor with an invalid token | Exit 1, connection check failed message | -| Search over `apps` for the gateway URL auth flow | Exit 0 in 5.6s; `cli/src/auth.ts` first with its declarations | +Live checks used the built development CLI with an isolated `XDG_CONFIG_HOME` and a +token scoped to Account > AI Gateway Run only, against a gateway custom domain: -A live check proves the route and authentication, not probability equality with -other providers, solve quality or cost. +| Check | Result | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `jg auth --provider cloudflare --base-url / --stdin` | Saved `{provider, baseURL, apiKey}` with the trailing slash removed | +| `jg doctor` | `Jev connection verified through Cloudflare AI Gateway ().` | +| Search over `packages/core/src` for where the gateway header is set | Exit 0 in 2s; `evaluator.ts` first with `cloudflareTransport`'s source | + +The first implementation (before the merge) was also verified live against +`https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` and with an invalid token +(exit 1, connection check failed). A live check proves the route and +authentication, not probability equality with other providers, solve quality or +cost. diff --git a/specs/done/cloudflare-provider/choices.md b/specs/done/cloudflare-provider/choices.md index df0b467..bcc7afd 100644 --- a/specs/done/cloudflare-provider/choices.md +++ b/specs/done/cloudflare-provider/choices.md @@ -10,6 +10,20 @@ it in the one auth record keeps the rule intact: a human chooses provider, key a gateway once, and agents search without routing decisions. An environment variable or a search flag would reopen the precedence questions that record closed. +### Share the custom endpoint's `baseURL` field and `--base-url` flag + +The custom provider already defines how a saved record names its endpoint and how +that URL is validated. A Cloudflare-only `gatewayURL` field and `--gateway-url` +flag would duplicate both. Cloudflare reuses them and differs only in having no +`model`, which its route fixes. + +### A provider of its own, not a custom endpoint preset + +A custom endpoint is a TypeSafe-compatible base URL plus a model. Cloudflare's route +takes a different path, auth header and envelope, so saving it as a custom record +would send requests Cloudflare rejects. It is listed after the fixed presets and +before "Custom endpoint", which stays last as the catch-all. + ### Adapt the request in the evaluator's fetch hook The alternatives were a separate evaluation model for Cloudflare or an unpublished diff --git a/test/evaluator.test.ts b/test/evaluator.test.ts index f444291..3cb2cb7 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -633,3 +633,149 @@ test("token-queued requests revalidate source after waiting", async () => { expect(await queued).toMatchObject({ kind: "source-invalid" }); expect(calls).toBe(3); }); + +test("Cloudflare AI Gateway uses the Workers AI route, gateway auth and unwraps the result", async () => { + const seen: { path: string; auth: string | null; gatewayAuth: string | null; body: unknown }[] = + []; + const server = Bun.serve({ + port: 0, + async fetch(request) { + seen.push({ + path: new URL(request.url).pathname, + auth: request.headers.get("authorization"), + gatewayAuth: request.headers.get("cf-aig-authorization"), + body: await request.json(), + }); + return Response.json({ + state: "Completed", + result: { model: "jev-1.13.0", answers: { useful: { type: "noul", noul: 0.7 } } }, + }); + }, + }); + try { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + baseURL: `http://127.0.0.1:${server.port}/v1/account/gateway/`, + signal: new AbortController().signal, + }); + const result = await evaluator.evaluate({ + state: "recordEvent()", + questions: { useful: { type: "boolean", instructions: "Is the source useful?" } }, + }); + expect(result).toEqual({ useful: 0.7 }); + expect(seen).toEqual([ + { + path: "/v1/account/gateway/workers-ai/run/typesafe/jev", + auth: null, + gatewayAuth: "Bearer cf-token", + body: { + state: "recordEvent()", + questions: { useful: { type: "noul", instructions: "Is the source useful?" } }, + }, + }, + ]); + } finally { + server.stop(true); + } +}); + +test("Cloudflare gateway rejection is an authentication failure, and a missing URL never calls out", async () => { + let calls = 0; + const server = Bun.serve({ + port: 0, + fetch() { + calls++; + return Response.json({ success: false, errors: [{ code: 10000 }] }, { status: 401 }); + }, + }); + const request = { + state: "test", + questions: { q: { type: "boolean" as const, instructions: "Relevant?" } }, + }; + try { + const evaluator = createEvaluator({ + apiKey: "bad", + provider: "cloudflare", + baseURL: `http://127.0.0.1:${server.port}`, + signal: new AbortController().signal, + }); + await expect(evaluator.evaluate(request)).rejects.toMatchObject({ kind: "authentication" }); + expect(calls).toBe(1); + for (const baseURL of [undefined, "http://gateway.example/v1/a/g"]) + expect(() => + createEvaluator({ + apiKey: "x", + provider: "cloudflare", + baseURL, + signal: new AbortController().signal, + }), + ).toThrow(); + expect(calls).toBe(1); + } finally { + server.stop(true); + } +}); + +test("Cloudflare transport never follows a redirect with the gateway token", async () => { + let leaked = 0; + const target = Bun.serve({ + port: 0, + fetch() { + leaked++; + return Response.json({ result: { answers: { q: { type: "noul", noul: 0.9 } } } }); + }, + }); + const gateway = Bun.serve({ + port: 0, + fetch: () => Response.redirect(`http://127.0.0.1:${target.port}/elsewhere`, 307), + }); + try { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + baseURL: `http://127.0.0.1:${gateway.port}`, + signal: new AbortController().signal, + }); + await expect( + evaluator.evaluate({ + state: "test", + questions: { q: { type: "boolean", instructions: "Relevant?" } }, + }), + ).rejects.toMatchObject({ kind: "provider" }); + expect(leaked).toBe(0); + } finally { + gateway.stop(true); + target.stop(true); + } +}); + +test("Cloudflare answers count only from a Completed envelope, never as negative evidence", async () => { + const bodies = [ + { state: "Queued", result: { answers: { q: { type: "noul", noul: 0.9 } } } }, + { state: "Completed" }, + { answers: { q: { type: "noul", noul: 0.9 } } }, + ]; + let calls = 0; + const server = Bun.serve({ port: 0, fetch: () => Response.json(bodies[calls++]) }); + try { + for (const _ of bodies) { + const evaluator = createEvaluator({ + apiKey: "cf-token", + provider: "cloudflare", + baseURL: `http://127.0.0.1:${server.port}`, + signal: new AbortController().signal, + requestLimit: 1, + }); + await expect( + evaluator.evaluate({ + state: "test", + questions: { q: { type: "boolean", instructions: "Relevant?" } }, + }), + ).rejects.toBeInstanceOf(Error); + } + expect(calls).toBe(3); + } finally { + server.stop(true); + } +}); diff --git a/test/fixtures/provider-route.mjs b/test/fixtures/provider-route.mjs index 88f0641..0899021 100644 --- a/test/fixtures/provider-route.mjs +++ b/test/fixtures/provider-route.mjs @@ -4,6 +4,7 @@ const endpoints = new Set([ "https://api.typesafe.ai/v1/systemone", "https://openrouter.ai/api/v1/systemone", "https://opencode.ai/zen/v1/systemone", + "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway/workers-ai/run/typesafe/jev", ]); export function routeProviderFetch(originalFetch, origin) { return async (input, init) => { diff --git a/test/installed.test.mjs b/test/installed.test.mjs index b2cb828..1c4f3fe 100644 --- a/test/installed.test.mjs +++ b/test/installed.test.mjs @@ -46,6 +46,12 @@ const providers = { url: "https://opencode.ai/zen/v1/systemone", model: "jev-1.13", }, + cloudflare: { + label: "Cloudflare AI Gateway", + url: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway/workers-ai/run/typesafe/jev", + model: "typesafe/jev", + baseURL: "https://gateway.ai.cloudflare.com/v1/fixture-account/fixture-gateway", + }, }; const fixtureKey = "installed-http-fixture-key"; const forbidden = "INSTALLED_FIXTURE_IGNORED_CONTENT_MUST_NEVER_UPLOAD"; @@ -118,10 +124,14 @@ async function context(t, mode = "healthy", executable = binary) { const preset = providers[expectedProvider]; assert.equal(request.url, new URL(preset.url).pathname); assert.equal(request.headers["x-jevgrep-original-url"], preset.url); + // Cloudflare's Workers AI route takes the gateway token and the model from its path. + const cloudflare = expectedProvider === "cloudflare"; assert.ok( - request.headers.authorization === `Bearer ${expectedKey}`, + request.headers[cloudflare ? "cf-aig-authorization" : "authorization"] === + `Bearer ${expectedKey}`, "Saved key must authenticate the request", ); + if (cloudflare) assert.equal(request.headers.authorization, undefined); assert.match(request.headers["content-type"] ?? "", /^application\/json/); const chunks = []; let bytes = 0; @@ -133,8 +143,11 @@ async function context(t, mode = "healthy", executable = binary) { const raw = Buffer.concat(chunks).toString("utf8"); assert.ok(!raw.includes(forbidden), "Ignored/hidden source reached the provider"); const body = JSON.parse(raw); - assert.deepEqual(Object.keys(body).sort(), ["model", "questions", "state"]); - assert.equal(body.model, preset.model); + assert.deepEqual( + Object.keys(body).sort(), + cloudflare ? ["questions", "state"] : ["model", "questions", "state"], + ); + if (!cloudflare) assert.equal(body.model, preset.model); assert.equal( typeof body.state, "object", @@ -220,15 +233,14 @@ async function context(t, mode = "healthy", executable = binary) { const ids = Object.keys(body.questions); assert.equal(probabilities.length, ids.length); response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - answers: Object.fromEntries( - ids.map((id, index) => [id, { type: "noul", noul: probabilities[index] }]), - ), - usage: { input_tokens: 1, output_tokens: 1 }, - warnings: [{ type: "other", message: "installed-fixture-warning" }], - }), - ); + const answer = { + answers: Object.fromEntries( + ids.map((id, index) => [id, { type: "noul", noul: probabilities[index] }]), + ), + usage: { input_tokens: 1, output_tokens: 1 }, + warnings: [{ type: "other", message: "installed-fixture-warning" }], + }; + response.end(JSON.stringify(cloudflare ? { state: "Completed", result: answer } : answer)); })().catch((error) => { protocolErrors.push(error.message); response.writeHead(400, { "content-type": "application/json" }); @@ -1194,8 +1206,9 @@ for (const [provider, preset] of Object.entries(providers)) await fixture.removeCredentials(); const key = `installed-${provider}-saved-key`; fixture.expectProvider(provider, key); + const gateway = preset.baseURL ? ["--base-url", preset.baseURL] : []; const auth = await fixture.run( - ["auth", "--provider", provider, "--stdin"], + ["auth", "--provider", provider, ...gateway, "--stdin"], {}, false, key + "\n", @@ -1206,6 +1219,7 @@ for (const [provider, preset] of Object.entries(providers)) assert.equal(fixture.requests.length, 0, "Auth must not contact any provider"); assert.deepEqual(JSON.parse(await readFile(fixture.credentials, "utf8")), { provider, + ...(preset.baseURL ? { baseURL: preset.baseURL } : {}), apiKey: key, }); assert.equal((await stat(fixture.credentials)).mode & 0o777, 0o600); @@ -1295,9 +1309,16 @@ test("installed saved credentials defeat conflicting environment and environment AI_GATEWAY_MODEL: "environment-model", TYPESAFE_MODEL: "environment-model", OPENROUTER_MODEL: "environment-model", + CLOUDFLARE_API_TOKEN: "environment-cloudflare-key", + CF_AIG_TOKEN: "environment-cloudflare-gateway-key", + CLOUDFLARE_GATEWAY_URL: "http://127.0.0.1:1/forbidden", }; for (const provider of Object.keys(providers)) { - await writeFile(fixture.credentials, JSON.stringify({ provider, apiKey: fixtureKey })); + const baseURL = providers[provider].baseURL; + await writeFile( + fixture.credentials, + JSON.stringify({ provider, apiKey: fixtureKey, ...(baseURL ? { baseURL } : {}) }), + ); fixture.expectProvider(provider); const doctor = await fixture.run(["doctor"], conflicts); assert.equal(doctor.code, 0, doctor.stdout); @@ -1317,8 +1338,15 @@ test("installed saved credentials defeat conflicting environment and environment test("installed invalid saved providers fail before HTTP without rewriting credentials", async (t) => { const fixture = await context(t); - for (const provider of [null, "", "unknown", false, 0, {}, []]) { - const original = JSON.stringify({ provider, apiKey: fixtureKey }) + "\n"; + const records = [null, "", "unknown", false, 0, {}, []].map((provider) => ({ + provider, + apiKey: fixtureKey, + })); + // A Cloudflare record is only valid with its https gateway URL; it never falls back. + for (const baseURL of [undefined, "", "http://gateway.example/v1/a/g", "not a url"]) + records.push({ provider: "cloudflare", apiKey: fixtureKey, baseURL }); + for (const record of records) { + const original = JSON.stringify(record) + "\n"; await writeFile(fixture.credentials, original); for (const args of [["doctor"], [query]]) { const result = await fixture.run(args); From 28cf52308f3ae00788e9b57f4a03b7e48aa31479 Mon Sep 17 00:00:00 2001 From: Rob Knight <12484127+rknightion@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:44:28 +0100 Subject: [PATCH 5/5] Close out the Cloudflare port with the repository's review and audit skills refactor-clean: drop the Cloudflare-only cache namespace (provider and gateway URL already isolate it), simplify the envelope unwrap, and give the saved-provider order one owner, credentialProviders, used by the menu, argument validation and errors. code-review: no unused-binding suppression or type cast. write-docs: link the provider record from the CLI README. audit-choices: rewrite the choices ledger per the skill. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UsM3MRh2GKBUmaPwA5E322 --- apps/cli/README.md | 4 +- apps/cli/src/args.ts | 4 +- apps/cli/src/auth.ts | 28 ++- packages/core/src/evaluator.ts | 23 +- packages/core/src/providers.ts | 13 +- specs/done/cloudflare-provider/README.md | 6 +- .../assets/verification.md | 13 +- specs/done/cloudflare-provider/choices.md | 210 +++++++++++++++--- 8 files changed, 222 insertions(+), 79 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index cf91598..224fbc6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -71,7 +71,9 @@ TypeSafe-compatible path, so it is its own provider instead of a custom endpoint Choose “Cloudflare AI Gateway” in `auth` and enter the gateway URL, either `https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY` or the gateway's custom domain, then a token with the account's AI Gateway Run permission. Jev is billed -through Cloudflare Unified Billing. For piped setup: +through Cloudflare Unified Billing. The +[Cloudflare provider record](../../specs/done/cloudflare-provider/README.md) explains +the route and its invariants. For piped setup: ```sh jg auth --provider cloudflare --base-url https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY --stdin diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 925204b..f772ae9 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,16 +2,14 @@ import { parseArgs } from "node:util"; import type { SearchInput } from "@repo/core"; import { cloudflareProviderId, + credentialProviders, customProviderId, isCredentialProvider, - providers, } from "@repo/core/providers"; import { CliError } from "./errors"; import { DEFAULT_MAX_SOURCE_BYTES } from "./render"; import type { AuthOptions } from "./auth"; -const credentialProviders = [...Object.keys(providers), cloudflareProviderId, customProviderId]; - export type Command = | { kind: "help" | "version" | "doctor" | "cache-clear" } | { kind: "skill"; agents: string[]; global: boolean; yes: boolean } diff --git a/apps/cli/src/auth.ts b/apps/cli/src/auth.ts index 9333bd2..5faeea6 100644 --- a/apps/cli/src/auth.ts +++ b/apps/cli/src/auth.ts @@ -6,6 +6,7 @@ import { isCancel, password, select, text } from "@clack/prompts"; import { cloudflareProviderId, cloudflareProviderLabel, + credentialProviders, customProviderId, customProviderLabel, endpointFor, @@ -56,9 +57,9 @@ function customEndpoint(options: AuthOptions): CustomEndpoint { } /** Validate and normalize a Cloudflare gateway URL the way the evaluator will use it. */ -function cloudflareGateway(baseURL: unknown): string { +function cloudflareGateway(baseURL: string | undefined): string { try { - return endpointFor({ provider: cloudflareProviderId, baseURL: baseURL as string }).baseURL; + return endpointFor({ provider: cloudflareProviderId, baseURL }).baseURL; } catch (error) { throw new CliError(invalid(error)); } @@ -171,22 +172,19 @@ export async function authenticate(options: AuthOptions, signal: AbortSignal) { } else { if (!process.stdin.isTTY) throw new CliError( - `Use auth --provider ${[ - ...Object.keys(providers), - cloudflareProviderId, - customProviderId, - ].join("|")} --stdin to read a piped key.`, + `Use auth --provider ${credentialProviders.join("|")} --stdin to read a piped key.`, ); const selected = await select({ message: "Choose your Jev provider", - options: [ - ...(Object.keys(providers) as ProviderId[]).map((value) => ({ - value, - label: providers[value].label, - })), - { value: cloudflareProviderId, label: cloudflareProviderLabel }, - { value: customProviderId, label: customProviderLabel }, - ], + options: credentialProviders.map((value) => ({ + value, + label: + value === customProviderId + ? customProviderLabel + : value === cloudflareProviderId + ? cloudflareProviderLabel + : providers[value].label, + })), output: process.stdout, signal, }); diff --git a/packages/core/src/evaluator.ts b/packages/core/src/evaluator.ts index 84c0ca6..a82d4ee 100644 --- a/packages/core/src/evaluator.ts +++ b/packages/core/src/evaluator.ts @@ -133,8 +133,8 @@ export function createEvaluator(options: { namespace: { model: endpoint.model, provider: options.provider, - endpoint: cloudflareRoute ?? endpoint.baseURL, - protocol: cloudflareRoute ? "typesafe-ai-3.0.8+workers-ai-run" : "typesafe-ai-3.0.8", + endpoint: endpoint.baseURL, + protocol: "typesafe-ai-3.0.8", policyVersion: options.policyVersion ?? "1", parserVersion: "tree-sitter-0.27.0-python-0.25.0-go-0.25.0-rust-0.24.0-ts-5.9.3", promptVersion: "unit-locators-1", @@ -306,7 +306,8 @@ async function cloudflareTransport( route: string, init: RequestInit | undefined, ): Promise { - const { model: _model, ...input } = JSON.parse(String(init?.body)); + const input = JSON.parse(String(init?.body)); + delete input.model; const headers = new Headers(init?.headers); const authorization = headers.get("authorization"); headers.delete("authorization"); @@ -319,18 +320,8 @@ async function cloudflareTransport( redirect: "error", }); if (!response.ok) return response; - const envelope: unknown = await response.json(); + const envelope = (await response.json()) as { state?: unknown; result?: unknown } | null; // Only a Completed envelope carries an answer; anything else fails answer validation. - const answer = - envelope && - typeof envelope === "object" && - "state" in envelope && - envelope.state === "Completed" && - "result" in envelope - ? envelope.result - : null; - const responseHeaders = new Headers(response.headers); - responseHeaders.delete("content-length"); - responseHeaders.delete("content-encoding"); - return Response.json(answer, { status: response.status, headers: responseHeaders }); + const answer = envelope?.state === "Completed" ? (envelope.result ?? null) : null; + return Response.json(answer, { status: response.status }); } diff --git a/packages/core/src/providers.ts b/packages/core/src/providers.ts index 3ed66c5..831de06 100644 --- a/packages/core/src/providers.ts +++ b/packages/core/src/providers.ts @@ -50,14 +50,21 @@ export function isProviderId(value: unknown): value is ProviderId { return typeof value === "string" && Object.hasOwn(providers, value); } +/** Every saved-provider name, in the order auth offers them; custom stays last. */ +export const credentialProviders: CredentialProvider[] = [ + ...(Object.keys(providers) as ProviderId[]), + cloudflareProviderId, + customProviderId, +]; + export function isCredentialProvider(value: unknown): value is CredentialProvider { - return value === customProviderId || value === cloudflareProviderId || isProviderId(value); + return credentialProviders.includes(value as CredentialProvider); } const loopbackHosts = new Set(["localhost", "127.0.0.1"]); export function validateBaseURL(value: unknown): string { - if (typeof value !== "string" || !value.trim()) throw new Error("Provide a base URL for the custom endpoint."); + if (typeof value !== "string" || !value.trim()) throw new Error("Provide the endpoint base URL."); const baseURL = value.trim(); let url: URL; try { @@ -94,8 +101,6 @@ function customEndpoint(selection: ProviderSelection): ProviderEndpoint { function cloudflareEndpoint(selection: ProviderSelection): ProviderEndpoint { // The route is appended to the gateway URL, so a trailing slash would double it. - if (typeof selection.baseURL !== "string" || !selection.baseURL.trim()) - throw new Error("Provide your Cloudflare AI Gateway URL."); const baseURL = validateBaseURL(selection.baseURL).replace(/\/+$/, ""); // A URL copied from a provider route would double the route Jevgrep appends. if (/\/(workers-ai|compat)(\/|$)/.test(new URL(baseURL).pathname)) diff --git a/specs/done/cloudflare-provider/README.md b/specs/done/cloudflare-provider/README.md index 7e48720..ef5a4b2 100644 --- a/specs/done/cloudflare-provider/README.md +++ b/specs/done/cloudflare-provider/README.md @@ -50,9 +50,9 @@ things the other providers do not combine: body of `{state: "Completed", result}` yields an answer. Any other state, a missing `result` or a bare unwrapped answer fails validation as an invalid answer. It is never treated as negative evidence. -- Answer-cache identity uses the full Workers AI endpoint and a distinct protocol tag - (`typesafe-ai-3.0.8+workers-ai-run`). Answers from another provider or another - gateway never certify this route. +- Answer-cache identity needs nothing Cloudflare-specific: the namespace already + carries `provider: "cloudflare"` and the normalized gateway URL, so answers from + another provider or another gateway never certify this route. ## Pointers diff --git a/specs/done/cloudflare-provider/assets/verification.md b/specs/done/cloudflare-provider/assets/verification.md index 75f63e2..292635f 100644 --- a/specs/done/cloudflare-provider/assets/verification.md +++ b/specs/done/cloudflare-provider/assets/verification.md @@ -12,16 +12,19 @@ the suite. No npm release or paid benchmark was run. | Installed journey `cloudflare` | Pass: auth with `--base-url`, doctor, cold and warm search, replacement by Vercel, unauthorized doctor | | Evaluator mutation checks | Sending the token as `Authorization`, dropping `redirect: "error"`, or accepting any envelope state each fails its own evaluator test | +The repository's `review` closeout (refactor-clean, code-review, write-docs) and +`audit-choices` ran over the full change; the gates above were rerun on the result. `oxfmt --check .` reports only upstream files outside this change. Live checks used the built development CLI with an isolated `XDG_CONFIG_HOME` and a token scoped to Account > AI Gateway Run only, against a gateway custom domain: -| Check | Result | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `jg auth --provider cloudflare --base-url / --stdin` | Saved `{provider, baseURL, apiKey}` with the trailing slash removed | -| `jg doctor` | `Jev connection verified through Cloudflare AI Gateway ().` | -| Search over `packages/core/src` for where the gateway header is set | Exit 0 in 2s; `evaluator.ts` first with `cloudflareTransport`'s source | +| Check | Result | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `jg auth --provider cloudflare --base-url / --stdin` | Saved `{provider, baseURL, apiKey}` with the trailing slash removed | +| `jg doctor` | `Jev connection verified through Cloudflare AI Gateway ().` | +| Search over `packages/core/src` for where the gateway header is set | Exit 0 in 2s; `evaluator.ts` first with `cloudflareTransport`'s source | +| `jg auth ... --base-url /workers-ai/run/typesafe/jev` | Exit 1 before reading the key: asks for the gateway URL without the route | The first implementation (before the merge) was also verified live against `https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` and with an invalid token diff --git a/specs/done/cloudflare-provider/choices.md b/specs/done/cloudflare-provider/choices.md index bcc7afd..4338f9b 100644 --- a/specs/done/cloudflare-provider/choices.md +++ b/specs/done/cloudflare-provider/choices.md @@ -1,46 +1,192 @@ # Cloudflare provider choices -## Sound: high confidence +Every decision the implementation made that the request left open. Review these +first: the shared validator's error wording and the Cloudflare timeout and pacing +defaults are the two calls a maintainer is least likely to have made the same way. -### The gateway URL is saved by auth, not supplied per search +## Needs user + +### The shared base URL validator now says "Provide the endpoint base URL." + +- **When:** the re-port onto the custom endpoint seams. +- **The choice:** `validateBaseURL` is the one function that checks any saved + endpoint URL. Before Cloudflare existed, only the custom provider called it, so an + empty URL produced "Provide a base URL for the custom endpoint." Now suppose a user + picks Cloudflare AI Gateway in `jg auth` and presses Enter at the gateway URL + prompt without typing anything. With the old wording they would be told about a + "custom endpoint" they never chose. The message is now neutral, so both providers + get a sentence that is true for them. The unbuilt alternative kept upstream's + wording and gave Cloudflare its own empty-value check before calling the + validator, which duplicates a check the validator already makes. +- **The gap:** the custom provider's validator predates a second caller. +- **The reach:** anyone reading the custom provider's errors sees the new wording; + no test pins either text. +- **Verdict:** needs-user, because the wording is upstream-owned. Provisional call: + keep the neutral message. To reverse it, restore the old string and add an + empty-value check in `cloudflareEndpoint`. +- **Confidence:** low. + +### Cloudflare uses the default 15 second timeout and no token pacing + +- **When:** the first implementation, kept by the re-port. +- **The choice:** the evaluator gives native TypeSafe a 60 second request timeout and + a token-rate budget (a local limit that spaces requests so a burst does not + exceed the provider's tokens-per-second quota). Every other provider, Cloudflare + included, gets 15 seconds and no budget. On a large repository a Cloudflare search + therefore sends requests as fast as the concurrency limit allows. Workers AI has + its own rate limit for Jev: one live search received 42 HTTP 429 ("too many + requests") replies without a `retry-after` header, backed off one second each time, + and ended incomplete. The unbuilt alternative gave Cloudflare the TypeSafe budget + and timeout, but Cloudflare publishes no quota to size the budget from. +- **The gap:** the request did not say how a gateway-fronted Workers AI model should + be paced. +- **The reach:** throttled Cloudflare searches can end incomplete more often than + direct TypeSafe ones. They never report a false "nothing found". +- **Verdict:** needs-user. Provisional call: keep the defaults and revisit with a + measured budget if incomplete runs recur. Reversible by extending the evaluator's + `typesafe` condition. +- **Confidence:** medium. + +## Sound + +### Cloudflare shares the custom endpoint's `baseURL` field and `--base-url` flag + +- **When:** the re-port. +- **The choice:** a Cloudflare gateway URL looks like + `https://gateway.ai.cloudflare.com/v1/ACCOUNT/GATEWAY` or a custom domain, and it + is different for every account, so auth must save it. The custom provider already + saves an endpoint URL as `baseURL`, sets it with `--base-url`, and validates it + with `validateBaseURL`. Cloudflare reuses all three, so its saved record is + `{provider: "cloudflare", baseURL, apiKey}`. It has no `model`, because the route + fixes the model, and `--model` is rejected for it. The unbuilt alternative gave + Cloudflare its own `gatewayURL` field, flag and validator, which would be a second + owner for "the endpoint this record points at". +- **The gap:** the request did not name the record shape or flag. +- **The reach:** tooling that writes Cloudflare records, such as setup scripts, uses + `--base-url`. +- **Verdict:** sound. One field and one validator own the saved endpoint. +- **Confidence:** medium. -The provider-support record rules out endpoint overrides on search and doctor. -A Cloudflare gateway URL is account-specific, so some input is unavoidable. Saving -it in the one auth record keeps the rule intact: a human chooses provider, key and -gateway once, and agents search without routing decisions. An environment variable -or a search flag would reopen the precedence questions that record closed. +### Cloudflare is listed after the fixed presets and before "Custom endpoint" -### Share the custom endpoint's `baseURL` field and `--base-url` flag +- **When:** the re-port. +- **The choice:** `jg auth` shows a menu of providers. The order is the fixed presets, + then Cloudflare AI Gateway, then Custom endpoint, so the open-ended catch-all stays + last. One exported list, `credentialProviders`, now owns that order. The menu, + the `--provider` validation and the error messages all read that list instead of + restating it. The unbuilt alternative appended Cloudflare after Custom, which would + leave upstream's menu test untouched but bury a named provider below the + catch-all. +- **The gap:** menu order was unspecified. +- **The reach:** adding a provider later means adding it to one list. +- **Verdict:** sound. +- **Confidence:** medium. -The custom provider already defines how a saved record names its endpoint and how -that URL is validated. A Cloudflare-only `gatewayURL` field and `--gateway-url` -flag would duplicate both. Cloudflare reuses them and differs only in having no -`model`, which its route fixes. +### A gateway URL containing a `/workers-ai` or `/compat` route is rejected -### A provider of its own, not a custom endpoint preset +- **When:** the re-port, after review. +- **The choice:** Jevgrep appends `/workers-ai/run/typesafe/jev` to the saved gateway + URL. A user who copies a full route from Cloudflare's dashboard, for example + `.../GATEWAY/workers-ai/run/typesafe/jev`, would otherwise save it, and every + request would go to `.../workers-ai/run/typesafe/jev/workers-ai/run/typesafe/jev` + and fail at `jg doctor` with an opaque 404. Auth now refuses it and asks for the + gateway URL itself. A trailing slash is simply removed, because it is unambiguous. + The unbuilt alternative stripped the route silently, which guesses at what the user + meant. +- **The gap:** URL shape was specified only as "the gateway URL". +- **The reach:** a gateway whose custom domain path legitimately contains + `/workers-ai` or `/compat` would be refused; none is known. +- **Verdict:** sound. +- **Confidence:** medium. + +### The request is adapted inside the evaluator's existing fetch hook + +- **When:** the first implementation, kept by the re-port. +- **The choice:** the TypeSafe SDK builds every Jev request as + `POST /systemone` with `{model, state, questions}` and a Bearer token. The + evaluator already wraps the SDK's `fetch` to count requests and honour 429 + cooldowns. For Cloudflare, that wrapper rewrites the one request: new + destination, token moved to another header, `model` removed, and the + `{state, result}` reply unwrapped. The unbuilt alternatives were a second + evaluation model for Cloudflare, or a change to the published SDK. +- **The gap:** the SDK has no Workers AI mode. +- **The reach:** question mapping, answer validation, retries and error + classification stay on one path for every provider. An SDK change to the request + body would reach Cloudflare through this adapter. +- **Verdict:** sound. +- **Confidence:** high. + +### The token travels only as `cf-aig-authorization`, and redirects are refused + +- **When:** the first implementation. +- **The choice:** research showed the gateway accepts the token either as + `Authorization` or as `cf-aig-authorization`, the header Cloudflare documents for + gateway authentication and Unified Billing. The token is sent once, under the + documented name. Browsers' `fetch` strips `Authorization` when a redirect crosses + origins, but it would forward a custom header like `cf-aig-authorization`. So if + the gateway ever answered with a redirect to another host, the token could leak. + The request sets `redirect: "error"`, so it fails instead. +- **The gap:** header choice and redirect policy were unspecified. +- **The reach:** a gateway that redirects cannot be used; none does today. +- **Verdict:** sound. +- **Confidence:** high. + +### Only a `{state: "Completed", result}` reply yields an answer + +- **When:** the first implementation. +- **The choice:** Workers AI wraps answers as `{state, result}`. If the state is + anything but `Completed`, or `result` is missing, the adapter passes `null` on, which + fails the SDK's answer validation. That makes it an invalid answer, which is retried + and then reported as incomplete. It is never read as "the file is irrelevant". The + unbuilt alternative took `result` whenever present, which could score a + half-finished job. +- **The gap:** only the Completed shape was observed live. +- **The reach:** a future asynchronous mode would need explicit polling support. +- **Verdict:** sound. +- **Confidence:** high. + +### The gateway URL is saved by auth, not supplied per search -A custom endpoint is a TypeSafe-compatible base URL plus a model. Cloudflare's route -takes a different path, auth header and envelope, so saving it as a custom record -would send requests Cloudflare rejects. It is listed after the fixed presets and -before "Custom endpoint", which stays last as the catch-all. +- **When:** the first implementation. +- **The choice:** the provider-support record rules out endpoint overrides on search + and doctor, so a human chooses provider, key and endpoint once and agents search + without routing decisions. The Cloudflare URL is saved in the same record. An + environment variable or a search flag would reopen the precedence questions that + record closed. +- **The gap:** Cloudflare is the first preset-like provider with an account-specific + URL. +- **The reach:** switching gateways means rerunning auth. +- **Verdict:** sound. +- **Confidence:** high. -### Adapt the request in the evaluator's fetch hook +### Cloudflare is a provider of its own, not a custom endpoint -The alternatives were a separate evaluation model for Cloudflare or an unpublished -change to the TypeSafe SDK adapter. Adapting the one request inside the existing -fetch hook keeps a single adapter. It also keeps retries, the attempt guard and -answer validation on one path. The adaptation is limited to destination, auth -header, the `model` field and the envelope. +- **When:** the re-port. +- **The choice:** the custom provider posts a TypeSafe body to `/systemone`. + Cloudflare returns `2008 Invalid provider` for that path, so a user who configured + Cloudflare as a custom endpoint would fail at doctor. Cloudflare therefore keeps its + own provider id and wire adaptation. +- **The gap:** upstream added the custom provider after the first implementation. +- **The reach:** two providers share `baseURL` but differ in transport. +- **Verdict:** sound. +- **Confidence:** high. -### `cf-aig-authorization` only, redirects refused +### The answer cache needs no Cloudflare-specific identity -Both headers authenticated during research. The gateway header is the one -Cloudflare documents for gateway authentication and Unified Billing, so the token -is sent once, under that name. Refusing redirects closes the one case where fetch -forwards a custom header across origins. +- **When:** the re-port. +- **The choice:** cached answers are keyed by a namespace that includes the provider + name and the endpoint base URL. A Cloudflare answer is keyed by + `provider: "cloudflare"` plus that account's gateway URL, so it can never satisfy + another provider's or another gateway's lookup. An extra route or protocol tag in + the key would add no separation. +- **The gap:** cache identity for a transport-adapted provider was unspecified. +- **The reach:** a future change to Cloudflare's wire format should bump the shared + protocol or prompt version, as for any provider. +- **Verdict:** sound. +- **Confidence:** high. -## Within the specification +## Trivial discretion -Loopback `http` exists only so tests and local proxies can reach a fixture. Tests -still use a canonical `https` gateway URL through the test-only preload wherever the -packed CLI is exercised. +Two calls: auth and doctor label the provider with the gateway host, as the custom +provider does; and loopback `http` is accepted because the shared validator already +allows it for local proxies and test fixtures.