Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
27 changes: 21 additions & 6 deletions apps/cli/src/args.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 54 additions & 11 deletions apps/cli/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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<string> {
const chunks: Buffer[] = [];
let bytes = 0;
Expand Down Expand Up @@ -98,6 +111,24 @@ async function promptEndpoint(signal: AbortSignal): Promise<CustomEndpoint> {
return customEndpoint({ baseURL, model });
}

async function promptGateway(signal: AbortSignal): Promise<string> {
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();
Expand Down Expand Up @@ -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<CredentialProvider>({
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,
});
Expand All @@ -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,
Expand Down Expand Up @@ -190,6 +231,8 @@ export async function loadCredentials(): Promise<Credentials> {
apiKey,
};
}
if (provider === cloudflareProviderId)
return { provider, baseURL: cloudflareGateway(credentials.baseURL), apiKey };
return { provider, apiKey };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
Expand Down
32 changes: 32 additions & 0 deletions apps/cli/test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
21 changes: 19 additions & 2 deletions apps/cli/test/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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");
Expand All @@ -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"]);
}
Expand Down
44 changes: 42 additions & 2 deletions packages/core/src/evaluator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Response> {
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 });
}
Loading