Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Model access: OpenRouter as a third provider, for tasks and agent review (#79)

OpenRouter keys (sk-or-, opt-in to its terms), Claude Code and Codex via OpenRouter with a chosen model, a proxy allowlist of its model endpoints only, Diffinery's own attribution headers, runs pinned to their model and routing, the largest output cap booked, and compaction booked at 128k.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2
Canberk Demirci authored · GitHub committed signed1 parent 2b53f63commit 283876325b4cb4cdae83a9373e4050e07b0e666dBrowse files
19 files changed+474−102
Files (19)
modifiedapps/diffinery/docs/agents/review.md+4−4View file
@@ -12,7 +12,7 @@
1212 You need to be a **repository admin**, with an agent you manage that has write access (see [Agent tasks](/docs/agents/tasks#before-you-start)) and your own model key under **Settings → Model access**.
1313
1414 1. Open the repository's **Settings → Agent review**.
15-2. Pick the **agent** and the **runtime**: Claude Code (your Anthropic key), Codex (your OpenAI key), or a custom command.
15+2. Pick the **agent** and the **runtime**: Claude Code (your Anthropic key), Codex (your OpenAI key), either of them via OpenRouter on a model you choose (your OpenRouter key; see [OpenRouter](/docs/agents/tasks#openrouter)), or a custom command.
1616 3. Choose **when to review**:
1717 - **Every pull request, when it opens and on every push** (the default).
1818 - **Once per pull request**, when it opens or is marked ready for review.
@@ -113,9 +113,9 @@
113113
114114 Diffinery counts tokens itself, as requests pass through the model proxy:
115115
116-- Before a request goes to the provider, the proxy books the most it may cost: a token per 3 bytes of request, plus the output it allows (`max_tokens`, or 32,000 when it sets none). If that could take the review past its budget, the request is refused and the review stops. So requests sent at once can't overrun the budget together.
116+- Before a request goes to the provider, the proxy books the most it may cost: a token per 3 bytes of request, plus the output it allows (its `max_tokens` or the endpoint's equivalent, or 32,000 when it sets none; a Codex compaction, which can't be capped, books 128,000). If that could take the review past its budget, the request is refused and the review stops. So requests sent at once can't overrun the budget together.
117117 - Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read, stays charged its booking. An error response costs nothing.
118-- A review can't start background responses, use `previous_response_id`, `conversation` or `prompt` (they pull in context stored at the provider that the booking can't see), or ask for more than one answer (`n`). A review's request that sets no output cap is sent with one of 32,000 tokens, so its booking holds.
118+- A review can't start background responses, use `previous_response_id`, `conversation` or `prompt` (they pull in context stored at the provider that the booking can't see), ask for more than one answer (`n`), or set routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`). A review through OpenRouter asks only for its configured model. A review's request is sent with every output cap set to the one booked (the largest it set, or 32,000 when it set none), so its booking holds.
119119
120120 Every run's model requests and tokens show in its transcript and in the API's `runs` (`model_requests`, `tokens`).
121121
@@ -132,7 +132,7 @@
132132 | Endpoint | Does | Who |
133133 | --- | --- | --- |
134134 | `GET /repos/{owner}/{repo}/diffinery/agent-review` | The repository's agent review settings | Write access |
135-| `PUT /repos/{owner}/{repo}/diffinery/agent-review` | Turn on, change (`agent`, `runtime`, `command`, `trigger`: `push`, `open` or `request`, `authors`: `all`, `people` or `agents`, `drafts`, `autofix`, `daily_limit`: 1 to 1000, `tokens_per_review`, `accept_terms: true`) or turn off (`enabled: false`) | Repository admin, browser session only |
135+| `PUT /repos/{owner}/{repo}/diffinery/agent-review` | Turn on, change (`agent`, `runtime`, `provider` and `model` for OpenRouter, `command`, `trigger`: `push`, `open` or `request`, `authors`: `all`, `people` or `agents`, `drafts`, `autofix`, `daily_limit`: 1 to 1000, `tokens_per_review`, `accept_terms: true`) or turn off (`enabled: false`) | Repository admin, browser session only |
136136 | `GET /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | The pull request's review task, the last reviewed head and every finding with its state (`open`, `fixed`, `dismissed`) | Write access |
137137 | `POST /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | Ask for a review (`429` past a daily limit) | People with write access (not agents) |
138138
modifiedapps/diffinery/docs/agents/tasks.md+17−3View file
@@ -15,7 +15,7 @@
1515
1616 1. **Write access** to the repository.
1717 2. **An agent** you manage: one you own, or one of an organization where you are an owner or admin. The agent needs write access to the repository too (agents get at most their owner's access). Create one under **Settings → Agents**; see [Agents](/docs/agents/overview).
18-3. **Your own model key** under **Settings → Model access**: an Anthropic API key for Claude Code, an OpenAI API key for Codex. See [Model access](#model-access).
18+3. **Your own model key** under **Settings → Model access**: an Anthropic API key for Claude Code, an OpenAI API key for Codex, or an OpenRouter key for either. See [Model access](#model-access).
1919
2020 ## Start a task
2121
@@ -63,8 +63,20 @@
6363 | --- | --- | --- |
6464 | **Claude Code** | Your Anthropic API key | `claude -p "$DIFFINERY_TASK_PROMPT" --output-format stream-json --verbose --permission-mode bypassPermissions` |
6565 | **Codex** | Your OpenAI API key | `codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$DIFFINERY_TASK_PROMPT"` |
66-| **Custom command** | Nothing required | Your command, with the goal in `$DIFFINERY_TASK_PROMPT`, and `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` and `OPENAI_BASE_URL` / `OPENAI_API_KEY` set for each provider you have a key for |
66+| **Claude Code via OpenRouter** | Your OpenRouter key | The same, with `ANTHROPIC_BASE_URL` on OpenRouter's Anthropic-compatible endpoint (through the proxy), the token as `ANTHROPIC_AUTH_TOKEN`, and the model you choose for every model role (`ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `_SONNET_`, `_HAIKU_`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`) |
67+| **Codex via OpenRouter** | Your OpenRouter key | The same, with an `openrouter` model provider on OpenRouter's Responses API (`-c model_provider=openrouter -c model_providers.openrouter...`) and `-m "$DIFFINERY_MODEL"` |
68+| **Custom command** | Nothing required | Your command, with the goal in `$DIFFINERY_TASK_PROMPT`, and `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`, `OPENAI_BASE_URL` / `OPENAI_API_KEY` and `OPENROUTER_BASE_URL` / `OPENROUTER_API_KEY` set for each provider you have a key for |
6769
70+### OpenRouter
71+
72+With an OpenRouter key, Claude Code and Codex can run on a model you choose there. Pick **Claude Code via OpenRouter** or **Codex via OpenRouter** as the runtime, and name the model (an id from [openrouter.ai/models](https://openrouter.ai/models)). Left empty, Claude Code uses `~anthropic/claude-sonnet-latest` and Codex `~openai/gpt-sol-latest`. Over the API, send `"provider": "openrouter"` and `"model"`.
73+
74+OpenRouter supports Claude Code fully only with Anthropic models served by Anthropic, and Codex works best with OpenAI models. Other models may not handle the runtime's tools well. Usage, including OpenRouter's fees, is billed to your OpenRouter account.
75+
76+A run through OpenRouter is **pinned to the model you chose**. The model proxy refuses a request for any other model, and requests that set OpenRouter's routing or paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`). So code in the repository can't switch the run to a pricier model. A custom command that uses your OpenRouter key isn't pinned, since it picks its own model.
77+
78+Diffinery names itself to OpenRouter as the app (`HTTP-Referer` and `X-Title`). The sandbox's own attribution and `X-OpenRouter-*` headers are dropped.
79+
6880 The runtimes run unmodified, from the vendors' own packages, at pinned versions: Claude Code 2.1.296 (`@anthropic-ai/claude-code`) and Codex 0.162.1 (`@openai/codex`). Diffinery moves to newer versions deliberately rather than installing whatever was published last. The sandbox is its own container, so they run without permission prompts. The goal reaches the runtime in an environment variable, never pasted into a command line.
6981
7082 The prompt tells the agent which task it is working on, that Diffinery pushes and opens the pull request, and not to push or change git credentials itself.
@@ -75,8 +87,9 @@
7587
7688 - **Anthropic**, for Claude Code. Create a key at console.anthropic.com.
7789 - **OpenAI**, for Codex. Create a key at platform.openai.com.
90+- **OpenRouter**, for Claude Code or Codex on a model you choose there (see [OpenRouter](#openrouter)). Create a key at openrouter.ai/settings/keys.
7891
79-Saving a key asks you to confirm that you use that provider through Diffinery **under your own account and the provider's terms**, with links to them (Anthropic's [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) and [Usage Policy](https://www.anthropic.com/legal/aup); OpenAI's [Terms and Policies](https://openai.com/policies/)). The provider bills your account. Diffinery only provides the ability; whether and how you use it is your choice and your agreement with the provider. Your confirmation is stored with its date.
92+Saving a key asks you to confirm that you use that provider through Diffinery **under your own account and the provider's terms**, with links to them (Anthropic's [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) and [Usage Policy](https://www.anthropic.com/legal/aup); OpenAI's [Terms and Policies](https://openai.com/policies/); OpenRouter's [Terms of Service](https://openrouter.ai/terms) and [Privacy Policy](https://openrouter.ai/privacy)). The provider bills your account. Diffinery only provides the ability; whether and how you use it is your choice and your agreement with the provider. Your confirmation is stored with its date.
8093
8194 - Keys are encrypted at rest and **write-only**: no page, API call or MCP tool returns one. You see the last four characters.
8295 - Only you, signed in in a browser, can save, replace or delete your keys. No token, agent, job or sandbox can.
@@ -96,6 +109,7 @@
96109 | --- | --- |
97110 | Anthropic | `POST /v1/messages`, `POST /v1/messages/count_tokens`, `GET /v1/models` and `GET /v1/models/{id}` |
98111 | OpenAI | `POST /v1/responses`, `POST /v1/responses/compact`, `GET /v1/responses/{id}`, `POST /v1/chat/completions`, `GET /v1/models` and `GET /v1/models/{id}` |
112+ | OpenRouter | `POST /v1/messages`, `POST /v1/messages/count_tokens`, `POST /v1/responses`, `POST /v1/chat/completions`, `POST /v1/completions` and `GET /v1/models`. Never keys, credits, generation stats or anything else on your OpenRouter account. |
99113
100114 4. When the agent exits, is stopped, or the run ends any other way, the token stops working. If you lose access to the repository or the agent, the task stops.
101115
modifiedapps/diffinery/src/agentic/ProjectDO.ts+21−9View file
@@ -6,9 +6,9 @@
66 import { taskCaller, TaskAccessError } from "./access.js";
77 import { PROVIDERS, type Provider } from "./keys.js";
88 import { enqueueMail } from "../mail/send.js";
9-import { formatTokens, weighted, type Usage } from "./usage.js";
9+import { formatTokens, pinRefusal, weighted, type Usage } from "./usage.js";
1010 import { countText, dayAgo, findingComment, rulesFiles, findingId, MAX_AUTOFIXES, placeFindings, REPO_DAILY_DEFAULT, REVIEW_APP, REVIEW_CHECK, REVIEW_CREATOR, REVIEW_MAX_MODEL_CALLS, REVIEW_TIMEOUT_MS, REVIEW_TOKENS_DEFAULT, reviewBody, reviewLimit, reviewOutput, reviewPrompt, fixGoal, shouldMailLimit, type Finding, type PriorFinding, type ReviewLimit, type ReviewSettings } from "./review.js";
11-import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, taskBranch, taskTitle, type RuntimeId, type TranscriptLine } from "./runtimes.js";
11+import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js";
1212
1313 /**
1414 * One per repository: its agent tasks. A task is a goal a person gives one of
@@ -41,6 +41,9 @@
4141 title: string;
4242 runtime: RuntimeId;
4343 command: string | null;
44+ /** Where the runtime's model calls go when not its own provider (OpenRouter), and the model asked for there. */
45+ via: Provider | null;
46+ model: string | null;
4447 agent_id: string;
4548 agent_login: string;
4649 person_id: string;
@@ -188,6 +191,8 @@
188191 ["runs", "cache_read_tokens", "cache_read_tokens INTEGER NOT NULL DEFAULT 0"],
189192 ["runs", "cache_write_tokens", "cache_write_tokens INTEGER NOT NULL DEFAULT 0"],
190193 ["tasks", "autofix_of", "autofix_of INTEGER"],
194+ ["tasks", "via", "via TEXT"],
195+ ["tasks", "model", "model TEXT"],
191196 ];
192197
193198 /** A finding as stored. */
@@ -225,6 +230,7 @@
225230 goal: string;
226231 runtime: RuntimeId;
227232 command: string | null;
233+ route?: ModelRoute | null;
228234 agent: { id: string; login: string };
229235 person: { id: string; login: string };
230236 base: string;
@@ -287,7 +293,7 @@
287293 const at = now();
288294 const branch = input.pull?.head ?? taskBranch(number, input.goal);
289295 this.sql.exec(
290- "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?)",
296+ "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, via, model, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?)",
291297 number,
292298 crypto.randomUUID(),
293299 input.repoId,
@@ -295,6 +301,8 @@
295301 taskTitle(input.goal),
296302 input.runtime,
297303 input.command,
304+ input.route?.via ?? null,
305+ input.route?.model ?? null,
298306 input.agent.id,
299307 input.agent.login,
300308 input.person.id,
@@ -418,6 +426,8 @@
418426 t.number,
419427 );
420428 }
429+ this.sql.exec("UPDATE tasks SET via = ?, model = ? WHERE number = ?", s.via ?? null, s.model ?? null, t.number);
430+ t = this.mustTask(t.number);
421431 if (this.activeRun(t.number)) {
422432 // One more review after this one covers every push in between.
423433 const who = input.requestedBy ?? "automatic";
@@ -466,7 +476,7 @@
466476 */
467477 async modelAuth(
468478 token: string,
469- req: { provider: Provider; estimate: Usage | null; reviewRefuse?: string },
479+ req: { provider: Provider; estimate: Usage | null; reviewRefuse?: string; model?: string | null; routing?: string },
470480 ): Promise<{ task: number; run: number; personId: string; agentId: string; repoId: string; review: boolean } | { limit: string } | { refuse: string } | null> {
471481 const hash = await sha256(token);
472482 const r = this.sql.exec<RunRow>("SELECT * FROM runs WHERE token_hash = ? AND step = 'agent'", hash).toArray()[0];
@@ -474,6 +484,8 @@
474484 const t = this.task(r.task)!;
475485 if (!(JSON.parse(r.providers) as Provider[]).includes(req.provider)) return { refuse: `this task doesn't use ${PROVIDERS[req.provider].name}` };
476486 if (t.kind === "review" && req.reviewRefuse) return { refuse: req.reviewRefuse };
487+ const pinned = pinRefusal(req, { review: t.kind === "review", model: t.model });
488+ if (pinned) return { refuse: pinned };
477489 const calls = r.model_calls + 1;
478490 const cost = req.estimate ? weighted(req.estimate) : 0;
479491 if (t.kind === "review") {
@@ -528,10 +540,10 @@
528540 const at = now();
529541 this.sql.exec("INSERT INTO runs (task, seq, kind, message, prompt, providers, step, started_at) VALUES (?, ?, ?, ?, ?, ?, 'boot', ?)", number, seq, kind, message, prompt, JSON.stringify(providers), at);
530542 this.sql.exec("UPDATE tasks SET state = 'running', error = NULL, updated_at = ? WHERE number = ?", at, number);
531- if (kind === "review") this.note(number, seq, `${message ? `${message} asked for a review` : "Reviewing automatically"}, as ${t.agent_login} with ${RUNTIMES[t.runtime].name} and ${t.person_login}'s model key.`);
543+ if (kind === "review") this.note(number, seq, `${message ? `${message} asked for a review` : "Reviewing automatically"}, as ${t.agent_login} with ${runtimeLabel(t.runtime, t)} and ${t.person_login}'s model key.`);
532544 else {
533545 if (message) this.line(number, seq, "note", `${t.person_login}: ${message}`);
534- this.note(number, seq, kind === "retry" ? "Retrying." : kind === "message" ? "Starting a new run with the message." : `${t.person_login} started the task with ${RUNTIMES[t.runtime].name}.`);
546+ this.note(number, seq, kind === "retry" ? "Retrying." : kind === "message" ? "Starting a new run with the message." : `${t.person_login} started the task with ${runtimeLabel(t.runtime, t)}.`);
535547 }
536548 this.arm(0);
537549 }
@@ -576,8 +588,8 @@
576588 const providers = JSON.parse(r.providers) as Provider[];
577589 const token = `dmt_${t.repo_id}_${randomToken(32)}`;
578590 this.sql.exec("UPDATE runs SET token_hash = ?, checked_at = ? WHERE task = ? AND seq = ?", await sha256(token), now(), r.task, r.seq);
579- const { env, secret } = agentEnv(this.env.PUBLIC_URL, providers, token);
580- return this.startProc(r, "agent", RUNTIMES[t.runtime].command(t.command ?? undefined), { ...env, DIFFINERY_TASK_PROMPT: r.prompt }, secret, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null);
591+ const { env, secret } = agentEnv(this.env.PUBLIC_URL, providers, token, { runtime: t.runtime, route: { via: t.via, model: t.model } });
592+ return this.startProc(r, "agent", RUNTIMES[t.runtime].command(t.command ?? undefined, t.via ?? undefined), { ...env, DIFFINERY_TASK_PROMPT: r.prompt }, secret, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null);
581593 }
582594 if (!r.checked_at || Date.parse(r.checked_at) + RECHECK_MS < Date.now()) {
583595 await this.checkAccess(t);
@@ -771,7 +783,7 @@
771783 else if (this.dailyRuns() >= repoLimit) this.note(t.number, r.seq, `Autofix didn't start: the repository reached its daily limit of ${repoLimit} reviews and autofix runs.`);
772784 else {
773785 this.sql.exec("UPDATE tasks SET autofixes = autofixes + 1 WHERE number = ?", t.number);
774- const fix = await this.create({ repoId: t.repo_id, goal: fixGoal(pr, blocking), runtime: settings.runtime, command: settings.command, agent: settings.agent, person: settings.person, base: issue.pull.base, providers: JSON.parse(r.providers) as Provider[], pull: { number: pr, head: issue.pull.head }, autofixOf: t.number });
786+ const fix = await this.create({ repoId: t.repo_id, goal: fixGoal(pr, blocking), runtime: settings.runtime, command: settings.command, route: { via: settings.via ?? null, model: settings.model ?? null }, agent: settings.agent, person: settings.person, base: issue.pull.base, providers: JSON.parse(r.providers) as Provider[], pull: { number: pr, head: issue.pull.head }, autofixOf: t.number });
775787 this.note(t.number, r.seq, `Autofix started task #${fix.number} for the blocking ${blocking.length === 1 ? "finding" : "findings"}.`);
776788 }
777789 }
modifiedapps/diffinery/src/agentic/api.ts+2−2View file
@@ -41,7 +41,7 @@
4141 // A key is spent by tasks this person starts; only they, in a browser, can set it.
4242 const me = requirePersonSession(c);
4343 const provider = c.req.param("provider");
44- if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic or openai");
44+ if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
4545 const b = await jsonBody<{ key?: unknown; accept_terms?: unknown }>(c);
4646 if (typeof b.key !== "string") throw new HttpError(422, "`key` is required");
4747 const k = await new ModelKeys(c.env).set(me, provider, b.key, b.accept_terms === true);
@@ -52,7 +52,7 @@
5252 agenticApi.delete("/diffinery/model-keys/:provider", async (c) => {
5353 const me = requirePersonSession(c);
5454 const provider = c.req.param("provider");
55- if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic or openai");
55+ if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
5656 if (!(await new ModelKeys(c.env).delete(me.id, provider))) throw new HttpError(404, "no key for that provider");
5757 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider }));
5858 return c.body(null, 204);
modifiedapps/diffinery/src/agentic/keys.ts+12−2View file
@@ -13,7 +13,7 @@
1313 * key never reaches a sandbox.
1414 */
1515
16-export type Provider = "anthropic" | "openai";
16+export type Provider = "anthropic" | "openai" | "openrouter";
1717
1818 export interface ProviderInfo {
1919 id: Provider;
@@ -46,9 +46,19 @@
4646 ],
4747 pattern: /^sk-[A-Za-z0-9_-]{20,400}$/,
4848 },
49+ openrouter: {
50+ id: "openrouter",
51+ name: "OpenRouter",
52+ keysUrl: "https://openrouter.ai/settings/keys",
53+ terms: [
54+ { label: "OpenRouter Terms of Service", url: "https://openrouter.ai/terms" },
55+ { label: "OpenRouter Privacy Policy", url: "https://openrouter.ai/privacy" },
56+ ],
57+ pattern: /^sk-or-[A-Za-z0-9_-]{20,400}$/,
58+ },
4959 };
5060
51-export const isProvider = (p: string): p is Provider => p === "anthropic" || p === "openai";
61+export const isProvider = (p: string): p is Provider => p === "anthropic" || p === "openai" || p === "openrouter";
5262
5363 export interface ModelKeyInfo {
5464 provider: Provider;
modifiedapps/diffinery/src/agentic/proxy.ts+22−4View file
@@ -17,7 +17,7 @@
1717 * credential and works nowhere else.
1818 */
1919
20-const UPSTREAM: Record<Provider, string> = { anthropic: "https://api.anthropic.com", openai: "https://api.openai.com" };
20+const UPSTREAM: Record<Provider, string> = { anthropic: "https://api.anthropic.com", openai: "https://api.openai.com", openrouter: "https://openrouter.ai/api" };
2121
2222 /**
2323 * Only the model endpoints the runtimes use. The person's key could reach the
@@ -38,6 +38,16 @@
3838 ["POST", /^\/v1\/chat\/completions$/],
3939 ["GET", /^\/v1\/models(\/[A-Za-z0-9._:-]+)?$/],
4040 ],
41+ // OpenRouter: its Anthropic-compatible Messages endpoint (Claude Code), the OpenAI-compatible ones (Codex,
42+ // custom commands) and the model list. Never keys, credits, generation stats or anything else on the account.
43+ openrouter: [
44+ ["POST", /^\/v1\/messages$/],
45+ ["POST", /^\/v1\/messages\/count_tokens$/],
46+ ["POST", /^\/v1\/responses$/],
47+ ["POST", /^\/v1\/chat\/completions$/],
48+ ["POST", /^\/v1\/completions$/],
49+ ["GET", /^\/v1\/models$/],
50+ ],
4151 };
4252
4353 export const proxyAllows = (provider: Provider, method: string, path: string): boolean => ALLOWED[provider].some(([m, re]) => m === method && re.test(path));
@@ -45,6 +55,9 @@
4555 /** Request headers that never go upstream: credentials, cookies and the hop's own. */
4656 const DROP = new Set(["authorization", "x-api-key", "cookie", "host", "content-length", "connection", "cf-connecting-ip", "cf-ipcountry", "cf-ray", "cf-visitor", "x-forwarded-for", "x-forwarded-proto", "x-real-ip"]);
4757
58+/** OpenRouter's app attribution and request options: only Diffinery sets these, never the sandbox. */
59+const ATTRIBUTION = /^(x-openrouter-.*|x-title|http-referer|referer)$/i;
60+
4861 const err = (status: number, message: string) =>
4962 new Response(JSON.stringify({ type: "error", error: { type: status === 401 ? "authentication_error" : status === 404 ? "not_found_error" : status === 429 ? "rate_limit_error" : "permission_error", message } }), { status, headers: { "content-type": "application/json" } });
5063
@@ -78,7 +91,7 @@
7891 body = prepared.body;
7992 }
8093 const { estimate } = prepared;
81- const run = await project.modelAuth(token, { provider, estimate, reviewRefuse: prepared.reviewRefuse });
94+ const run = await project.modelAuth(token, { provider, estimate, reviewRefuse: prepared.reviewRefuse, model: prepared.model, routing: prepared.routing });
8295 if (!run) return err(401, "this model token isn't valid: its task run has ended");
8396 if ("limit" in run) return err(429, run.limit);
8497 if ("refuse" in run) return err(403, run.refuse);
@@ -99,10 +112,15 @@
99112 return err(403, `no ${PROVIDERS[provider].name} key in Settings → Model access`);
100113 }
101114 const headers = new Headers();
102- for (const [k, v] of c.req.raw.headers) if (!DROP.has(k.toLowerCase())) headers.set(k, v);
115+ for (const [k, v] of c.req.raw.headers) if (!DROP.has(k.toLowerCase()) && !ATTRIBUTION.test(k)) headers.set(k, v);
103116 if (provider === "anthropic") headers.set("x-api-key", key);
104117 else headers.set("authorization", `Bearer ${key}`);
105- const base = (provider === "anthropic" ? env.MODEL_UPSTREAM_ANTHROPIC : env.MODEL_UPSTREAM_OPENAI) || UPSTREAM[provider];
118+ if (provider === "openrouter") {
119+ // OpenRouter's app attribution headers: Diffinery, not whatever the sandbox claims.
120+ headers.set("http-referer", env.PUBLIC_URL);
121+ headers.set("x-title", "Diffinery");
122+ }
123+ const base = (provider === "anthropic" ? env.MODEL_UPSTREAM_ANTHROPIC : provider === "openai" ? env.MODEL_UPSTREAM_OPENAI : env.MODEL_UPSTREAM_OPENROUTER) || UPSTREAM[provider];
106124 // If the provider can't be reached, the booking stays: it may have started the answer.
107125 const res = await fetch(`${base}${rest}${url.search}`, { method: c.req.method, headers, body, redirect: "manual" });
108126 const out = new Headers(res.headers);
modifiedapps/diffinery/src/agentic/review-web.tsx+13−14View file
@@ -7,11 +7,12 @@
77 import { KCheckbox, KRadio, KSelect } from "../web/kumo.js";
88 import { page as settingsPage } from "../web/repo-settings.js";
99 import { Form, Time, act, post, prm, repoUrl, statusOf, type WebApp } from "../web/ui.js";
10-import { ModelKeys, PROVIDERS } from "./keys.js";
10+import { ModelKeys, PROVIDERS, type Provider } from "./keys.js";
1111 import { AUTHORS, findingId, MAX_AUTOFIXES, PR_DAILY_AUTOMATIC, PR_DAILY_REVIEWS, REPO_DAILY_DEFAULT, REPO_DAILY_MAX, REVIEW_MAX_MODEL_CALLS, REVIEW_TIMEOUT_MS, REVIEW_TOKENS_DEFAULT, REVIEW_TOKENS_MAX, REVIEW_TOKENS_MIN, TRIGGERS, type ReviewSettings } from "./review.js";
12-import { askForReview, reviewSettings, runtimeOk, saveReviewSettings } from "./reviews.js";
13-import { RUNTIMES, type RuntimeId } from "./runtimes.js";
12+import { askForReview, reviewSettings, saveReviewSettings } from "./reviews.js";
13+import { parseRuntimeChoice, runtimeChoices, runtimeLabel } from "./runtimes.js";
1414 import { drivableAgents } from "./tasks.js";
15+import { OpenRouterModel } from "./tasks-web.js";
1516
1617 /**
1718 * Web pages for agent code review: Settings → Agent review, the PR page's
@@ -54,12 +55,8 @@
5455 const [current, agents, keys] = await Promise.all([reviewSettings(c.env, l.repo.id), drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id)]);
5556 const v = (k: string, d: string) => values?.[k] ?? d;
5657 const s: Partial<ReviewSettings> = current ?? {};
57- const runtimes = (Object.keys(RUNTIMES) as RuntimeId[]).map((id) => ({
58- value: id,
59- label: RUNTIMES[id].name,
60- description: runtimeOk(id, keys) ? RUNTIMES[id].description : `${RUNTIMES[id].description} Add your ${RUNTIMES[id].needs.map((p) => PROVIDERS[p].name).join(" and ")} key in Settings → Model access first.`,
61- disabled: !runtimeOk(id, keys),
62- }));
58+ const runtimes = runtimeChoices(keys, Object.fromEntries(Object.values(PROVIDERS).map((p) => [p.id, p.name])) as Record<Provider, string>);
59+ const currentChoice = s.runtime ? (s.via ? `${s.runtime}+${s.via}` : s.runtime) : undefined;
6360 const youPay = !current || current.person.id === me.id;
6461 return settingsPage(
6562 c,
@@ -75,7 +72,7 @@
7572 <div class={`flash ${current.enabled ? "info" : ""}`}>
7673 {current.enabled ? (
7774 <>
78- Agent review is <b>on</b>: {current.agent.login} reviews with {RUNTIMES[current.runtime].name}, using <b>{current.person.login}</b>'s model key (since <Time at={current.optedInAt} />).
75+ Agent review is <b>on</b>: {current.agent.login} reviews with {runtimeLabel(current.runtime, current)}, using <b>{current.person.login}</b>'s model key (since <Time at={current.optedInAt} />).
7976 </>
8077 ) : (
8178 <>Agent review is <b>off</b>.</>
@@ -100,8 +97,9 @@
10097 </div>
10198 <div class="field">
10299 <label>Runtime</label>
103- <KRadio name="runtime" label="Runtime" value={v("runtime", s.runtime ?? runtimes.find((r) => !r.disabled)?.value ?? "custom")} items={runtimes} />
100+ <KRadio name="runtime" label="Runtime" value={v("runtime", currentChoice ?? runtimes.find((r) => !r.disabled)?.value ?? "custom")} items={runtimes} />
104101 </div>
102+ <OpenRouterModel value={v("model", s.model ?? "")} />
105103 <div class="field">
106104 <label for="command">
107105 Command <span class="faint">(custom runtime only)</span>
@@ -171,11 +169,12 @@
171169
172170 post(web, "/:owner/:repo/settings/agent-review", async (c, f) => {
173171 const l = await admin(c);
174- const values = { agent: f.s("agent"), runtime: f.s("runtime") || "claude-code", command: f.s("command"), trigger: f.s("trigger") || "push", authors: f.s("authors") || "all", drafts: f.all("drafts").length ? "1" : "", autofix: f.all("autofix").length ? "1" : "", daily_limit: f.s("daily_limit"), tokens_per_review: f.s("tokens_per_review") };
172+ const values = { agent: f.s("agent"), runtime: f.s("runtime") || "claude-code", model: f.s("model"), command: f.s("command"), trigger: f.s("trigger") || "push", authors: f.s("authors") || "all", drafts: f.all("drafts").length ? "1" : "", autofix: f.all("autofix").length ? "1" : "", daily_limit: f.s("daily_limit"), tokens_per_review: f.s("tokens_per_review") };
175173 try {
176- const s = await saveReviewSettings(c, l, { ...values, drafts: !!values.drafts, autofix: !!values.autofix, accept: f.all("accept").length > 0 });
174+ const { runtime, provider } = parseRuntimeChoice(values.runtime);
175+ const s = await saveReviewSettings(c, l, { ...values, runtime, provider, model: provider ? values.model : undefined, drafts: !!values.drafts, autofix: !!values.autofix, accept: f.all("accept").length > 0 });
177176 const me = requireAccount(c);
178- c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "review.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { enabled: true, agent: s?.agent.login, runtime: s?.runtime, trigger: s?.trigger, autofix: s?.autofix, daily_limit: s?.dailyLimit, tokens_per_review: s?.tokensPerReview } }));
177+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "review.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { enabled: true, agent: s?.agent.login, runtime: s?.runtime, provider: s?.via ?? undefined, model: s?.model ?? undefined, trigger: s?.trigger, autofix: s?.autofix, daily_limit: s?.dailyLimit, tokens_per_review: s?.tokensPerReview } }));
179178 } catch (e) {
180179 const st = statusOf(e);
181180 if (st.status >= 500) throw e;
modifiedapps/diffinery/src/agentic/review.ts+4−0View file
@@ -1,3 +1,4 @@
1+import type { Provider } from "./keys.js";
12 import { finalMessage, type RuntimeId, type TranscriptLine } from "./runtimes.js";
23
34 /**
@@ -22,6 +23,9 @@
2223 runtime: RuntimeId;
2324 /** Only for the custom runtime. */
2425 command: string | null;
26+ /** OpenRouter instead of the runtime's own provider, and the model asked for there. */
27+ via?: Provider | null;
28+ model?: string | null;
2529 trigger: ReviewTrigger;
2630 drafts: boolean;
2731 authors: ReviewAuthors;
modifiedapps/diffinery/src/agentic/reviews.ts+13−9View file
@@ -9,8 +9,8 @@
99 import { ModelKeys, PROVIDERS, type Provider } from "./keys.js";
1010 import type { FindingRow } from "./ProjectDO.js";
1111 import { asksForReview, REPO_DAILY_DEFAULT, REPO_DAILY_MAX, REVIEW_TOKENS_DEFAULT, REVIEW_TOKENS_MAX, REVIEW_TOKENS_MIN, REVIEW_APP, REVIEW_CHECK, REVIEW_CREATOR, TRIGGERS, AUTHORS, type ReviewAuthors, type ReviewSettings, type ReviewTrigger } from "./review.js";
12-import { isRuntime, MAX_COMMAND, RUNTIMES, type RuntimeId } from "./runtimes.js";
13-import { projectStub, requirePersonSession } from "./tasks.js";
12+import { isRuntime, MAX_COMMAND, RUNTIMES, runtimeNeeds, type RuntimeId } from "./runtimes.js";
13+import { modelRoute, projectStub, providersFor, requirePersonSession } from "./tasks.js";
1414
1515 /**
1616 * Agent code review in the Worker: who may turn it on, who may ask for a
@@ -35,6 +35,8 @@
3535 agent?: unknown;
3636 runtime?: unknown;
3737 command?: unknown;
38+ provider?: unknown;
39+ model?: unknown;
3840 trigger?: unknown;
3941 drafts?: unknown;
4042 authors?: unknown;
@@ -75,10 +77,8 @@
7577 if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
7678 throw e;
7779 }
78- const have = await new ModelKeys(c.env).providers(me.id);
79- for (const p of RUNTIMES[runtime].needs) {
80- if (!have.has(p)) throw new HttpError(422, `${RUNTIMES[runtime].name} needs your ${PROVIDERS[p].name} API key: add it in Settings → Model access`);
81- }
80+ const route = modelRoute(runtime, input.provider, input.model);
81+ await providersFor(c.env, me, runtime, route.via);
8282 const dailyLimit = input.daily_limit === undefined || input.daily_limit === "" ? REPO_DAILY_DEFAULT : Number(input.daily_limit);
8383 if (!Number.isInteger(dailyLimit) || dailyLimit < 1 || dailyLimit > REPO_DAILY_MAX) throw new HttpError(422, `\`daily_limit\` must be a whole number from 1 to ${REPO_DAILY_MAX}`);
8484 const tokensPerReview = input.tokens_per_review === undefined || input.tokens_per_review === "" ? REVIEW_TOKENS_DEFAULT : Number(input.tokens_per_review);
@@ -91,6 +91,8 @@
9191 person: { id: me.id, login: me.login },
9292 runtime,
9393 command,
94+ via: route.via,
95+ model: route.model,
9496 trigger,
9597 drafts: input.drafts === true,
9698 authors,
@@ -131,9 +133,10 @@
131133 throw e;
132134 }
133135 const have = await new ModelKeys(env).providers(person.id);
134- const missing = RUNTIMES[settings.runtime].needs.filter((p) => !have.has(p));
136+ const needs = runtimeNeeds(settings.runtime, settings.via ?? null);
137+ const missing = needs.filter((p) => !have.has(p));
135138 if (missing.length) return blocked(`${settings.person.login}'s ${missing.map((p) => PROVIDERS[p].name).join(" and ")} API key is gone; they can add it in Settings → Model access`);
136- const providers: Provider[] = settings.runtime === "custom" ? [...have] : RUNTIMES[settings.runtime].needs;
139+ const providers: Provider[] = settings.runtime === "custom" ? [...have] : needs;
137140 const r = await projectStub(env, repo.id).requestReview({ repoId: repo.id, pr, head: issue.pull.head, base: issue.pull.base, headSha: head, settings, providers, requestedBy: requestedBy?.login ?? null });
138141 return { status: r.status, task: r.task, ...(r.status === "skipped" ? { reason: "this head was already reviewed" } : r.reason ? { reason: r.reason } : {}) };
139142 }
@@ -215,6 +218,8 @@
215218 uses_key_of: { login: s.person.login, type: "User" },
216219 runtime: s.runtime,
217220 ...(s.command ? { command: s.command } : {}),
221+ provider: s.via ?? RUNTIMES[s.runtime].needs[0] ?? null,
222+ model: s.model ?? null,
218223 trigger: s.trigger,
219224 drafts: s.drafts,
220225 authors: s.authors,
@@ -242,4 +247,3 @@
242247 created_at: f.created_at,
243248 });
244249
245-export const runtimeOk = (r: RuntimeId, keys: Set<Provider>) => RUNTIMES[r].needs.every((p) => keys.has(p));
modifiedapps/diffinery/src/agentic/runtimes.ts+84−6View file
@@ -19,12 +19,17 @@
1919 name: string;
2020 /** What it says on the new-task page. */
2121 description: string;
22- /** Providers it needs a key for (custom: none required; it gets whichever the person has). */
22+ /** Providers it needs a key for by default (custom: none required; it gets whichever the person has). */
2323 needs: Provider[];
24+ /** Where its model calls can go instead: OpenRouter, with a model chosen there. */
25+ alternatives: Provider[];
2426 /** Run before any credential exists in the sandbox. */
2527 install: string | null;
26- /** The run itself; the prompt is in $DIFFINERY_TASK_PROMPT, never on the command line we build. */
27- command: (custom?: string) => string;
28+ /**
29+ * The run itself; the prompt is in $DIFFINERY_TASK_PROMPT and an OpenRouter
30+ * model in $DIFFINERY_MODEL, never on the command line we build.
31+ */
32+ command: (custom?: string, via?: Provider) => string;
2833 }
2934
3035 export const RUNTIMES: Record<RuntimeId, Runtime> = {
@@ -33,6 +38,8 @@
3338 name: "Claude Code",
3439 description: "Anthropic's coding agent, with your Anthropic API key.",
3540 needs: ["anthropic"],
41+ // OpenRouter speaks the Messages API at /api/v1/messages, so Claude Code only needs another base URL (agentEnv).
42+ alternatives: ["openrouter"],
3643 install: `npm install --global --no-fund --no-audit --loglevel=error @anthropic-ai/claude-code@${RUNTIME_VERSIONS["claude-code"]} && claude --version`,
3744 // The sandbox runs as root in its own container; IS_SANDBOX lets Claude Code skip permission prompts there.
3845 command: () => 'claude -p "$DIFFINERY_TASK_PROMPT" --output-format stream-json --verbose --permission-mode bypassPermissions',
@@ -42,14 +49,22 @@
4249 name: "Codex",
4350 description: "OpenAI's coding agent, with your OpenAI API key.",
4451 needs: ["openai"],
52+ alternatives: ["openrouter"],
4553 install: `npm install --global --no-fund --no-audit --loglevel=error @openai/codex@${RUNTIME_VERSIONS.codex} && codex --version`,
46- command: () => 'codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$DIFFINERY_TASK_PROMPT"',
54+ // Through OpenRouter: a model provider for its OpenAI-compatible Responses API, defined on the command line.
55+ command: (_custom, via) =>
56+ `codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check${
57+ via === "openrouter"
58+ ? ' -c model_provider=openrouter -c model_providers.openrouter.name=OpenRouter -c "model_providers.openrouter.base_url=$OPENROUTER_BASE_URL" -c model_providers.openrouter.env_key=OPENROUTER_API_KEY -c model_providers.openrouter.wire_api=responses -m "$DIFFINERY_MODEL"'
59+ : ""
60+ } "$DIFFINERY_TASK_PROMPT"`,
4761 },
4862 custom: {
4963 id: "custom",
5064 name: "Custom command",
5165 description: "Any shell command, such as another agent's CLI. It gets the model proxy for each provider you have a key for.",
5266 needs: [],
67+ alternatives: [],
5368 install: null,
5469 command: (custom) => custom ?? "true",
5570 },
@@ -57,6 +72,57 @@
5772
5873 export const isRuntime = (r: string): r is RuntimeId => r === "claude-code" || r === "codex" || r === "custom";
5974
75+/** The model a runtime asks OpenRouter for unless the person names another. */
76+export const OPENROUTER_DEFAULT_MODEL: Partial<Record<RuntimeId, string>> = { "claude-code": "~anthropic/claude-sonnet-latest", codex: "~openai/gpt-sol-latest" };
77+/** An OpenRouter model slug, such as `anthropic/claude-sonnet-4.5`, `~openai/gpt-sol-latest` or `qwen/qwen3-coder:free`. */
78+export const OPENROUTER_MODEL = /^~?[a-z0-9][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,127}$/;
79+
80+/** Where a runtime's model calls go: its own provider (null `via`) or OpenRouter with a model. */
81+export interface ModelRoute {
82+ via: Provider | null;
83+ model: string | null;
84+}
85+
86+/** "Claude Code", or "Claude Code via OpenRouter (model)". */
87+export const runtimeLabel = (runtime: RuntimeId, route?: Partial<ModelRoute> | null): string =>
88+ route?.via && route.via !== RUNTIMES[runtime].needs[0] ? `${RUNTIMES[runtime].name} via ${route.via === "openrouter" ? "OpenRouter" : route.via}${route.model ? ` (${route.model})` : ""}` : RUNTIMES[runtime].name;
89+
90+/** A runtime as one choice on a form: "claude-code", or "claude-code+openrouter" to go through OpenRouter. */
91+export interface RuntimeChoice {
92+ value: string;
93+ label: string;
94+ description: string;
95+ disabled: boolean;
96+}
97+
98+export function runtimeChoices(keys: Set<Provider>, names: Record<Provider, string>): RuntimeChoice[] {
99+ const out: RuntimeChoice[] = [];
100+ for (const id of Object.keys(RUNTIMES) as RuntimeId[]) {
101+ const r = RUNTIMES[id];
102+ for (const via of [null, ...r.alternatives]) {
103+ const needs = runtimeNeeds(id, via);
104+ const missing = needs.filter((p) => !keys.has(p));
105+ const description = via ? `${r.name} with your ${names[via]} key, on a model you choose there.` : r.description;
106+ out.push({
107+ value: via ? `${id}+${via}` : id,
108+ label: runtimeLabel(id, { via }),
109+ description: missing.length ? `${description} Add your ${missing.map((p) => names[p]).join(" and ")} key in Settings → Model access first.` : description,
110+ disabled: missing.length > 0,
111+ });
112+ }
113+ }
114+ return out;
115+}
116+
117+/** A form's runtime choice, split into the runtime and the provider it goes through. */
118+export function parseRuntimeChoice(v: string): { runtime: string; provider: string | undefined } {
119+ const [runtime, provider] = v.split("+", 2);
120+ return { runtime: runtime ?? "", provider: provider || undefined };
121+}
122+
123+/** The provider keys a run of `runtime` needs, going `via` a provider other than its own. */
124+export const runtimeNeeds = (runtime: RuntimeId, via: Provider | null): Provider[] => (via && RUNTIMES[runtime].alternatives.includes(via) ? [via] : RUNTIMES[runtime].needs);
125+
60126 /** The longest a run may take. A sandbox's git credential lasts 24 hours. */
61127 export const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000;
62128 export const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
@@ -68,19 +134,31 @@
68134 * `secret`) the task's proxy token under each provider's usual variable. The
69135 * real keys stay in Diffinery.
70136 */
71-export function agentEnv(publicUrl: string, providers: Provider[], proxyToken: string): { env: Record<string, string>; secret: Record<string, string> } {
137+export function agentEnv(publicUrl: string, providers: Provider[], proxyToken: string, o: { runtime?: RuntimeId; route?: ModelRoute | null } = {}): { env: Record<string, string>; secret: Record<string, string> } {
72138 const env: Record<string, string> = { IS_SANDBOX: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_AUTOUPDATER: "1" };
73139 const secret: Record<string, string> = {};
74140 for (const p of providers) {
75141 if (p === "anthropic") {
76142 env.ANTHROPIC_BASE_URL = `${publicUrl}/-/model/anthropic`;
77143 secret.ANTHROPIC_API_KEY = proxyToken;
78- } else {
144+ } else if (p === "openai") {
79145 env.OPENAI_BASE_URL = `${publicUrl}/-/model/openai/v1`;
80146 secret.OPENAI_API_KEY = proxyToken;
81147 secret.CODEX_API_KEY = proxyToken;
148+ } else {
149+ env.OPENROUTER_BASE_URL = `${publicUrl}/-/model/openrouter/v1`;
150+ secret.OPENROUTER_API_KEY = proxyToken;
82151 }
83152 }
153+ const route = o.route;
154+ if (route?.via === "openrouter" && route.model) env.DIFFINERY_MODEL = route.model;
155+ if (o.runtime === "claude-code" && route?.via === "openrouter") {
156+ // OpenRouter's Anthropic-compatible endpoint, with the token as a bearer token, and every model role on the chosen model.
157+ env.ANTHROPIC_BASE_URL = `${publicUrl}/-/model/openrouter`;
158+ secret.ANTHROPIC_AUTH_TOKEN = proxyToken;
159+ env.ANTHROPIC_API_KEY = "";
160+ if (route.model) for (const k of ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"]) env[k] = route.model;
161+ }
84162 return { env, secret };
85163 }
86164
modifiedapps/diffinery/src/agentic/tasks-web.tsx+27−14View file
@@ -10,7 +10,7 @@
1010 import { isProvider, ModelKeys, PROVIDERS, type Provider } from "./keys.js";
1111 import type { TaskRow, TaskView } from "./ProjectDO.js";
1212 import { fixGoal } from "./review.js";
13-import { RUNTIMES, transcriptItem, type RuntimeId, type TranscriptItem } from "./runtimes.js";
13+import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js";
1414 import { drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js";
1515
1616 /**
@@ -27,6 +27,21 @@
2727 stopped: { label: "Stopped", tone: "" },
2828 };
2929
30+const providerNames = Object.fromEntries(Object.values(PROVIDERS).map((p) => [p.id, p.name])) as Record<Provider, string>;
31+
32+/** The model field for a runtime that goes through OpenRouter (tasks and agent review share it). */
33+export const OpenRouterModel: FC<{ value?: string | null }> = ({ value }) => (
34+ <div class="field">
35+ <label for="model">
36+ OpenRouter model <span class="faint">(through OpenRouter only)</span>
37+ </label>
38+ <input id="model" name="model" type="text" maxlength={200} value={value ?? ""} placeholder={`${OPENROUTER_DEFAULT_MODEL["claude-code"]} for Claude Code, ${OPENROUTER_DEFAULT_MODEL.codex} for Codex`} />
39+ <div class="hint">
40+ A model id from <a href="https://openrouter.ai/models" rel="noopener noreferrer" target="_blank">openrouter.ai/models</a>. Left empty, Claude Code uses {OPENROUTER_DEFAULT_MODEL["claude-code"]} and Codex {OPENROUTER_DEFAULT_MODEL.codex}. OpenRouter supports Claude Code fully only on Anthropic models, and Codex on OpenAI ones.
41+ </div>
42+ </div>
43+);
44+
3045 export const TaskBadge: FC<{ state: TaskRow["state"] }> = ({ state }) => <span class={`badge ${TASK_STATE[state].tone}`}>{TASK_STATE[state].label}</span>;
3146
3247 // ---------------------------------------------------------------- Settings → Model access
@@ -43,7 +58,7 @@
4358 <div>
4459 <h1>Model access</h1>
4560 <p class="muted">
46- Agent tasks you start run Claude Code or Codex with your own API key. Diffinery keeps the key encrypted, never shows it again, and never puts it in a sandbox: the agent calls the provider through Diffinery, which adds your key. See <a href="/docs/agents/tasks">Agent tasks</a>.
61+ Agent tasks you start run Claude Code or Codex with your own API key: Anthropic's or OpenAI's, or OpenRouter's to run them on a model you choose there. Diffinery keeps the key encrypted, never shows it again, and never puts it in a sandbox: the agent calls the provider through Diffinery, which adds your key. See <a href="/docs/agents/tasks">Agent tasks</a>.
4762 </p>
4863 <div class="flash info">
4964 Anything an agent runs in its sandbox, including code from the repository and its dependencies, can use your model access while the task runs, and could be tricked into misusing it. Use a <b>dedicated key with a spending limit</b> for Diffinery, and revoke it with the provider if you suspect misuse.
@@ -75,7 +90,7 @@
7590 <Form action={`/settings/model-access/${id}`} t={t}>
7691 <div class="field">
7792 <label for={`key-${id}`}>{k ? `Replace your ${p.name} API key` : `Your ${p.name} API key`}</label>
78- <input id={`key-${id}`} name="key" type="password" autocomplete="off" required placeholder={id === "anthropic" ? "sk-ant-…" : "sk-…"} />
93+ <input id={`key-${id}`} name="key" type="password" autocomplete="off" required placeholder={id === "anthropic" ? "sk-ant-…" : id === "openrouter" ? "sk-or-…" : "sk-…"} />
7994 <div class="hint">
8095 Create one at <a href={p.keysUrl} rel="noopener noreferrer" target="_blank">{p.keysUrl.replace("https://", "")}</a>.
8196 </div>
@@ -130,7 +145,7 @@
130145 </a>{" "}
131146 {t.kind === "review" ? <span class="badge purple">Review</span> : null} <TaskBadge state={t.state} />
132147 <div class="muted small">
133- #{t.number} · {RUNTIMES[t.runtime].name} as <a href={`/${t.agent_login}`}>{t.agent_login}</a> · started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} />
148+ #{t.number} · {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a> · started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} />
134149 {t.pr_number ? (
135150 <>
136151 {" "}
@@ -189,7 +204,7 @@
189204 {t.title} <span class="faint">#{t.number}</span>
190205 </h1>
191206 <div class="muted small mt">
192- <TaskBadge state={t.state} /> {RUNTIMES[t.runtime].name} as <a href={`/${t.agent_login}`}>{t.agent_login}</a>, started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} /> · branch <span class="mono">{t.branch}</span> from <span class="mono">{t.base}</span>
207+ <TaskBadge state={t.state} /> {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a>, started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} /> · branch <span class="mono">{t.branch}</span> from <span class="mono">{t.base}</span>
193208 {t.pr_number ? (
194209 <>
195210 {" "}
@@ -259,16 +274,12 @@
259274 );
260275 }
261276
262-async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; pull?: string } = {}) {
277+async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string } = {}) {
263278 const me = requireAccount(c);
264279 const base = repoUrl(l.repo);
265280 const tk = csrfToken(c);
266281 const [agents, keys, branches] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/")]);
267- const runtimes = (Object.keys(RUNTIMES) as RuntimeId[]).map((id) => {
268- const r = RUNTIMES[id];
269- const missing = r.needs.filter((p) => !keys.has(p));
270- return { value: id, label: r.name, description: missing.length ? `${r.description} Add your ${missing.map((p) => PROVIDERS[p].name).join(" and ")} key in Settings → Model access first.` : r.description, disabled: missing.length > 0 };
271- });
282+ const runtimes = runtimeChoices(keys, providerNames);
272283 const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom";
273284 return render(
274285 c,
@@ -303,12 +314,13 @@
303314 <label>Runtime</label>
304315 <KRadio name="runtime" label="Runtime" value={v.runtime ?? firstUsable} items={runtimes} />
305316 </div>
317+ <OpenRouterModel value={v.model} />
306318 <div class="field">
307319 <label for="command">
308320 Command <span class="faint">(custom runtime only)</span>
309321 </label>
310322 <input id="command" name="command" type="text" maxlength={2000} value={v.command ?? ""} placeholder='aider --yes --message "$DIFFINERY_TASK_PROMPT"' />
311- <div class="hint">Runs in the repository with the goal in $DIFFINERY_TASK_PROMPT, and ANTHROPIC_BASE_URL / OPENAI_BASE_URL set for the providers you have keys for.</div>
323+ <div class="hint">Runs in the repository with the goal in $DIFFINERY_TASK_PROMPT, and ANTHROPIC_BASE_URL, OPENAI_BASE_URL or OPENROUTER_BASE_URL (with the matching _API_KEY) set for the providers you have keys for.</div>
312324 </div>
313325 <div class="row" style="align-items:flex-start">
314326 <div class="field grow">
@@ -400,9 +412,10 @@
400412
401413 post(web, "/:owner/:repo/tasks", async (c, f) => {
402414 const l = await forTasks(c);
403- const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), pull: f.s("pull") || undefined };
415+ const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined };
404416 try {
405- const t = await startTask(c, l, v);
417+ const { runtime, provider } = parseRuntimeChoice(v.runtime);
418+ const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined });
406419 return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303);
407420 } catch (e) {
408421 const st = statusOf(e);
modifiedapps/diffinery/src/agentic/tasks.ts+38−12View file
@@ -5,9 +5,9 @@
55 import { HttpError } from "../lib/util.js";
66 import { atLeast, type Caller } from "../repo/types.js";
77 import { mayDriveAgent, taskCaller, TaskAccessError } from "./access.js";
8-import { ModelKeys, PROVIDERS, type Provider } from "./keys.js";
8+import { isProvider, ModelKeys, PROVIDERS, type Provider } from "./keys.js";
99 import type { TaskRow, TaskView } from "./ProjectDO.js";
10-import { isRuntime, MAX_COMMAND, MAX_GOAL, RUNTIMES, type RuntimeId, type TranscriptLine } from "./runtimes.js";
10+import { isRuntime, MAX_COMMAND, MAX_GOAL, OPENROUTER_DEFAULT_MODEL, OPENROUTER_MODEL, RUNTIMES, runtimeLabel, runtimeNeeds, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js";
1111
1212 /**
1313 * Who may do what with agent tasks. Shared by the REST API, MCP and the web.
@@ -51,15 +51,35 @@
5151 return t;
5252 }
5353
54-/** The providers a run gets, checked against the runtime's needs and the person's keys. */
55-async function providersFor(env: Env, person: Account, runtime: RuntimeId): Promise<Provider[]> {
54+/** The providers a run gets, checked against the runtime's needs (or the provider it goes through) and the person's keys. */
55+export async function providersFor(env: Env, person: Account, runtime: RuntimeId, via: Provider | null = null): Promise<Provider[]> {
5656 const have = await new ModelKeys(env).providers(person.id);
57- for (const p of RUNTIMES[runtime].needs) {
58- if (!have.has(p)) throw new HttpError(422, `${RUNTIMES[runtime].name} needs your ${PROVIDERS[p].name} API key: add it in Settings → Model access`);
57+ const needs = runtimeNeeds(runtime, via);
58+ for (const p of needs) {
59+ if (!have.has(p)) throw new HttpError(422, `${runtimeLabel(runtime, { via })} needs your ${PROVIDERS[p].name} API key: add it in Settings → Model access`);
5960 }
60- return runtime === "custom" ? [...have] : RUNTIMES[runtime].needs;
61+ return runtime === "custom" ? [...have] : needs;
6162 }
6263
64+/**
65+ * Where a runtime's model calls go: `provider` is its own (the default) or
66+ * OpenRouter, which takes a `model` (a default one when none is given).
67+ */
68+export function modelRoute(runtime: RuntimeId, provider: unknown, model: unknown): ModelRoute {
69+ const own = RUNTIMES[runtime].needs[0];
70+ if (provider === undefined || provider === null || provider === "" || provider === own) {
71+ if (model !== undefined && model !== null && model !== "") throw new HttpError(422, "`model` is only for OpenRouter: set `provider` to openrouter");
72+ return { via: null, model: null };
73+ }
74+ if (typeof provider !== "string" || !isProvider(provider) || !RUNTIMES[runtime].alternatives.includes(provider)) {
75+ const ok = [own, ...RUNTIMES[runtime].alternatives].filter(Boolean);
76+ throw new HttpError(422, ok.length ? `\`provider\` for ${RUNTIMES[runtime].name} must be ${ok.join(" or ")}` : `${RUNTIMES[runtime].name} gets every provider you have a key for: leave \`provider\` out`);
77+ }
78+ const m = typeof model === "string" && model.trim() ? model.trim() : (OPENROUTER_DEFAULT_MODEL[runtime] ?? "");
79+ if (!OPENROUTER_MODEL.test(m)) throw new HttpError(422, "`model` must be an OpenRouter model id, such as anthropic/claude-sonnet-4.5");
80+ return { via: provider, model: m };
81+}
82+
6383 async function asTaskCaller(c: AppContext, person: Account, agent: Account, l: Loaded): Promise<Caller> {
6484 try {
6585 return await taskCaller(c.get("dir"), person, agent, l.repo);
@@ -75,6 +95,9 @@
7595 agent?: unknown;
7696 base?: unknown;
7797 command?: unknown;
98+ /** The runtime's own provider (the default) or `openrouter`, with `model`. */
99+ provider?: unknown;
100+ model?: unknown;
78101 /** Work on this pull request's branch (Fix it) instead of a new branch. */
79102 pull?: unknown;
80103 }
@@ -90,6 +113,7 @@
90113 const command = runtime === "custom" ? (typeof input.command === "string" ? input.command.trim() : "") : null;
91114 if (runtime === "custom" && !command) throw new HttpError(422, "`command` is required for the custom runtime");
92115 if (command && command.length > MAX_COMMAND) throw new HttpError(422, `\`command\` is longer than ${MAX_COMMAND} characters`);
116+ const route = modelRoute(runtime, input.provider, input.model);
93117 const agentLogin = typeof input.agent === "string" ? input.agent.trim() : "";
94118 const agent = agentLogin ? await c.get("dir").accountByLogin(agentLogin) : null;
95119 if (!agent || agent.kind !== "agent") throw new HttpError(422, "`agent` must be the login of one of your agents");
@@ -107,9 +131,9 @@
107131 base = issue.pull.base;
108132 }
109133 if (!(await l.stub.getRef(`refs/heads/${base}`))) throw new HttpError(422, `branch ${base} doesn't exist`);
110- const providers = await providersFor(c.env, person, runtime);
111- const t = await projectStub(c.env, l.repo.id).create({ repoId: l.repo.id, goal, runtime, command, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull });
112- c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime } }));
134+ const providers = await providersFor(c.env, person, runtime, route.via);
135+ const t = await projectStub(c.env, l.repo.id).create({ repoId: l.repo.id, goal, runtime, command, route, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull });
136+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime, ...(route.via ? { provider: route.via, model: route.model } : {}) } }));
113137 return t;
114138 }
115139
@@ -131,14 +155,14 @@
131155 if (!body) throw new HttpError(422, "`body` is required");
132156 if (body.length > MAX_GOAL) throw new HttpError(422, `\`body\` is longer than ${MAX_GOAL} characters`);
133157 const { person, task } = await starter(c, l, number);
134- const t = await projectStub(c.env, l.repo.id).message(number, body, await providersFor(c.env, person, task.runtime));
158+ const t = await projectStub(c.env, l.repo.id).message(number, body, await providersFor(c.env, person, task.runtime, task.via));
135159 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.message", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
136160 return t;
137161 }
138162
139163 export async function retryTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
140164 const { person, task } = await starter(c, l, number);
141- const t = await projectStub(c.env, l.repo.id).retry(number, await providersFor(c.env, person, task.runtime));
165+ const t = await projectStub(c.env, l.repo.id).retry(number, await providersFor(c.env, person, task.runtime, task.via));
142166 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.retry", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
143167 return t;
144168 }
@@ -178,6 +202,8 @@
178202 state: t.state,
179203 runtime: t.runtime,
180204 ...(t.command ? { command: t.command } : {}),
205+ provider: t.via ?? RUNTIMES[t.runtime].needs[0] ?? null,
206+ model: t.model ?? null,
181207 agent: { login: t.agent_login, type: "Bot" },
182208 started_by: { login: t.person_login, type: "User" },
183209 base: t.base,
modifiedapps/diffinery/src/agentic/usage.ts+48−12View file
@@ -95,11 +95,21 @@
9595
9696 export const formatTokens = (t: number): string => (t >= 1_000_000 ? `${+(t / 1_000_000).toFixed(1)}M` : t >= 1000 ? `${Math.round(t / 1000)}k` : String(t));
9797
98-/** Answers that generate tokens, and so are charged an estimate when they report no usage. */
99-const GENERATES = /^\/v1\/(messages|responses|responses\/compact|chat\/completions)$/;
10098 /** The output an estimate assumes when a request doesn't cap it. */
10199 export const DEFAULT_MAX_OUTPUT = 32_000;
100+/** What a compaction may write back, which its request can't cap: the most any current OpenAI model emits. */
101+export const COMPACT_MAX_OUTPUT = 128_000;
102102
103+/** Endpoints that generate tokens, so each request books an estimate, with the fields that cap their output. */
104+const GENERATES: Record<string, string[]> = {
105+ "/v1/messages": ["max_tokens"],
106+ "/v1/responses": ["max_output_tokens"],
107+ "/v1/responses/compact": [],
108+ "/v1/chat/completions": ["max_completion_tokens", "max_tokens"],
109+ // OpenRouter's legacy text completions.
110+ "/v1/completions": ["max_tokens"],
111+};
112+
103113 export interface PreparedRequest {
104114 /** The body to forward (changed only to ask for usage). */
105115 body: string;
@@ -107,13 +117,32 @@
107117 estimate: Usage | null;
108118 /** Why a review may not send this request, if it may not. */
109119 reviewRefuse?: string;
110- /** The body a review sends instead, when the request doesn't cap its output: capped, so the estimate holds. */
120+ /** The body a review sends instead: every output cap set to the booked one, so the estimate holds. */
111121 reviewBody?: string;
122+ /** The model the request asks for (null when it names none), for runs pinned to one. */
123+ model?: string | null;
124+ /** A routing field the request sets, which a pinned run may not. */
125+ routing?: string;
112126 }
113127
114-/** Where each generating endpoint takes its output cap. */
115-const CAP_FIELD: Record<string, string> = { "/v1/messages": "max_tokens", "/v1/responses": "max_output_tokens", "/v1/chat/completions": "max_completion_tokens" };
128+/**
129+ * Request fields that choose other models, providers or paid extras (OpenRouter's fallbacks and routing,
130+ * plugins, web search, and more answers to pick from): a review or a run pinned to a model may not set them,
131+ * or its code could switch to a far pricier model than the one its budget was set for.
132+ */
133+const ROUTING = ["models", "route", "provider", "plugins", "web_search_options", "speed", "best_of"] as const;
116134
135+/**
136+ * Why a run may not send a prepared request: a review never sets routing fields, and a run pinned to a model
137+ * (one that goes through OpenRouter with a model chosen in Diffinery) asks only for that model.
138+ */
139+export function pinRefusal(p: Pick<PreparedRequest, "estimate" | "model" | "routing">, run: { review: boolean; model: string | null }): string | undefined {
140+ const who = run.review ? "agent reviews" : "this task";
141+ if ((run.review || run.model) && p.routing) return `${p.routing} isn't available to ${who}: Diffinery pins the model and its routing`;
142+ if (run.model && p.estimate && p.model !== run.model) return `this ${run.review ? "review" : "task"} runs on ${run.model}${p.model ? `, not ${p.model}` : ""}`;
143+ return undefined;
144+}
145+
117146 /** Responses API fields that pull in context stored at the provider, which the request's size doesn't show. */
118147 const STORED_CONTEXT = ["previous_response_id", "conversation", "prompt"] as const;
119148
@@ -125,14 +154,18 @@
125154 * start a background response, which it could leave unfetched and so
126155 * uncharged, pull in stored context its estimate can't see, or ask for more
127156 * than one answer; and a review's request that doesn't cap its output is
128- * capped at the estimate's DEFAULT_MAX_OUTPUT.
157+ * capped at the estimate's DEFAULT_MAX_OUTPUT. A compaction can't be capped,
158+ * so it books COMPACT_MAX_OUTPUT.
129159 */
130160 export function prepareRequest(method: string, path: string, body: string, size: number): PreparedRequest {
131- if (method !== "POST" || !GENERATES.test(path)) return { body, estimate: null };
161+ const caps = method === "POST" && Object.hasOwn(GENERATES, path) ? GENERATES[path]! : null;
162+ if (!caps) return { body, estimate: null };
132163 const json = parse(body);
133164 const x = json && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : null;
134- const cap = x ? [x.max_tokens, x.max_output_tokens, x.max_completion_tokens].map(n).find((v) => v > 0) : undefined;
135- const estimate: Usage = { input: Math.ceil(size / 3), output: cap ?? DEFAULT_MAX_OUTPUT, cacheRead: 0, cacheWrite: 0 };
165+ // A request can set several caps (Chat Completions takes two): book the largest.
166+ const capped = x ? caps.map((k) => n(x[k])).filter((v) => v > 0) : [];
167+ const cap = capped.length ? Math.max(...capped) : undefined;
168+ const estimate: Usage = { input: Math.ceil(size / 3), output: cap ?? (caps.length ? DEFAULT_MAX_OUTPUT : COMPACT_MAX_OUTPUT), cacheRead: 0, cacheWrite: 0 };
136169 if (!x) return { body, estimate };
137170 const stored = path.startsWith("/v1/responses") ? STORED_CONTEXT.find((k) => x[k] != null) : undefined;
138171 const reviewRefuse =
@@ -148,10 +181,13 @@
148181 const so = x.stream_options && typeof x.stream_options === "object" ? (x.stream_options as Record<string, unknown>) : {};
149182 if (so.include_usage !== true) fields = { ...x, stream_options: { ...so, include_usage: true } };
150183 }
151- const out: PreparedRequest = { body: fields === x ? body : JSON.stringify(fields), estimate };
184+ const out: PreparedRequest = { body: fields === x ? body : JSON.stringify(fields), estimate, model: typeof x.model === "string" ? x.model : null };
152185 if (reviewRefuse) out.reviewRefuse = reviewRefuse;
153- const field = CAP_FIELD[path];
154- if (cap === undefined && field) out.reviewBody = JSON.stringify({ ...fields, [field]: DEFAULT_MAX_OUTPUT });
186+ const routing = ROUTING.find((k) => x[k] != null);
187+ if (routing) out.routing = routing;
188+ // A review sends every cap as the one booked, and the default when it sets none.
189+ const set = caps.filter((k) => x[k] !== undefined);
190+ if (caps[0] && (!set.length || set.some((k) => x[k] !== estimate.output))) out.reviewBody = JSON.stringify({ ...fields, ...Object.fromEntries((set.length ? set : [caps[0]]).map((k) => [k, estimate.output])) });
155191 return out;
156192 }
157193
modifiedapps/diffinery/src/docs/api-meta.ts+7−3View file
@@ -1350,14 +1350,14 @@
13501350 // ------------------------------------------------------------ model access and agent tasks (agentic/)
13511351 "GET /diffinery/model-keys": {
13521352 summary: "List your model keys",
1353- description: "Your saved model API keys for agent tasks: `provider` (`anthropic` or `openai`), `hint` (the key's last four characters), `opted_in_at`, `created_at` and `updated_at`. The keys themselves are never returned. See /docs/agents/tasks#model-access.",
1353+ description: "Your saved model API keys for agent tasks: `provider` (`anthropic`, `openai` or `openrouter`), `hint` (the key's last four characters), `opted_in_at`, `created_at` and `updated_at`. The keys themselves are never returned. See /docs/agents/tasks#model-access.",
13541354 access: "account",
13551355 },
13561356 "PUT /diffinery/model-keys/{provider}": {
13571357 summary: "Save a model key",
1358- description: "From a browser session only: no token, agent, job or sandbox can set a model key. `provider` is `anthropic` or `openai`. `accept_terms: true` records that you use that provider through Diffinery under your own account and its terms (422 without it). The key is encrypted, never shown again and never sent to a sandbox. Sends you a security email. Audited as `model_key.set`.",
1358+ description: "From a browser session only: no token, agent, job or sandbox can set a model key. `provider` is `anthropic`, `openai` or `openrouter`. `accept_terms: true` records that you use that provider through Diffinery under your own account and its terms (422 without it). The key is encrypted, never shown again and never sent to a sandbox. Sends you a security email. Audited as `model_key.set`.",
13591359 access: "account",
1360- body: { key: ["string", "The API key (`sk-ant-...` or `sk-...`)."], accept_terms: ["boolean", "Must be `true`."] },
1360+ body: { key: ["string", "The API key (`sk-ant-...`, `sk-...` or `sk-or-...`)."], accept_terms: ["boolean", "Must be `true`."] },
13611361 },
13621362 "DELETE /diffinery/model-keys/{provider}": { summary: "Delete a model key", description: "From a browser session only. Running tasks that use it lose model access. Audited as `model_key.delete`.", access: "account", status: 204 },
13631363 "GET /diffinery/tasks": {
@@ -1379,6 +1379,8 @@
13791379 body: {
13801380 goal: ["string", "What to do (required, at most 20,000 characters)."],
13811381 runtime: ["string", "`claude-code` (default), `codex` or `custom`."],
1382+ provider: ["string", "Where the runtime's model calls go: its own provider (`anthropic` for Claude Code, `openai` for Codex; the default) or `openrouter`."],
1383+ model: ["string", "With `provider: openrouter`: the OpenRouter model id (default `~anthropic/claude-sonnet-latest` for Claude Code, `~openai/gpt-sol-latest` for Codex)."],
13821384 agent: ["string", "The agent's login (required)."],
13831385 base: ["string", "Branch to start from (default: the default branch)."],
13841386 command: ["string", "For `custom`: the shell command to run."],
@@ -1416,6 +1418,8 @@
14161418 body: {
14171419 agent: ["string", "The reviewing agent's login (required)."],
14181420 runtime: ["string", "`claude-code` (default), `codex` or `custom`."],
1421+ provider: ["string", "Where the runtime's model calls go: its own provider (`anthropic` for Claude Code, `openai` for Codex; the default) or `openrouter`."],
1422+ model: ["string", "With `provider: openrouter`: the OpenRouter model id (default `~anthropic/claude-sonnet-latest` for Claude Code, `~openai/gpt-sol-latest` for Codex)."],
14191423 command: ["string", "For `custom`: the shell command; it must end its output with the findings block the prompt describes."],
14201424 trigger: ["string", "`push` (default: on open and every push), `open` (once per pull request) or `request` (only when asked)."],
14211425 authors: ["string", "`all` (default), `people` or `agents`."],
modifiedapps/diffinery/src/env.ts+1−0View file
@@ -28,6 +28,7 @@
2828 /** Local development and end-to-end tests only: where the model proxy forwards instead of the providers' APIs. */
2929 MODEL_UPSTREAM_ANTHROPIC?: string;
3030 MODEL_UPSTREAM_OPENAI?: string;
31+ MODEL_UPSTREAM_OPENROUTER?: string;
3132 RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>;
3233 /** Cloudflare Email Sending. Missing when sending isn't set up; emails are then logged as skipped. */
3334 EMAIL?: SendEmail;
modifiedapps/diffinery/test/e2e/review.test.mjs+14−4View file
@@ -154,7 +154,7 @@
154154 base = `http://127.0.0.1:${port}`;
155155 const persist = join(work, "state");
156156 execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
157- const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl };
157+ const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl };
158158 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], {
159159 cwd: APP,
160160 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
@@ -493,6 +493,7 @@
493493 // and a review can't start a background response it would never fetch.
494494 const OPENAI_KEY = `sk-proj-${randomBytes(30).toString("hex")}`;
495495 assert.equal((await asPerson("PUT", "/diffinery/model-keys/openai", { key: OPENAI_KEY, accept_terms: true })).status, 200);
496+ assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: `sk-or-v1-${randomBytes(32).toString("hex")}`, accept_terms: true })).status, 200);
496497 const probe = join(work, "usage-agent.sh");
497498 const CUT = '{"model":"m","max_tokens":500,"abort":true,"messages":[]}';
498499 writeFileSync(
@@ -506,6 +507,10 @@
506507 (k) => `echo "${k} $(curl -s -o /dev/null -w '%{http_code}' -X POST "$OPENAI_BASE_URL/responses" -H "authorization: Bearer $OPENAI_API_KEY" -H "content-type: application/json" -d '{"model":"m","${k}":"x","input":"x"}')"`,
507508 ),
508509 `echo "n $(curl -s -o /dev/null -w '%{http_code}' -X POST "$OPENAI_BASE_URL/chat/completions" -H "authorization: Bearer $OPENAI_API_KEY" -H "content-type: application/json" -d '{"model":"m","n":128,"max_completion_tokens":16000,"messages":[]}')"`,
510+ ...["models", "provider", "plugins"].map(
511+ (k) => `echo "${k} $(curl -s -o /dev/null -w '%{http_code}' -X POST "$OPENROUTER_BASE_URL/chat/completions" -H "authorization: Bearer $OPENROUTER_API_KEY" -H "content-type: application/json" -d '{"model":"m","max_tokens":5,"${k}":[],"messages":[]}')"`,
512+ ),
513+ `curl -s -o /dev/null -X POST "$OPENROUTER_BASE_URL/chat/completions" -H "authorization: Bearer $OPENROUTER_API_KEY" -H "content-type: application/json" -d '{"model":"m","max_completion_tokens":1,"max_tokens":300,"messages":[]}'`,
509514 `curl -s -o /dev/null -X POST "$ANTHROPIC_BASE_URL/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" -H "content-type: application/json" -d '${CUT}'`,
510515 "sleep 1",
511516 "",
@@ -520,16 +525,21 @@
520525 }, 180_000);
521526 const run3 = t3.runs.at(-1);
522527 const text3 = (await ok("GET", `/repos/${owner}/review-demo/diffinery/tasks/${task}/transcript`)).filter((l) => l.run === run3.number).map((l) => l.text).join("\n");
523- for (const k of ["background", "previous_response_id", "conversation", "prompt", "n"]) assert.match(text3, new RegExp(`^${k} 403$`, "m"), text3);
528+ for (const k of ["background", "previous_response_id", "conversation", "prompt", "n", "models", "provider", "plugins"]) assert.match(text3, new RegExp(`^${k} 403$`, "m"), text3);
524529 const served3 = modelRequests.slice(before3);
525- assert.equal(served3.length, 3, "the refused requests never reached the provider");
530+ assert.equal(served3.length, 4, "the refused requests never reached the provider");
531+ // Two caps: the largest is booked, and the review sends both as that one.
532+ const twoCaps = JSON.parse(served3[2].body);
533+ assert.equal(twoCaps.max_completion_tokens, 300);
534+ assert.equal(twoCaps.max_tokens, 300);
526535 // A streamed Chat Completion is asked for usage, and one with no output cap is capped at what its estimate assumed.
527536 assert.equal(JSON.parse(served3[1].body).stream_options.include_usage, true);
528537 assert.equal(JSON.parse(served3[1].body).max_completion_tokens, 32_000);
529538 // 42 bytes / 3 + max_tokens 7 for the answer without usage, the streamed answer's own 40,000,
530539 // and the estimate the cut-off answer was booked, which stays charged since its usage was never read.
531540 const cut = Math.ceil(Buffer.byteLength(CUT) / 3) + 500;
532- await until("the estimates and the streamed usage to be counted", async () => (await runTokens(run3.number)) === 14 + 7 + 40_000 + cut, 15_000);
541+ // The two-caps answer reports usage 10 in, 5 out.
542+ await until("the estimates and the streamed usage to be counted", async () => (await runTokens(run3.number)) === 14 + 7 + 40_000 + 15 + cut, 15_000);
533543
534544 // Per repository: past the payer's daily limit, a request is refused, the check asks for action, and the payer gets one email.
535545 assert.equal((await asPerson("PUT", S, { ...settings, daily_limit: 1 })).status, 200);
modifiedapps/diffinery/test/e2e/tasks.test.mjs+55−1View file
@@ -26,6 +26,7 @@
2626 const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-"));
2727 const poolRoot = join(work, "pool");
2828 const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
29+const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`;
2930 let base;
3031 let dev;
3132 let pool;
@@ -130,7 +131,7 @@
130131 base = `http://127.0.0.1:${port}`;
131132 const persist = join(work, "state");
132133 execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
133- const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl };
134+ const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl };
134135 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], {
135136 cwd: APP,
136137 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
@@ -327,6 +328,10 @@
327328 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
328329 const bot = (await asPerson("POST", "/diffinery/agents/task-bot/tokens", { name: "second" })).json.token;
329330 const tokFile = join(work, "model-token-2");
331+ // An OpenRouter key too: its own shape, its own opt-in.
332+ assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: ANTHROPIC_KEY, accept_terms: true })).status, 422);
333+ assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: OPENROUTER_KEY })).status, 422);
334+ assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: OPENROUTER_KEY, accept_terms: true })).status, 200);
330335 const before_ = new Set(poolSandboxes());
331336 const started = await asPerson("POST", R, { goal: "Wait a while", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${tokFile}; sleep 120`, agent: "task-bot" });
332337 assert.equal(started.status, 201, JSON.stringify(started.json));
@@ -352,6 +357,37 @@
352357 assert.equal(modelRequests.length, seen, `${method} ${path} must not reach the provider`);
353358 }
354359
360+ // OpenRouter: the run's token works there too (the custom runtime gets every provider the person has a key for),
361+ // with the person's OpenRouter key and Diffinery's attribution added, and only its model endpoints.
362+ const seenOr = modelRequests.length;
363+ const orLive = await fetch(`${base}/-/model/openrouter/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json", "x-title": "spoofed", "http-referer": "https://spoofed.example", referer: "https://spoofed.example", "x-openrouter-categories": "spoofed" }, body: '{"model":"anthropic/claude-sonnet-4.5","messages":[]}' });
364+ assert.equal(orLive.status, 200);
365+ const orReq = modelRequests[seenOr];
366+ assert.equal(orReq.url, "/v1/chat/completions");
367+ assert.equal(orReq.headers.authorization, `Bearer ${OPENROUTER_KEY}`);
368+ assert.equal(orReq.headers["x-api-key"], undefined);
369+ assert.equal(orReq.headers["x-title"], "Diffinery");
370+ assert.equal(orReq.headers["http-referer"], base);
371+ // Only Diffinery names itself to OpenRouter: the sandbox's attribution headers never reach it.
372+ assert.equal(orReq.headers.referer, undefined);
373+ assert.equal(orReq.headers["x-openrouter-categories"], undefined);
374+ // Claude Code's way in: the Messages endpoint, with the token as a bearer token.
375+ assert.equal((await fetch(`${base}/-/model/openrouter/v1/messages`, { method: "POST", headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json" }, body: "{}" })).status, 200);
376+ for (const [method, path] of [
377+ ["GET", "/v1/key"],
378+ ["GET", "/v1/credits"],
379+ ["GET", "/v1/keys"],
380+ ["POST", "/v1/keys"],
381+ ["GET", "/v1/generation"],
382+ ["GET", "/v1/activity"],
383+ ["GET", "/v1/models/user"],
384+ ]) {
385+ const seen = modelRequests.length;
386+ const r = await fetch(`${base}/-/model/openrouter${path}`, { method, headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json" }, body: method === "POST" ? "{}" : undefined });
387+ assert.equal(r.status, 403, `openrouter ${method} ${path}`);
388+ assert.equal(modelRequests.length, seen, `openrouter ${method} ${path} must not reach the provider`);
389+ }
390+
355391 // No public sandbox call reaches it: the person (a repository admin here), their token, the agent.
356392 for (const [who, tok] of [
357393 ["the person in a browser", null],
@@ -399,6 +435,24 @@
399435 const t = await ok("GET", `${R}/${n}`);
400436 assert.equal(t.runs[0].state, "stopped");
401437 assert.match((await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n"), /Stopped by task-bot/);
438+
439+ // Claude Code and Codex can run through OpenRouter with a model of the person's choice; only combinations that work.
440+ const form = await (await fetch(`${base}/${owner}/tasks-demo/tasks/new`)).text();
441+ assert.match(form, /claude-code\+openrouter&quot;,&quot;label&quot;:&quot;Claude Code via OpenRouter/);
442+ assert.match(form, /codex\+openrouter&quot;,&quot;label&quot;:&quot;Codex via OpenRouter/);
443+ for (const bad of [
444+ { runtime: "codex", provider: "anthropic" },
445+ { runtime: "custom", command: "true", provider: "openrouter" },
446+ { runtime: "claude-code", provider: "openrouter", model: "not a model" },
447+ { runtime: "claude-code", model: "anthropic/claude-sonnet-4.5" },
448+ ]) assert.equal((await asPerson("POST", R, { goal: "x", agent: "task-bot", ...bad })).status, 422, JSON.stringify(bad));
449+ const viaOr = await asPerson("POST", R, { goal: "Say hi", agent: "task-bot", runtime: "claude-code", provider: "openrouter", model: "anthropic/claude-sonnet-4.5" });
450+ assert.equal(viaOr.status, 201, JSON.stringify(viaOr.json));
451+ assert.equal(viaOr.json.runtime, "claude-code");
452+ assert.equal(viaOr.json.provider, "openrouter");
453+ assert.equal(viaOr.json.model, "anthropic/claude-sonnet-4.5");
454+ assert.equal((await ok("GET", `${R}/${viaOr.json.number}`)).provider, "openrouter");
455+ await ok("POST", `${R}/${viaOr.json.number}/stop`, {}, bot);
402456 });
403457
404458 test("losing access to the agent stops the task", async () => {
modifiedapps/diffinery/test/unit/tasks.test.ts+56−1View file
@@ -1,7 +1,8 @@
11 import { describe, expect, it } from "vitest";
22 import { PROVIDERS } from "../../src/agentic/keys.js";
33 import { proxyAllows, proxyToken } from "../../src/agentic/proxy.js";
4-import { agentEnv, finalMessage, runPrompt, RUNTIMES, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js";
4+import { agentEnv, finalMessage, OPENROUTER_MODEL, parseRuntimeChoice, runPrompt, RUNTIMES, runtimeChoices, runtimeLabel, runtimeNeeds, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js";
5+import { modelRoute } from "../../src/agentic/tasks.js";
56 import { mayStop } from "../../src/agentic/tasks.js";
67 import type { TaskRow } from "../../src/agentic/ProjectDO.js";
78
@@ -52,6 +53,60 @@
5253 });
5354 });
5455
56+describe("OpenRouter", () => {
57+ it("points Claude Code at OpenRouter's Messages endpoint with the token as a bearer token and the model on every role", () => {
58+ const { env, secret } = agentEnv("https://d", ["openrouter"], "tok", { runtime: "claude-code", route: { via: "openrouter", model: "anthropic/claude-sonnet-4.5" } });
59+ expect(env.ANTHROPIC_BASE_URL).toBe("https://d/-/model/openrouter");
60+ expect(env.ANTHROPIC_API_KEY).toBe("");
61+ expect(secret).toEqual({ OPENROUTER_API_KEY: "tok", ANTHROPIC_AUTH_TOKEN: "tok" });
62+ for (const k of ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", "DIFFINERY_MODEL"]) expect(env[k]).toBe("anthropic/claude-sonnet-4.5");
63+ expect(Object.values(env)).not.toContain("tok");
64+ });
65+ it("gives Codex an OpenRouter model provider on the Responses API", () => {
66+ const { env, secret } = agentEnv("https://d", ["openrouter"], "tok", { runtime: "codex", route: { via: "openrouter", model: "openai/gpt-5" } });
67+ expect(env.OPENROUTER_BASE_URL).toBe("https://d/-/model/openrouter/v1");
68+ expect(env.DIFFINERY_MODEL).toBe("openai/gpt-5");
69+ expect(secret).toEqual({ OPENROUTER_API_KEY: "tok" });
70+ const cmd = RUNTIMES.codex.command(undefined, "openrouter");
71+ expect(cmd).toContain("-c model_provider=openrouter");
72+ expect(cmd).toContain('-c "model_providers.openrouter.base_url=$OPENROUTER_BASE_URL"');
73+ expect(cmd).toContain("-c model_providers.openrouter.env_key=OPENROUTER_API_KEY");
74+ expect(cmd).toContain("-c model_providers.openrouter.wire_api=responses");
75+ expect(cmd).toContain('-m "$DIFFINERY_MODEL"');
76+ expect(cmd.endsWith('"$DIFFINERY_TASK_PROMPT"')).toBe(true);
77+ expect(RUNTIMES.codex.command()).not.toContain("openrouter");
78+ });
79+ it("chooses where calls go, with a model only for OpenRouter", () => {
80+ expect(modelRoute("claude-code", undefined, undefined)).toEqual({ via: null, model: null });
81+ expect(modelRoute("claude-code", "anthropic", "")).toEqual({ via: null, model: null });
82+ expect(modelRoute("claude-code", "openrouter", "")).toEqual({ via: "openrouter", model: "~anthropic/claude-sonnet-latest" });
83+ expect(modelRoute("codex", "openrouter", " qwen/qwen3-coder:free ")).toEqual({ via: "openrouter", model: "qwen/qwen3-coder:free" });
84+ expect(() => modelRoute("claude-code", "openai", undefined)).toThrow(/must be anthropic or openrouter/);
85+ expect(() => modelRoute("custom", "openrouter", undefined)).toThrow(/leave `provider` out/);
86+ expect(() => modelRoute("codex", undefined, "openai/gpt-5")).toThrow(/only for OpenRouter/);
87+ expect(() => modelRoute("codex", "openrouter", "gpt-5; rm -rf /")).toThrow(/OpenRouter model id/);
88+ expect(OPENROUTER_MODEL.test("anthropic/claude-opus-4.1[1m]")).toBe(true);
89+ expect(OPENROUTER_MODEL.test("$(id)/x")).toBe(false);
90+ expect(runtimeNeeds("claude-code", "openrouter")).toEqual(["openrouter"]);
91+ expect(runtimeNeeds("claude-code", null)).toEqual(["anthropic"]);
92+ expect(runtimeLabel("codex", { via: "openrouter", model: "openai/gpt-5" })).toBe("Codex via OpenRouter (openai/gpt-5)");
93+ expect(runtimeLabel("codex", { via: null })).toBe("Codex");
94+ });
95+ it("offers each runtime through OpenRouter on the forms, enabled by the keys you have", () => {
96+ const names = { anthropic: "Anthropic", openai: "OpenAI", openrouter: "OpenRouter" } as const;
97+ const choices = runtimeChoices(new Set(["openrouter"]), names);
98+ expect(choices.map((c) => [c.value, c.disabled])).toEqual([
99+ ["claude-code", true],
100+ ["claude-code+openrouter", false],
101+ ["codex", true],
102+ ["codex+openrouter", false],
103+ ["custom", false],
104+ ]);
105+ expect(parseRuntimeChoice("codex+openrouter")).toEqual({ runtime: "codex", provider: "openrouter" });
106+ expect(parseRuntimeChoice("custom")).toEqual({ runtime: "custom", provider: undefined });
107+ });
108+});
109+
55110 describe("transcriptItem", () => {
56111 it("reads Claude Code's stream-json", () => {
57112 expect(transcriptItem(line(JSON.stringify({ type: "system", subtype: "init", model: "claude-x", session_id: "s" })))).toEqual([{ kind: "note", body: "Claude Code started (claude-x)." }]);
modifiedapps/diffinery/test/unit/usage.test.ts+36−2View file
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "vitest";
2-import { DEFAULT_MAX_OUTPUT, formatTokens, prepareRequest, readBody, readUsage, usageOf, weighted } from "../../src/agentic/usage.js";
2+import { COMPACT_MAX_OUTPUT, DEFAULT_MAX_OUTPUT, formatTokens, pinRefusal, prepareRequest, readBody, readUsage, usageOf, weighted } from "../../src/agentic/usage.js";
33
44 const stream = (chunks: string[]) =>
55 new ReadableStream<Uint8Array>({
@@ -52,11 +52,19 @@
5252 const prep = (method: string, path: string, body: string) => prepareRequest(method, path, body, new TextEncoder().encode(body).length);
5353 it("estimates the most an answer may cost: a token per 3 bytes plus the output it allows", () => {
5454 const body = JSON.stringify({ model: "m", max_tokens: 1000, messages: [{ role: "user", content: "é".repeat(300) }] });
55- expect(prep("POST", "/v1/messages", body)).toEqual({ body, estimate: { input: Math.ceil((body.length + 300) / 3), output: 1000, cacheRead: 0, cacheWrite: 0 } });
55+ expect(prep("POST", "/v1/messages", body)).toEqual({ body, model: "m", estimate: { input: Math.ceil((body.length + 300) / 3), output: 1000, cacheRead: 0, cacheWrite: 0 } });
5656 expect(prep("POST", "/v1/responses", '{"max_output_tokens":50}').estimate?.output).toBe(50);
5757 expect(prep("POST", "/v1/chat/completions", '{"max_completion_tokens":70}').estimate?.output).toBe(70);
5858 expect(prep("POST", "/v1/messages", '{"model":"m"}').estimate?.output).toBe(DEFAULT_MAX_OUTPUT);
5959 expect(prep("POST", "/v1/messages", "not json").estimate).toEqual({ input: 3, output: DEFAULT_MAX_OUTPUT, cacheRead: 0, cacheWrite: 0 });
60+ // Each endpoint's own cap fields only; Chat Completions takes either.
61+ expect(prep("POST", "/v1/messages", '{"max_output_tokens":50}').estimate?.output).toBe(DEFAULT_MAX_OUTPUT);
62+ expect(prep("POST", "/v1/responses", '{"max_tokens":50}').estimate?.output).toBe(DEFAULT_MAX_OUTPUT);
63+ expect(prep("POST", "/v1/chat/completions", '{"max_tokens":60}').estimate?.output).toBe(60);
64+ expect(prep("POST", "/v1/completions", '{"prompt":"x","max_tokens":80}').estimate?.output).toBe(80);
65+ // A compaction can't be capped: it books the most a model emits.
66+ expect(prep("POST", "/v1/responses/compact", '{"input":[],"max_output_tokens":5}').estimate?.output).toBe(COMPACT_MAX_OUTPUT);
67+ expect(prep("POST", "/v1/responses/compact", '{"input":[]}').reviewBody).toBeUndefined();
6068 // Requests that generate nothing are never estimated.
6169 expect(prep("POST", "/v1/messages/count_tokens", "{}").estimate).toBeNull();
6270 expect(prep("GET", "/v1/models", "").estimate).toBeNull();
@@ -79,6 +87,29 @@
7987 expect(prep("POST", "/v1/chat/completions", '{"n":"2"}').reviewRefuse).toMatch(/\(n\)/);
8088 expect(prep("POST", "/v1/chat/completions", '{"n":1,"max_completion_tokens":5}').reviewRefuse).toBeUndefined();
8189 });
90+ it("books the largest of a request's caps, and a review sends every cap as the one booked", () => {
91+ const p = prep("POST", "/v1/chat/completions", '{"max_completion_tokens":1,"max_tokens":100000}');
92+ expect(p.estimate?.output).toBe(100_000);
93+ expect(JSON.parse(p.reviewBody!)).toEqual({ max_completion_tokens: 100_000, max_tokens: 100_000 });
94+ expect(prep("POST", "/v1/chat/completions", '{"max_completion_tokens":7,"max_tokens":7}').reviewBody).toBeUndefined();
95+ });
96+ it("pins a review's or a pinned run's model and routing", () => {
97+ const req = (o: object) => prep("POST", "/v1/chat/completions", JSON.stringify({ max_tokens: 5, ...o }));
98+ const pinned = { review: false, model: "anthropic/claude-sonnet-4.5" };
99+ expect(pinRefusal(req({ model: "anthropic/claude-sonnet-4.5" }), pinned)).toBeUndefined();
100+ expect(pinRefusal(req({ model: "openai/o1-pro" }), pinned)).toMatch(/runs on anthropic\/claude-sonnet-4\.5, not openai\/o1-pro/);
101+ expect(pinRefusal(req({}), pinned)).toMatch(/runs on anthropic\/claude-sonnet-4\.5$/);
102+ for (const k of ["models", "route", "provider", "plugins", "web_search_options", "speed", "best_of"]) {
103+ expect(req({ model: "anthropic/claude-sonnet-4.5", [k]: k === "models" ? ["x/y"] : {} }).routing).toBe(k);
104+ expect(pinRefusal(req({ model: "anthropic/claude-sonnet-4.5", [k]: {} }), pinned)).toMatch(new RegExp(`^${k} isn't available to this task`));
105+ expect(pinRefusal(req({ model: "m", [k]: {} }), { review: true, model: null })).toMatch(new RegExp(`^${k} isn't available to agent reviews`));
106+ }
107+ // A run that isn't pinned (a custom command, or a direct provider) may choose: only reviews refuse routing.
108+ expect(pinRefusal(req({ model: "any/model", plugins: [] }), { review: false, model: null })).toBeUndefined();
109+ expect(pinRefusal(req({ model: "any/model" }), { review: true, model: null })).toBeUndefined();
110+ // Requests that generate nothing aren't pinned.
111+ expect(pinRefusal(prep("POST", "/v1/messages/count_tokens", '{"model":"other"}'), pinned)).toBeUndefined();
112+ });
82113 it("gives reviews a body capped at the estimate's output when the request sets no cap", () => {
83114 expect(JSON.parse(prep("POST", "/v1/responses", '{"input":"x"}').reviewBody!)).toEqual({ input: "x", max_output_tokens: DEFAULT_MAX_OUTPUT });
84115 expect(JSON.parse(prep("POST", "/v1/messages", '{"messages":[]}').reviewBody!)).toEqual({ messages: [], max_tokens: DEFAULT_MAX_OUTPUT });
@@ -88,6 +119,9 @@
88119 expect(prep("POST", "/v1/chat/completions", '{"max_tokens":9}').reviewBody).toBeUndefined();
89120 expect(prep("POST", "/v1/responses", '{"max_output_tokens":9}').reviewBody).toBeUndefined();
90121 expect(prep("POST", "/v1/messages/count_tokens", "{}").reviewBody).toBeUndefined();
122+ expect(JSON.parse(prep("POST", "/v1/completions", '{"prompt":"x"}').reviewBody!)).toEqual({ prompt: "x", max_tokens: DEFAULT_MAX_OUTPUT });
123+ // OpenRouter's text completions take a prompt, which isn't stored context there.
124+ expect(prep("POST", "/v1/completions", '{"prompt":"x","max_tokens":5}').reviewRefuse).toBeUndefined();
91125 });
92126 });
93127