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)
- apps/diffinery/docs/agents/review.md+4−4
- apps/diffinery/docs/agents/tasks.md+17−3
- apps/diffinery/src/agentic/ProjectDO.ts+21−9
- apps/diffinery/src/agentic/api.ts+2−2
- apps/diffinery/src/agentic/keys.ts+12−2
- apps/diffinery/src/agentic/proxy.ts+22−4
- apps/diffinery/src/agentic/review-web.tsx+13−14
- apps/diffinery/src/agentic/review.ts+4−0
- apps/diffinery/src/agentic/reviews.ts+13−9
- apps/diffinery/src/agentic/runtimes.ts+84−6
- apps/diffinery/src/agentic/tasks-web.tsx+27−14
- apps/diffinery/src/agentic/tasks.ts+38−12
- apps/diffinery/src/agentic/usage.ts+48−12
- apps/diffinery/src/docs/api-meta.ts+7−3
- apps/diffinery/src/env.ts+1−0
- apps/diffinery/test/e2e/review.test.mjs+14−4
- apps/diffinery/test/e2e/tasks.test.mjs+55−1
- apps/diffinery/test/unit/tasks.test.ts+56−1
- apps/diffinery/test/unit/usage.test.ts+36−2
modifiedapps/diffinery/docs/agents/review.md+4−4View file
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | 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**. |
| 13 | 13 | |
| 14 | 14 | 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. | |
| 16 | 16 | 3. Choose **when to review**: |
| 17 | 17 | - **Every pull request, when it opens and on every push** (the default). |
| 18 | 18 | - **Once per pull request**, when it opens or is marked ready for review. |
| @@ -113,9 +113,9 @@ | ||
| 113 | 113 | |
| 114 | 114 | Diffinery counts tokens itself, as requests pass through the model proxy: |
| 115 | 115 | |
| 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. | |
| 117 | 117 | - 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. | |
| 119 | 119 | |
| 120 | 120 | Every run's model requests and tokens show in its transcript and in the API's `runs` (`model_requests`, `tokens`). |
| 121 | 121 | |
| @@ -132,7 +132,7 @@ | ||
| 132 | 132 | | Endpoint | Does | Who | |
| 133 | 133 | | --- | --- | --- | |
| 134 | 134 | | `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 | | |
| 136 | 136 | | `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 | |
| 137 | 137 | | `POST /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | Ask for a review (`429` past a daily limit) | People with write access (not agents) | |
| 138 | 138 |
modifiedapps/diffinery/docs/agents/tasks.md+17−3View file
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | 1. **Write access** to the repository. |
| 17 | 17 | 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). | |
| 19 | 19 | |
| 20 | 20 | ## Start a task |
| 21 | 21 | |
| @@ -63,8 +63,20 @@ | ||
| 63 | 63 | | --- | --- | --- | |
| 64 | 64 | | **Claude Code** | Your Anthropic API key | `claude -p "$DIFFINERY_TASK_PROMPT" --output-format stream-json --verbose --permission-mode bypassPermissions` | |
| 65 | 65 | | **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 | | |
| 67 | 69 | |
| 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 | + | |
| 68 | 80 | 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. |
| 69 | 81 | |
| 70 | 82 | 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 @@ | ||
| 75 | 87 | |
| 76 | 88 | - **Anthropic**, for Claude Code. Create a key at console.anthropic.com. |
| 77 | 89 | - **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. | |
| 78 | 91 | |
| 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. | |
| 80 | 93 | |
| 81 | 94 | - Keys are encrypted at rest and **write-only**: no page, API call or MCP tool returns one. You see the last four characters. |
| 82 | 95 | - 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 @@ | ||
| 96 | 109 | | --- | --- | |
| 97 | 110 | | Anthropic | `POST /v1/messages`, `POST /v1/messages/count_tokens`, `GET /v1/models` and `GET /v1/models/{id}` | |
| 98 | 111 | | 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. | | |
| 99 | 113 | |
| 100 | 114 | 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. |
| 101 | 115 |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+21−9View file
| @@ -6,9 +6,9 @@ | ||
| 6 | 6 | import { taskCaller, TaskAccessError } from "./access.js"; |
| 7 | 7 | import { PROVIDERS, type Provider } from "./keys.js"; |
| 8 | 8 | 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"; | |
| 10 | 10 | 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"; | |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | 14 | * One per repository: its agent tasks. A task is a goal a person gives one of |
| @@ -41,6 +41,9 @@ | ||
| 41 | 41 | title: string; |
| 42 | 42 | runtime: RuntimeId; |
| 43 | 43 | 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; | |
| 44 | 47 | agent_id: string; |
| 45 | 48 | agent_login: string; |
| 46 | 49 | person_id: string; |
| @@ -188,6 +191,8 @@ | ||
| 188 | 191 | ["runs", "cache_read_tokens", "cache_read_tokens INTEGER NOT NULL DEFAULT 0"], |
| 189 | 192 | ["runs", "cache_write_tokens", "cache_write_tokens INTEGER NOT NULL DEFAULT 0"], |
| 190 | 193 | ["tasks", "autofix_of", "autofix_of INTEGER"], |
| 194 | + ["tasks", "via", "via TEXT"], | |
| 195 | + ["tasks", "model", "model TEXT"], | |
| 191 | 196 | ]; |
| 192 | 197 | |
| 193 | 198 | /** A finding as stored. */ |
| @@ -225,6 +230,7 @@ | ||
| 225 | 230 | goal: string; |
| 226 | 231 | runtime: RuntimeId; |
| 227 | 232 | command: string | null; |
| 233 | + route?: ModelRoute | null; | |
| 228 | 234 | agent: { id: string; login: string }; |
| 229 | 235 | person: { id: string; login: string }; |
| 230 | 236 | base: string; |
| @@ -287,7 +293,7 @@ | ||
| 287 | 293 | const at = now(); |
| 288 | 294 | const branch = input.pull?.head ?? taskBranch(number, input.goal); |
| 289 | 295 | 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', ?, ?, ?, ?)", | |
| 291 | 297 | number, |
| 292 | 298 | crypto.randomUUID(), |
| 293 | 299 | input.repoId, |
| @@ -295,6 +301,8 @@ | ||
| 295 | 301 | taskTitle(input.goal), |
| 296 | 302 | input.runtime, |
| 297 | 303 | input.command, |
| 304 | + input.route?.via ?? null, | |
| 305 | + input.route?.model ?? null, | |
| 298 | 306 | input.agent.id, |
| 299 | 307 | input.agent.login, |
| 300 | 308 | input.person.id, |
| @@ -418,6 +426,8 @@ | ||
| 418 | 426 | t.number, |
| 419 | 427 | ); |
| 420 | 428 | } |
| 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); | |
| 421 | 431 | if (this.activeRun(t.number)) { |
| 422 | 432 | // One more review after this one covers every push in between. |
| 423 | 433 | const who = input.requestedBy ?? "automatic"; |
| @@ -466,7 +476,7 @@ | ||
| 466 | 476 | */ |
| 467 | 477 | async modelAuth( |
| 468 | 478 | 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 }, | |
| 470 | 480 | ): Promise<{ task: number; run: number; personId: string; agentId: string; repoId: string; review: boolean } | { limit: string } | { refuse: string } | null> { |
| 471 | 481 | const hash = await sha256(token); |
| 472 | 482 | const r = this.sql.exec<RunRow>("SELECT * FROM runs WHERE token_hash = ? AND step = 'agent'", hash).toArray()[0]; |
| @@ -474,6 +484,8 @@ | ||
| 474 | 484 | const t = this.task(r.task)!; |
| 475 | 485 | if (!(JSON.parse(r.providers) as Provider[]).includes(req.provider)) return { refuse: `this task doesn't use ${PROVIDERS[req.provider].name}` }; |
| 476 | 486 | 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 }; | |
| 477 | 489 | const calls = r.model_calls + 1; |
| 478 | 490 | const cost = req.estimate ? weighted(req.estimate) : 0; |
| 479 | 491 | if (t.kind === "review") { |
| @@ -528,10 +540,10 @@ | ||
| 528 | 540 | const at = now(); |
| 529 | 541 | 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); |
| 530 | 542 | 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.`); | |
| 532 | 544 | else { |
| 533 | 545 | 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)}.`); | |
| 535 | 547 | } |
| 536 | 548 | this.arm(0); |
| 537 | 549 | } |
| @@ -576,8 +588,8 @@ | ||
| 576 | 588 | const providers = JSON.parse(r.providers) as Provider[]; |
| 577 | 589 | const token = `dmt_${t.repo_id}_${randomToken(32)}`; |
| 578 | 590 | 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); | |
| 581 | 593 | } |
| 582 | 594 | if (!r.checked_at || Date.parse(r.checked_at) + RECHECK_MS < Date.now()) { |
| 583 | 595 | await this.checkAccess(t); |
| @@ -771,7 +783,7 @@ | ||
| 771 | 783 | 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.`); |
| 772 | 784 | else { |
| 773 | 785 | 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 }); | |
| 775 | 787 | this.note(t.number, r.seq, `Autofix started task #${fix.number} for the blocking ${blocking.length === 1 ? "finding" : "findings"}.`); |
| 776 | 788 | } |
| 777 | 789 | } |
modifiedapps/diffinery/src/agentic/api.ts+2−2View file
| @@ -41,7 +41,7 @@ | ||
| 41 | 41 | // A key is spent by tasks this person starts; only they, in a browser, can set it. |
| 42 | 42 | const me = requirePersonSession(c); |
| 43 | 43 | 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"); | |
| 45 | 45 | const b = await jsonBody<{ key?: unknown; accept_terms?: unknown }>(c); |
| 46 | 46 | if (typeof b.key !== "string") throw new HttpError(422, "`key` is required"); |
| 47 | 47 | const k = await new ModelKeys(c.env).set(me, provider, b.key, b.accept_terms === true); |
| @@ -52,7 +52,7 @@ | ||
| 52 | 52 | agenticApi.delete("/diffinery/model-keys/:provider", async (c) => { |
| 53 | 53 | const me = requirePersonSession(c); |
| 54 | 54 | 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"); | |
| 56 | 56 | if (!(await new ModelKeys(c.env).delete(me.id, provider))) throw new HttpError(404, "no key for that provider"); |
| 57 | 57 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider })); |
| 58 | 58 | return c.body(null, 204); |
modifiedapps/diffinery/src/agentic/keys.ts+12−2View file
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * key never reaches a sandbox. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | -export type Provider = "anthropic" | "openai"; | |
| 16 | +export type Provider = "anthropic" | "openai" | "openrouter"; | |
| 17 | 17 | |
| 18 | 18 | export interface ProviderInfo { |
| 19 | 19 | id: Provider; |
| @@ -46,9 +46,19 @@ | ||
| 46 | 46 | ], |
| 47 | 47 | pattern: /^sk-[A-Za-z0-9_-]{20,400}$/, |
| 48 | 48 | }, |
| 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 | + }, | |
| 49 | 59 | }; |
| 50 | 60 | |
| 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"; | |
| 52 | 62 | |
| 53 | 63 | export interface ModelKeyInfo { |
| 54 | 64 | provider: Provider; |
modifiedapps/diffinery/src/agentic/proxy.ts+22−4View file
| @@ -17,7 +17,7 @@ | ||
| 17 | 17 | * credential and works nowhere else. |
| 18 | 18 | */ |
| 19 | 19 | |
| 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" }; | |
| 21 | 21 | |
| 22 | 22 | /** |
| 23 | 23 | * Only the model endpoints the runtimes use. The person's key could reach the |
| @@ -38,6 +38,16 @@ | ||
| 38 | 38 | ["POST", /^\/v1\/chat\/completions$/], |
| 39 | 39 | ["GET", /^\/v1\/models(\/[A-Za-z0-9._:-]+)?$/], |
| 40 | 40 | ], |
| 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 | + ], | |
| 41 | 51 | }; |
| 42 | 52 | |
| 43 | 53 | export const proxyAllows = (provider: Provider, method: string, path: string): boolean => ALLOWED[provider].some(([m, re]) => m === method && re.test(path)); |
| @@ -45,6 +55,9 @@ | ||
| 45 | 55 | /** Request headers that never go upstream: credentials, cookies and the hop's own. */ |
| 46 | 56 | 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"]); |
| 47 | 57 | |
| 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 | + | |
| 48 | 61 | const err = (status: number, message: string) => |
| 49 | 62 | 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" } }); |
| 50 | 63 | |
| @@ -78,7 +91,7 @@ | ||
| 78 | 91 | body = prepared.body; |
| 79 | 92 | } |
| 80 | 93 | 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 }); | |
| 82 | 95 | if (!run) return err(401, "this model token isn't valid: its task run has ended"); |
| 83 | 96 | if ("limit" in run) return err(429, run.limit); |
| 84 | 97 | if ("refuse" in run) return err(403, run.refuse); |
| @@ -99,10 +112,15 @@ | ||
| 99 | 112 | return err(403, `no ${PROVIDERS[provider].name} key in Settings → Model access`); |
| 100 | 113 | } |
| 101 | 114 | 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); | |
| 103 | 116 | if (provider === "anthropic") headers.set("x-api-key", key); |
| 104 | 117 | 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]; | |
| 106 | 124 | // If the provider can't be reached, the booking stays: it may have started the answer. |
| 107 | 125 | const res = await fetch(`${base}${rest}${url.search}`, { method: c.req.method, headers, body, redirect: "manual" }); |
| 108 | 126 | const out = new Headers(res.headers); |
modifiedapps/diffinery/src/agentic/review-web.tsx+13−14View file
| @@ -7,11 +7,12 @@ | ||
| 7 | 7 | import { KCheckbox, KRadio, KSelect } from "../web/kumo.js"; |
| 8 | 8 | import { page as settingsPage } from "../web/repo-settings.js"; |
| 9 | 9 | 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"; | |
| 11 | 11 | 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"; | |
| 14 | 14 | import { drivableAgents } from "./tasks.js"; |
| 15 | +import { OpenRouterModel } from "./tasks-web.js"; | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * Web pages for agent code review: Settings → Agent review, the PR page's |
| @@ -54,12 +55,8 @@ | ||
| 54 | 55 | const [current, agents, keys] = await Promise.all([reviewSettings(c.env, l.repo.id), drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id)]); |
| 55 | 56 | const v = (k: string, d: string) => values?.[k] ?? d; |
| 56 | 57 | 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; | |
| 63 | 60 | const youPay = !current || current.person.id === me.id; |
| 64 | 61 | return settingsPage( |
| 65 | 62 | c, |
| @@ -75,7 +72,7 @@ | ||
| 75 | 72 | <div class={`flash ${current.enabled ? "info" : ""}`}> |
| 76 | 73 | {current.enabled ? ( |
| 77 | 74 | <> |
| 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} />). | |
| 79 | 76 | </> |
| 80 | 77 | ) : ( |
| 81 | 78 | <>Agent review is <b>off</b>.</> |
| @@ -100,8 +97,9 @@ | ||
| 100 | 97 | </div> |
| 101 | 98 | <div class="field"> |
| 102 | 99 | <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} /> | |
| 104 | 101 | </div> |
| 102 | + <OpenRouterModel value={v("model", s.model ?? "")} /> | |
| 105 | 103 | <div class="field"> |
| 106 | 104 | <label for="command"> |
| 107 | 105 | Command <span class="faint">(custom runtime only)</span> |
| @@ -171,11 +169,12 @@ | ||
| 171 | 169 | |
| 172 | 170 | post(web, "/:owner/:repo/settings/agent-review", async (c, f) => { |
| 173 | 171 | 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") }; | |
| 175 | 173 | 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 }); | |
| 177 | 176 | 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 } })); | |
| 179 | 178 | } catch (e) { |
| 180 | 179 | const st = statusOf(e); |
| 181 | 180 | 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"; | |
| 1 | 2 | import { finalMessage, type RuntimeId, type TranscriptLine } from "./runtimes.js"; |
| 2 | 3 | |
| 3 | 4 | /** |
| @@ -22,6 +23,9 @@ | ||
| 22 | 23 | runtime: RuntimeId; |
| 23 | 24 | /** Only for the custom runtime. */ |
| 24 | 25 | 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; | |
| 25 | 29 | trigger: ReviewTrigger; |
| 26 | 30 | drafts: boolean; |
| 27 | 31 | authors: ReviewAuthors; |
modifiedapps/diffinery/src/agentic/reviews.ts+13−9View file
| @@ -9,8 +9,8 @@ | ||
| 9 | 9 | import { ModelKeys, PROVIDERS, type Provider } from "./keys.js"; |
| 10 | 10 | import type { FindingRow } from "./ProjectDO.js"; |
| 11 | 11 | 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"; | |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | 16 | * Agent code review in the Worker: who may turn it on, who may ask for a |
| @@ -35,6 +35,8 @@ | ||
| 35 | 35 | agent?: unknown; |
| 36 | 36 | runtime?: unknown; |
| 37 | 37 | command?: unknown; |
| 38 | + provider?: unknown; | |
| 39 | + model?: unknown; | |
| 38 | 40 | trigger?: unknown; |
| 39 | 41 | drafts?: unknown; |
| 40 | 42 | authors?: unknown; |
| @@ -75,10 +77,8 @@ | ||
| 75 | 77 | if (e instanceof TaskAccessError) throw new HttpError(403, e.message); |
| 76 | 78 | throw e; |
| 77 | 79 | } |
| 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); | |
| 82 | 82 | const dailyLimit = input.daily_limit === undefined || input.daily_limit === "" ? REPO_DAILY_DEFAULT : Number(input.daily_limit); |
| 83 | 83 | 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}`); |
| 84 | 84 | const tokensPerReview = input.tokens_per_review === undefined || input.tokens_per_review === "" ? REVIEW_TOKENS_DEFAULT : Number(input.tokens_per_review); |
| @@ -91,6 +91,8 @@ | ||
| 91 | 91 | person: { id: me.id, login: me.login }, |
| 92 | 92 | runtime, |
| 93 | 93 | command, |
| 94 | + via: route.via, | |
| 95 | + model: route.model, | |
| 94 | 96 | trigger, |
| 95 | 97 | drafts: input.drafts === true, |
| 96 | 98 | authors, |
| @@ -131,9 +133,10 @@ | ||
| 131 | 133 | throw e; |
| 132 | 134 | } |
| 133 | 135 | 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)); | |
| 135 | 138 | 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; | |
| 137 | 140 | 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 }); |
| 138 | 141 | return { status: r.status, task: r.task, ...(r.status === "skipped" ? { reason: "this head was already reviewed" } : r.reason ? { reason: r.reason } : {}) }; |
| 139 | 142 | } |
| @@ -215,6 +218,8 @@ | ||
| 215 | 218 | uses_key_of: { login: s.person.login, type: "User" }, |
| 216 | 219 | runtime: s.runtime, |
| 217 | 220 | ...(s.command ? { command: s.command } : {}), |
| 221 | + provider: s.via ?? RUNTIMES[s.runtime].needs[0] ?? null, | |
| 222 | + model: s.model ?? null, | |
| 218 | 223 | trigger: s.trigger, |
| 219 | 224 | drafts: s.drafts, |
| 220 | 225 | authors: s.authors, |
| @@ -242,4 +247,3 @@ | ||
| 242 | 247 | created_at: f.created_at, |
| 243 | 248 | }); |
| 244 | 249 | |
| 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 @@ | ||
| 19 | 19 | name: string; |
| 20 | 20 | /** What it says on the new-task page. */ |
| 21 | 21 | 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). */ | |
| 23 | 23 | needs: Provider[]; |
| 24 | + /** Where its model calls can go instead: OpenRouter, with a model chosen there. */ | |
| 25 | + alternatives: Provider[]; | |
| 24 | 26 | /** Run before any credential exists in the sandbox. */ |
| 25 | 27 | 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; | |
| 28 | 33 | } |
| 29 | 34 | |
| 30 | 35 | export const RUNTIMES: Record<RuntimeId, Runtime> = { |
| @@ -33,6 +38,8 @@ | ||
| 33 | 38 | name: "Claude Code", |
| 34 | 39 | description: "Anthropic's coding agent, with your Anthropic API key.", |
| 35 | 40 | needs: ["anthropic"], |
| 41 | + // OpenRouter speaks the Messages API at /api/v1/messages, so Claude Code only needs another base URL (agentEnv). | |
| 42 | + alternatives: ["openrouter"], | |
| 36 | 43 | install: `npm install --global --no-fund --no-audit --loglevel=error @anthropic-ai/claude-code@${RUNTIME_VERSIONS["claude-code"]} && claude --version`, |
| 37 | 44 | // The sandbox runs as root in its own container; IS_SANDBOX lets Claude Code skip permission prompts there. |
| 38 | 45 | command: () => 'claude -p "$DIFFINERY_TASK_PROMPT" --output-format stream-json --verbose --permission-mode bypassPermissions', |
| @@ -42,14 +49,22 @@ | ||
| 42 | 49 | name: "Codex", |
| 43 | 50 | description: "OpenAI's coding agent, with your OpenAI API key.", |
| 44 | 51 | needs: ["openai"], |
| 52 | + alternatives: ["openrouter"], | |
| 45 | 53 | 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"`, | |
| 47 | 61 | }, |
| 48 | 62 | custom: { |
| 49 | 63 | id: "custom", |
| 50 | 64 | name: "Custom command", |
| 51 | 65 | description: "Any shell command, such as another agent's CLI. It gets the model proxy for each provider you have a key for.", |
| 52 | 66 | needs: [], |
| 67 | + alternatives: [], | |
| 53 | 68 | install: null, |
| 54 | 69 | command: (custom) => custom ?? "true", |
| 55 | 70 | }, |
| @@ -57,6 +72,57 @@ | ||
| 57 | 72 | |
| 58 | 73 | export const isRuntime = (r: string): r is RuntimeId => r === "claude-code" || r === "codex" || r === "custom"; |
| 59 | 74 | |
| 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 | + | |
| 60 | 126 | /** The longest a run may take. A sandbox's git credential lasts 24 hours. */ |
| 61 | 127 | export const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000; |
| 62 | 128 | export const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; |
| @@ -68,19 +134,31 @@ | ||
| 68 | 134 | * `secret`) the task's proxy token under each provider's usual variable. The |
| 69 | 135 | * real keys stay in Diffinery. |
| 70 | 136 | */ |
| 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> } { | |
| 72 | 138 | const env: Record<string, string> = { IS_SANDBOX: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_AUTOUPDATER: "1" }; |
| 73 | 139 | const secret: Record<string, string> = {}; |
| 74 | 140 | for (const p of providers) { |
| 75 | 141 | if (p === "anthropic") { |
| 76 | 142 | env.ANTHROPIC_BASE_URL = `${publicUrl}/-/model/anthropic`; |
| 77 | 143 | secret.ANTHROPIC_API_KEY = proxyToken; |
| 78 | - } else { | |
| 144 | + } else if (p === "openai") { | |
| 79 | 145 | env.OPENAI_BASE_URL = `${publicUrl}/-/model/openai/v1`; |
| 80 | 146 | secret.OPENAI_API_KEY = proxyToken; |
| 81 | 147 | secret.CODEX_API_KEY = proxyToken; |
| 148 | + } else { | |
| 149 | + env.OPENROUTER_BASE_URL = `${publicUrl}/-/model/openrouter/v1`; | |
| 150 | + secret.OPENROUTER_API_KEY = proxyToken; | |
| 82 | 151 | } |
| 83 | 152 | } |
| 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 | + } | |
| 84 | 162 | return { env, secret }; |
| 85 | 163 | } |
| 86 | 164 |
modifiedapps/diffinery/src/agentic/tasks-web.tsx+27−14View file
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { isProvider, ModelKeys, PROVIDERS, type Provider } from "./keys.js"; |
| 11 | 11 | import type { TaskRow, TaskView } from "./ProjectDO.js"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js"; |
| 15 | 15 | |
| 16 | 16 | /** |
| @@ -27,6 +27,21 @@ | ||
| 27 | 27 | stopped: { label: "Stopped", tone: "" }, |
| 28 | 28 | }; |
| 29 | 29 | |
| 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 | + | |
| 30 | 45 | export const TaskBadge: FC<{ state: TaskRow["state"] }> = ({ state }) => <span class={`badge ${TASK_STATE[state].tone}`}>{TASK_STATE[state].label}</span>; |
| 31 | 46 | |
| 32 | 47 | // ---------------------------------------------------------------- Settings → Model access |
| @@ -43,7 +58,7 @@ | ||
| 43 | 58 | <div> |
| 44 | 59 | <h1>Model access</h1> |
| 45 | 60 | <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>. | |
| 47 | 62 | </p> |
| 48 | 63 | <div class="flash info"> |
| 49 | 64 | 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 @@ | ||
| 75 | 90 | <Form action={`/settings/model-access/${id}`} t={t}> |
| 76 | 91 | <div class="field"> |
| 77 | 92 | <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-…"} /> | |
| 79 | 94 | <div class="hint"> |
| 80 | 95 | Create one at <a href={p.keysUrl} rel="noopener noreferrer" target="_blank">{p.keysUrl.replace("https://", "")}</a>. |
| 81 | 96 | </div> |
| @@ -130,7 +145,7 @@ | ||
| 130 | 145 | </a>{" "} |
| 131 | 146 | {t.kind === "review" ? <span class="badge purple">Review</span> : null} <TaskBadge state={t.state} /> |
| 132 | 147 | <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} /> | |
| 134 | 149 | {t.pr_number ? ( |
| 135 | 150 | <> |
| 136 | 151 | {" "} |
| @@ -189,7 +204,7 @@ | ||
| 189 | 204 | {t.title} <span class="faint">#{t.number}</span> |
| 190 | 205 | </h1> |
| 191 | 206 | <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> | |
| 193 | 208 | {t.pr_number ? ( |
| 194 | 209 | <> |
| 195 | 210 | {" "} |
| @@ -259,16 +274,12 @@ | ||
| 259 | 274 | ); |
| 260 | 275 | } |
| 261 | 276 | |
| 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 } = {}) { | |
| 263 | 278 | const me = requireAccount(c); |
| 264 | 279 | const base = repoUrl(l.repo); |
| 265 | 280 | const tk = csrfToken(c); |
| 266 | 281 | 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); | |
| 272 | 283 | const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom"; |
| 273 | 284 | return render( |
| 274 | 285 | c, |
| @@ -303,12 +314,13 @@ | ||
| 303 | 314 | <label>Runtime</label> |
| 304 | 315 | <KRadio name="runtime" label="Runtime" value={v.runtime ?? firstUsable} items={runtimes} /> |
| 305 | 316 | </div> |
| 317 | + <OpenRouterModel value={v.model} /> | |
| 306 | 318 | <div class="field"> |
| 307 | 319 | <label for="command"> |
| 308 | 320 | Command <span class="faint">(custom runtime only)</span> |
| 309 | 321 | </label> |
| 310 | 322 | <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> | |
| 312 | 324 | </div> |
| 313 | 325 | <div class="row" style="align-items:flex-start"> |
| 314 | 326 | <div class="field grow"> |
| @@ -400,9 +412,10 @@ | ||
| 400 | 412 | |
| 401 | 413 | post(web, "/:owner/:repo/tasks", async (c, f) => { |
| 402 | 414 | 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 }; | |
| 404 | 416 | 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 }); | |
| 406 | 419 | return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303); |
| 407 | 420 | } catch (e) { |
| 408 | 421 | const st = statusOf(e); |
modifiedapps/diffinery/src/agentic/tasks.ts+38−12View file
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | import { HttpError } from "../lib/util.js"; |
| 6 | 6 | import { atLeast, type Caller } from "../repo/types.js"; |
| 7 | 7 | 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"; | |
| 9 | 9 | 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"; | |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * Who may do what with agent tasks. Shared by the REST API, MCP and the web. |
| @@ -51,15 +51,35 @@ | ||
| 51 | 51 | return t; |
| 52 | 52 | } |
| 53 | 53 | |
| 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[]> { | |
| 56 | 56 | 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`); | |
| 59 | 60 | } |
| 60 | - return runtime === "custom" ? [...have] : RUNTIMES[runtime].needs; | |
| 61 | + return runtime === "custom" ? [...have] : needs; | |
| 61 | 62 | } |
| 62 | 63 | |
| 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 | + | |
| 63 | 83 | async function asTaskCaller(c: AppContext, person: Account, agent: Account, l: Loaded): Promise<Caller> { |
| 64 | 84 | try { |
| 65 | 85 | return await taskCaller(c.get("dir"), person, agent, l.repo); |
| @@ -75,6 +95,9 @@ | ||
| 75 | 95 | agent?: unknown; |
| 76 | 96 | base?: unknown; |
| 77 | 97 | command?: unknown; |
| 98 | + /** The runtime's own provider (the default) or `openrouter`, with `model`. */ | |
| 99 | + provider?: unknown; | |
| 100 | + model?: unknown; | |
| 78 | 101 | /** Work on this pull request's branch (Fix it) instead of a new branch. */ |
| 79 | 102 | pull?: unknown; |
| 80 | 103 | } |
| @@ -90,6 +113,7 @@ | ||
| 90 | 113 | const command = runtime === "custom" ? (typeof input.command === "string" ? input.command.trim() : "") : null; |
| 91 | 114 | if (runtime === "custom" && !command) throw new HttpError(422, "`command` is required for the custom runtime"); |
| 92 | 115 | 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); | |
| 93 | 117 | const agentLogin = typeof input.agent === "string" ? input.agent.trim() : ""; |
| 94 | 118 | const agent = agentLogin ? await c.get("dir").accountByLogin(agentLogin) : null; |
| 95 | 119 | if (!agent || agent.kind !== "agent") throw new HttpError(422, "`agent` must be the login of one of your agents"); |
| @@ -107,9 +131,9 @@ | ||
| 107 | 131 | base = issue.pull.base; |
| 108 | 132 | } |
| 109 | 133 | 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 } : {}) } })); | |
| 113 | 137 | return t; |
| 114 | 138 | } |
| 115 | 139 | |
| @@ -131,14 +155,14 @@ | ||
| 131 | 155 | if (!body) throw new HttpError(422, "`body` is required"); |
| 132 | 156 | if (body.length > MAX_GOAL) throw new HttpError(422, `\`body\` is longer than ${MAX_GOAL} characters`); |
| 133 | 157 | 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)); | |
| 135 | 159 | 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}` })); |
| 136 | 160 | return t; |
| 137 | 161 | } |
| 138 | 162 | |
| 139 | 163 | export async function retryTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> { |
| 140 | 164 | 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)); | |
| 142 | 166 | 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}` })); |
| 143 | 167 | return t; |
| 144 | 168 | } |
| @@ -178,6 +202,8 @@ | ||
| 178 | 202 | state: t.state, |
| 179 | 203 | runtime: t.runtime, |
| 180 | 204 | ...(t.command ? { command: t.command } : {}), |
| 205 | + provider: t.via ?? RUNTIMES[t.runtime].needs[0] ?? null, | |
| 206 | + model: t.model ?? null, | |
| 181 | 207 | agent: { login: t.agent_login, type: "Bot" }, |
| 182 | 208 | started_by: { login: t.person_login, type: "User" }, |
| 183 | 209 | base: t.base, |
modifiedapps/diffinery/src/agentic/usage.ts+48−12View file
| @@ -95,11 +95,21 @@ | ||
| 95 | 95 | |
| 96 | 96 | 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)); |
| 97 | 97 | |
| 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)$/; | |
| 100 | 98 | /** The output an estimate assumes when a request doesn't cap it. */ |
| 101 | 99 | 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; | |
| 102 | 102 | |
| 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 | + | |
| 103 | 113 | export interface PreparedRequest { |
| 104 | 114 | /** The body to forward (changed only to ask for usage). */ |
| 105 | 115 | body: string; |
| @@ -107,13 +117,32 @@ | ||
| 107 | 117 | estimate: Usage | null; |
| 108 | 118 | /** Why a review may not send this request, if it may not. */ |
| 109 | 119 | 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. */ | |
| 111 | 121 | 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; | |
| 112 | 126 | } |
| 113 | 127 | |
| 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; | |
| 116 | 134 | |
| 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 | + | |
| 117 | 146 | /** Responses API fields that pull in context stored at the provider, which the request's size doesn't show. */ |
| 118 | 147 | const STORED_CONTEXT = ["previous_response_id", "conversation", "prompt"] as const; |
| 119 | 148 | |
| @@ -125,14 +154,18 @@ | ||
| 125 | 154 | * start a background response, which it could leave unfetched and so |
| 126 | 155 | * uncharged, pull in stored context its estimate can't see, or ask for more |
| 127 | 156 | * 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. | |
| 129 | 159 | */ |
| 130 | 160 | 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 }; | |
| 132 | 163 | const json = parse(body); |
| 133 | 164 | 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 }; | |
| 136 | 169 | if (!x) return { body, estimate }; |
| 137 | 170 | const stored = path.startsWith("/v1/responses") ? STORED_CONTEXT.find((k) => x[k] != null) : undefined; |
| 138 | 171 | const reviewRefuse = |
| @@ -148,10 +181,13 @@ | ||
| 148 | 181 | const so = x.stream_options && typeof x.stream_options === "object" ? (x.stream_options as Record<string, unknown>) : {}; |
| 149 | 182 | if (so.include_usage !== true) fields = { ...x, stream_options: { ...so, include_usage: true } }; |
| 150 | 183 | } |
| 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 }; | |
| 152 | 185 | 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])) }); | |
| 155 | 191 | return out; |
| 156 | 192 | } |
| 157 | 193 |
modifiedapps/diffinery/src/docs/api-meta.ts+7−3View file
| @@ -1350,14 +1350,14 @@ | ||
| 1350 | 1350 | // ------------------------------------------------------------ model access and agent tasks (agentic/) |
| 1351 | 1351 | "GET /diffinery/model-keys": { |
| 1352 | 1352 | 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.", | |
| 1354 | 1354 | access: "account", |
| 1355 | 1355 | }, |
| 1356 | 1356 | "PUT /diffinery/model-keys/{provider}": { |
| 1357 | 1357 | 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`.", | |
| 1359 | 1359 | 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`."] }, | |
| 1361 | 1361 | }, |
| 1362 | 1362 | "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 }, |
| 1363 | 1363 | "GET /diffinery/tasks": { |
| @@ -1379,6 +1379,8 @@ | ||
| 1379 | 1379 | body: { |
| 1380 | 1380 | goal: ["string", "What to do (required, at most 20,000 characters)."], |
| 1381 | 1381 | 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)."], | |
| 1382 | 1384 | agent: ["string", "The agent's login (required)."], |
| 1383 | 1385 | base: ["string", "Branch to start from (default: the default branch)."], |
| 1384 | 1386 | command: ["string", "For `custom`: the shell command to run."], |
| @@ -1416,6 +1418,8 @@ | ||
| 1416 | 1418 | body: { |
| 1417 | 1419 | agent: ["string", "The reviewing agent's login (required)."], |
| 1418 | 1420 | 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)."], | |
| 1419 | 1423 | command: ["string", "For `custom`: the shell command; it must end its output with the findings block the prompt describes."], |
| 1420 | 1424 | trigger: ["string", "`push` (default: on open and every push), `open` (once per pull request) or `request` (only when asked)."], |
| 1421 | 1425 | authors: ["string", "`all` (default), `people` or `agents`."], |
modifiedapps/diffinery/src/env.ts+1−0View file
| @@ -28,6 +28,7 @@ | ||
| 28 | 28 | /** Local development and end-to-end tests only: where the model proxy forwards instead of the providers' APIs. */ |
| 29 | 29 | MODEL_UPSTREAM_ANTHROPIC?: string; |
| 30 | 30 | MODEL_UPSTREAM_OPENAI?: string; |
| 31 | + MODEL_UPSTREAM_OPENROUTER?: string; | |
| 31 | 32 | RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>; |
| 32 | 33 | /** Cloudflare Email Sending. Missing when sending isn't set up; emails are then logged as skipped. */ |
| 33 | 34 | EMAIL?: SendEmail; |
modifiedapps/diffinery/test/e2e/review.test.mjs+14−4View file
| @@ -154,7 +154,7 @@ | ||
| 154 | 154 | base = `http://127.0.0.1:${port}`; |
| 155 | 155 | const persist = join(work, "state"); |
| 156 | 156 | 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 }; | |
| 158 | 158 | 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"], { |
| 159 | 159 | cwd: APP, |
| 160 | 160 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| @@ -493,6 +493,7 @@ | ||
| 493 | 493 | // and a review can't start a background response it would never fetch. |
| 494 | 494 | const OPENAI_KEY = `sk-proj-${randomBytes(30).toString("hex")}`; |
| 495 | 495 | 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); | |
| 496 | 497 | const probe = join(work, "usage-agent.sh"); |
| 497 | 498 | const CUT = '{"model":"m","max_tokens":500,"abort":true,"messages":[]}'; |
| 498 | 499 | writeFileSync( |
| @@ -506,6 +507,10 @@ | ||
| 506 | 507 | (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"}')"`, |
| 507 | 508 | ), |
| 508 | 509 | `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":[]}'`, | |
| 509 | 514 | `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}'`, |
| 510 | 515 | "sleep 1", |
| 511 | 516 | "", |
| @@ -520,16 +525,21 @@ | ||
| 520 | 525 | }, 180_000); |
| 521 | 526 | const run3 = t3.runs.at(-1); |
| 522 | 527 | 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); | |
| 524 | 529 | 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); | |
| 526 | 535 | // A streamed Chat Completion is asked for usage, and one with no output cap is capped at what its estimate assumed. |
| 527 | 536 | assert.equal(JSON.parse(served3[1].body).stream_options.include_usage, true); |
| 528 | 537 | assert.equal(JSON.parse(served3[1].body).max_completion_tokens, 32_000); |
| 529 | 538 | // 42 bytes / 3 + max_tokens 7 for the answer without usage, the streamed answer's own 40,000, |
| 530 | 539 | // and the estimate the cut-off answer was booked, which stays charged since its usage was never read. |
| 531 | 540 | 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); | |
| 533 | 543 | |
| 534 | 544 | // Per repository: past the payer's daily limit, a request is refused, the check asks for action, and the payer gets one email. |
| 535 | 545 | 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 @@ | ||
| 26 | 26 | const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-")); |
| 27 | 27 | const poolRoot = join(work, "pool"); |
| 28 | 28 | const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`; |
| 29 | +const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`; | |
| 29 | 30 | let base; |
| 30 | 31 | let dev; |
| 31 | 32 | let pool; |
| @@ -130,7 +131,7 @@ | ||
| 130 | 131 | base = `http://127.0.0.1:${port}`; |
| 131 | 132 | const persist = join(work, "state"); |
| 132 | 133 | 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 }; | |
| 134 | 135 | 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"], { |
| 135 | 136 | cwd: APP, |
| 136 | 137 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| @@ -327,6 +328,10 @@ | ||
| 327 | 328 | const R = `/repos/${owner}/tasks-demo/diffinery/tasks`; |
| 328 | 329 | const bot = (await asPerson("POST", "/diffinery/agents/task-bot/tokens", { name: "second" })).json.token; |
| 329 | 330 | 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); | |
| 330 | 335 | const before_ = new Set(poolSandboxes()); |
| 331 | 336 | const started = await asPerson("POST", R, { goal: "Wait a while", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${tokFile}; sleep 120`, agent: "task-bot" }); |
| 332 | 337 | assert.equal(started.status, 201, JSON.stringify(started.json)); |
| @@ -352,6 +357,37 @@ | ||
| 352 | 357 | assert.equal(modelRequests.length, seen, `${method} ${path} must not reach the provider`); |
| 353 | 358 | } |
| 354 | 359 | |
| 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 | + | |
| 355 | 391 | // No public sandbox call reaches it: the person (a repository admin here), their token, the agent. |
| 356 | 392 | for (const [who, tok] of [ |
| 357 | 393 | ["the person in a browser", null], |
| @@ -399,6 +435,24 @@ | ||
| 399 | 435 | const t = await ok("GET", `${R}/${n}`); |
| 400 | 436 | assert.equal(t.runs[0].state, "stopped"); |
| 401 | 437 | 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","label":"Claude Code via OpenRouter/); | |
| 442 | + assert.match(form, /codex\+openrouter","label":"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); | |
| 402 | 456 | }); |
| 403 | 457 | |
| 404 | 458 | test("losing access to the agent stops the task", async () => { |
modifiedapps/diffinery/test/unit/tasks.test.ts+56−1View file
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { PROVIDERS } from "../../src/agentic/keys.js"; |
| 3 | 3 | 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"; | |
| 5 | 6 | import { mayStop } from "../../src/agentic/tasks.js"; |
| 6 | 7 | import type { TaskRow } from "../../src/agentic/ProjectDO.js"; |
| 7 | 8 | |
| @@ -52,6 +53,60 @@ | ||
| 52 | 53 | }); |
| 53 | 54 | }); |
| 54 | 55 | |
| 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 | + | |
| 55 | 110 | describe("transcriptItem", () => { |
| 56 | 111 | it("reads Claude Code's stream-json", () => { |
| 57 | 112 | 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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | |
| 4 | 4 | const stream = (chunks: string[]) => |
| 5 | 5 | new ReadableStream<Uint8Array>({ |
| @@ -52,11 +52,19 @@ | ||
| 52 | 52 | const prep = (method: string, path: string, body: string) => prepareRequest(method, path, body, new TextEncoder().encode(body).length); |
| 53 | 53 | it("estimates the most an answer may cost: a token per 3 bytes plus the output it allows", () => { |
| 54 | 54 | 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 } }); | |
| 56 | 56 | expect(prep("POST", "/v1/responses", '{"max_output_tokens":50}').estimate?.output).toBe(50); |
| 57 | 57 | expect(prep("POST", "/v1/chat/completions", '{"max_completion_tokens":70}').estimate?.output).toBe(70); |
| 58 | 58 | expect(prep("POST", "/v1/messages", '{"model":"m"}').estimate?.output).toBe(DEFAULT_MAX_OUTPUT); |
| 59 | 59 | 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(); | |
| 60 | 68 | // Requests that generate nothing are never estimated. |
| 61 | 69 | expect(prep("POST", "/v1/messages/count_tokens", "{}").estimate).toBeNull(); |
| 62 | 70 | expect(prep("GET", "/v1/models", "").estimate).toBeNull(); |
| @@ -79,6 +87,29 @@ | ||
| 79 | 87 | expect(prep("POST", "/v1/chat/completions", '{"n":"2"}').reviewRefuse).toMatch(/\(n\)/); |
| 80 | 88 | expect(prep("POST", "/v1/chat/completions", '{"n":1,"max_completion_tokens":5}').reviewRefuse).toBeUndefined(); |
| 81 | 89 | }); |
| 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 | + }); | |
| 82 | 113 | it("gives reviews a body capped at the estimate's output when the request sets no cap", () => { |
| 83 | 114 | expect(JSON.parse(prep("POST", "/v1/responses", '{"input":"x"}').reviewBody!)).toEqual({ input: "x", max_output_tokens: DEFAULT_MAX_OUTPUT }); |
| 84 | 115 | expect(JSON.parse(prep("POST", "/v1/messages", '{"messages":[]}').reviewBody!)).toEqual({ messages: [], max_tokens: DEFAULT_MAX_OUTPUT }); |
| @@ -88,6 +119,9 @@ | ||
| 88 | 119 | expect(prep("POST", "/v1/chat/completions", '{"max_tokens":9}').reviewBody).toBeUndefined(); |
| 89 | 120 | expect(prep("POST", "/v1/responses", '{"max_output_tokens":9}').reviewBody).toBeUndefined(); |
| 90 | 121 | 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(); | |
| 91 | 125 | }); |
| 92 | 126 | }); |
| 93 | 127 |