diff --git a/README.md b/README.md index 63f3422..fdf5cae 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, Linux, or Windows**, and a key for **Vercel AI Gateway, TypeSafe, OpenRouter, OpenCode Zen, or a custom TypeSafe-compatible endpoint**. +Requires **Node.js 22+**, **macOS, Linux, or Windows**, 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 db73144..a25d5e1 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -66,6 +66,19 @@ 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. 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 +``` + Credentials are saved under `$XDG_CONFIG_HOME/jevgrep/credentials.json`, or `~/.config/jevgrep/credentials.json`. On Windows, config defaults to `%APPDATA%\jevgrep` and cache to `%LOCALAPPDATA%\jevgrep`. diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ee4700a..2cb6b8e 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,12 +1,15 @@ import { parseArgs } from "node:util"; import type { SearchInput } from "@repo/core"; -import { providers, customProviderId, isCredentialProvider } from "@repo/core/providers"; +import { + cloudflareProviderId, + credentialProviders, + customProviderId, + isCredentialProvider, +} from "@repo/core/providers"; import { CliError } from "./errors"; import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_SOURCE_BYTES } from "./render"; import type { AuthOptions } from "./auth"; -const credentialProviders = [...Object.keys(providers), customProviderId]; - export type Command = | { kind: "help" | "version" | "doctor" | "cache-clear" } | { kind: "skill"; agents: string[]; global: boolean; yes: boolean } @@ -84,8 +87,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") { @@ -217,9 +229,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 c582f96..ea05a23 100644 --- a/apps/cli/src/auth.ts +++ b/apps/cli/src/auth.ts @@ -3,6 +3,9 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { isCancel, password, select, text } from "@clack/prompts"; import { + cloudflareProviderId, + cloudflareProviderLabel, + credentialProviders, customProviderId, customProviderLabel, endpointFor, @@ -26,7 +29,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; @@ -48,6 +52,15 @@ function customEndpoint(options: AuthOptions): CustomEndpoint { } } +/** Validate and normalize a Cloudflare gateway URL the way the evaluator will use it. */ +function cloudflareGateway(baseURL: string | undefined): string { + try { + return endpointFor({ provider: cloudflareProviderId, baseURL }).baseURL; + } catch (error) { + throw new CliError(invalid(error)); + } +} + async function readKey(signal: AbortSignal): Promise { const chunks: Buffer[] = []; let bytes = 0; @@ -98,6 +111,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 = storageDirectory("config"); signal.throwIfAborted(); @@ -128,25 +159,28 @@ 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 ${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: customProviderId, label: customProviderLabel }, - ], + options: credentialProviders.map((value) => ({ + value, + label: + value === customProviderId + ? customProviderLabel + : value === cloudflareProviderId + ? cloudflareProviderLabel + : providers[value].label, + })), output: process.stdout, signal, }); @@ -158,6 +192,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, @@ -190,6 +231,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 8ffabf8..2dfbe16 100644 --- a/apps/cli/test/args.test.ts +++ b/apps/cli/test/args.test.ts @@ -176,3 +176,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 4b1d6cd..944dd1c 100644 --- a/apps/cli/test/process.test.ts +++ b/apps/cli/test/process.test.ts @@ -43,6 +43,7 @@ testInDockerPosix("interactive auth hides input and exits 130 on interruption", 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 3468917..d5505d5 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); @@ -285,3 +295,33 @@ 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 input = JSON.parse(String(init?.body)); + delete input.model; + 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 = (await response.json()) as { state?: unknown; result?: unknown } | null; + // Only a Completed envelope carries an answer; anything else fails answer validation. + 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 18f36fd..831de06 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 }; @@ -41,15 +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 || 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 { @@ -84,9 +99,27 @@ 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. + 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 new file mode 100644 index 0000000..ef5a4b2 --- /dev/null +++ b/specs/done/cloudflare-provider/README.md @@ -0,0 +1,92 @@ +# Cloudflare AI Gateway provider + +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. + +## 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, 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 --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 + 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 + +- 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. +- 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 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 + +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) 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. +[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, [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. + +## 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. + +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. diff --git a/specs/done/cloudflare-provider/assets/verification.md b/specs/done/cloudflare-provider/assets/verification.md new file mode 100644 index 0000000..292635f --- /dev/null +++ b/specs/done/cloudflare-provider/assets/verification.md @@ -0,0 +1,33 @@ +# Cloudflare provider verification + +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 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 | + +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 | +| `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 +(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 new file mode 100644 index 0000000..4338f9b --- /dev/null +++ b/specs/done/cloudflare-provider/choices.md @@ -0,0 +1,192 @@ +# Cloudflare provider choices + +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. + +## 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. + +### Cloudflare is listed after the fixed presets and before "Custom endpoint" + +- **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. + +### A gateway URL containing a `/workers-ai` or `/compat` route is rejected + +- **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 + +- **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. + +### Cloudflare is a provider of its own, not a custom endpoint + +- **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. + +### The answer cache needs no Cloudflare-specific identity + +- **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. + +## Trivial discretion + +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. 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 51fbbd4..5e20a40 100644 --- a/test/evaluator.test.ts +++ b/test/evaluator.test.ts @@ -685,3 +685,149 @@ test("token-queued requests revalidate source after waiting", async () => { expect(await queued).toMatchObject({ kind: "source-invalid" }); expect(calls).toBe(3); }, 10_000); + +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 f7a7d9d..dd2ddd9 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" }); @@ -1181,8 +1193,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", @@ -1193,6 +1206,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); @@ -1282,9 +1296,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); @@ -1304,8 +1325,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);