co-maintainer 0.5.1-beta.1 → 0.5.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,8 +43,8 @@ co-maintainer review --output=github # annotations and a job summary inside Git
43
43
  Settings live in `config.json`, so a flag you use every time can be saved once:
44
44
 
45
45
  ```sh
46
- co-maintainer config set ai openrouter # or openai, or anthropic
47
- co-maintainer config set ai-key sk-or-... # your provider's key (OpenRouter, OpenAI or Anthropic)
46
+ co-maintainer config set ai openrouter # or openai, anthropic, opencode-zen, opencode-go
47
+ co-maintainer config set ai-key sk-or-... # your provider's key (OpenRouter, OpenAI, Anthropic or OpenCode)
48
48
  co-maintainer config set high-model openai/gpt-5.6-luna
49
49
  co-maintainer config set low-model openai/gpt-oss-120b
50
50
  co-maintainer config list # every key, its value and its source
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1-beta.1",
3
+ "version": "0.5.1-beta.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,10 @@
1
+ import type { ChatHost } from "./openrouter.ts";
2
+ import type { VerifyResult } from "./verify.ts";
3
+ export type OpenCodeVariant = "opencode-zen" | "opencode-go";
4
+ /** `CM_OPENCODE_URL` replaces the origin in tests. Zen and Go differ only in
5
+ * the path under it. */
6
+ export declare function openCodeBase(variant: OpenCodeVariant): string;
7
+ export declare function openCodeHost(variant: OpenCodeVariant): ChatHost;
8
+ /** `/models` is public, so it proves nothing about the key. Only the model is
9
+ * checked here, and a bad key shows up on the first real call. */
10
+ export declare function verifyOpenCode(variant: OpenCodeVariant, _apiKey: string, model?: string): Promise<VerifyResult>;
@@ -0,0 +1,76 @@
1
+ /** OpenCode Zen and OpenCode Go, two gateways run by the OpenCode team.
2
+ *
3
+ * Both route each model to its own wire format (Responses, Messages, Chat
4
+ * Completions, Google), and the model list does not say which. Every model is
5
+ * called over Chat Completions instead of keeping a model to format table
6
+ * that goes stale with each new model. A model that refuses it fails with a
7
+ * hint that says so. */
8
+ import { randomUUID } from "node:crypto";
9
+ import { CliError, EXIT_USAGE } from "../cli/error.js";
10
+ import { getEnv } from "../util/runtime.js";
11
+ import { VERSION } from "../version.js";
12
+ /** Go asks every client for one session id that stays the same for the run,
13
+ * so one per process. */
14
+ const SESSION_ID = randomUUID();
15
+ /** `CM_OPENCODE_URL` replaces the origin in tests. Zen and Go differ only in
16
+ * the path under it. */
17
+ export function openCodeBase(variant) {
18
+ const origin = (getEnv("CM_OPENCODE_URL") ?? "https://opencode.ai").replace(/\/$/, "");
19
+ return `${origin}${variant === "opencode-go" ? "/zen/go/v1" : "/zen/v1"}`;
20
+ }
21
+ function displayName(variant) {
22
+ return variant === "opencode-go" ? "OpenCode Go" : "OpenCode Zen";
23
+ }
24
+ export function openCodeHost(variant) {
25
+ return {
26
+ provider: variant,
27
+ name: displayName(variant),
28
+ code: "opencode",
29
+ endpoint: `${openCodeBase(variant)}/chat/completions`,
30
+ // Go wants its own user agent and a stable session id from every client.
31
+ // Zen does not ask, and sending the same pair costs nothing.
32
+ headers: {
33
+ "User-Agent": `co-maintainer/${VERSION}`,
34
+ "x-opencode-session": SESSION_ID,
35
+ },
36
+ modelHint: "co-maintainer calls every OpenCode model over Chat Completions. Pick one that supports it from https://opencode.ai/docs/zen and set it with co-maintainer set --high-model=...",
37
+ };
38
+ }
39
+ /** `/models` is public, so it proves nothing about the key. Only the model is
40
+ * checked here, and a bad key shows up on the first real call. */
41
+ export async function verifyOpenCode(variant, _apiKey, model) {
42
+ if (!model)
43
+ return { status: "ok" };
44
+ const name = displayName(variant);
45
+ let response;
46
+ try {
47
+ response = await fetch(`${openCodeBase(variant)}/models`);
48
+ }
49
+ catch (error) {
50
+ const code = error.cause?.code;
51
+ return { status: "unreachable", reason: code ?? "network error" };
52
+ }
53
+ if (!response.ok) {
54
+ return {
55
+ status: "unreachable",
56
+ reason: `${name} returned ${response.status}`,
57
+ };
58
+ }
59
+ let rows;
60
+ try {
61
+ rows = (await response.json()).data;
62
+ }
63
+ catch {
64
+ rows = undefined;
65
+ }
66
+ if (!Array.isArray(rows)) {
67
+ return { status: "unreachable", reason: `${name} returned no model list` };
68
+ }
69
+ if (!rows.some((row) => row?.id === model)) {
70
+ return {
71
+ status: "rejected",
72
+ error: new CliError("opencode_unknown_model", `${name} does not know the model ${model}.`, "Pick one from https://opencode.ai/docs/zen and set it with co-maintainer set --high-model=...", EXIT_USAGE),
73
+ };
74
+ }
75
+ return { status: "ok" };
76
+ }
@@ -1,22 +1,38 @@
1
1
  import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
2
2
  import { CliError } from "../cli/error.ts";
3
+ /** A service that speaks OpenAI-compatible Chat Completions. OpenRouter is
4
+ * one, OpenCode Zen and OpenCode Go are the others, so they share this client
5
+ * and differ only in where they live and how they name themselves. */
6
+ export type ChatHost = {
7
+ provider: AiResponse["provider"];
8
+ /** Shown in every message, e.g. `OpenCode Zen rejected the API key.` */
9
+ name: string;
10
+ /** Prefix of the error codes, e.g. `openrouter_unauthorized`. */
11
+ code: string;
12
+ endpoint: string;
13
+ headers: Record<string, string>;
14
+ /** The hint for a model the host refuses. */
15
+ modelHint: string;
16
+ };
17
+ /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
18
+ * only a default override: with the env unset the real endpoint is used, so
19
+ * nothing changes for users. */
20
+ export declare function openRouterHost(): ChatHost;
3
21
  /** Provider failures as actionable text. A 401 and a 400 for
4
22
  * an unknown model are preconditions the user can fix, so they are usage
5
23
  * errors; anything else is a runtime failure. Only the provider's own
6
24
  * `error.message` is surfaced: the raw body can carry account metadata
7
25
  * (`user_id`) that has no business in a terminal. */
8
- export declare function openRouterError(model: string, status: number, body: string): CliError;
26
+ export declare function openRouterError(model: string, status: number, body: string, host?: ChatHost): CliError;
9
27
  export declare class OpenRouterProvider implements AiProvider {
10
28
  readonly supportsTools = true;
11
- /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
12
- * only a default override: with the env unset the real endpoint is used, so
13
- * nothing changes for users. */
14
- private readonly endpoint;
29
+ private readonly host;
15
30
  private readonly apiKey;
16
31
  private readonly model;
17
- constructor(apiKey: string, model: string);
32
+ constructor(apiKey: string, model: string, host?: ChatHost);
18
33
  /** One POST. Returns the raw response so `complete` can decide whether the
19
34
  * failure is worth a retry without the schema. */
20
35
  private post;
36
+ private fail;
21
37
  complete(request: AiRequest): Promise<AiResponse>;
22
38
  }
@@ -1,12 +1,29 @@
1
1
  import { chatBody, parseChatResponse } from "./provider.js";
2
2
  import { getEnv } from "../util/runtime.js";
3
3
  import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
4
+ /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
5
+ * only a default override: with the env unset the real endpoint is used, so
6
+ * nothing changes for users. */
7
+ export function openRouterHost() {
8
+ return {
9
+ provider: "openrouter",
10
+ name: "OpenRouter",
11
+ code: "openrouter",
12
+ endpoint: getEnv("CM_OPENROUTER_URL") ??
13
+ "https://openrouter.ai/api/v1/chat/completions",
14
+ headers: {
15
+ "HTTP-Referer": "https://github.com/GroophyLifefor/co-maintainer",
16
+ "X-Title": "co-maintainer",
17
+ },
18
+ modelHint: "Pick one at https://openrouter.ai/models and set it with co-maintainer set --high-model=...",
19
+ };
20
+ }
4
21
  /** Provider failures as actionable text. A 401 and a 400 for
5
22
  * an unknown model are preconditions the user can fix, so they are usage
6
23
  * errors; anything else is a runtime failure. Only the provider's own
7
24
  * `error.message` is surfaced: the raw body can carry account metadata
8
25
  * (`user_id`) that has no business in a terminal. */
9
- export function openRouterError(model, status, body) {
26
+ export function openRouterError(model, status, body, host = openRouterHost()) {
10
27
  let detail = "";
11
28
  try {
12
29
  const parsed = JSON.parse(body);
@@ -16,25 +33,22 @@ export function openRouterError(model, status, body) {
16
33
  detail = body.trim().slice(0, 200);
17
34
  }
18
35
  if (status === 401 || status === 403) {
19
- return new CliError("openrouter_unauthorized", "OpenRouter rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
36
+ return new CliError(`${host.code}_unauthorized`, `${host.name} rejected the API key.`, "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
20
37
  }
21
38
  if (status === 400 && /model/i.test(detail)) {
22
- return new CliError("openrouter_unknown_model", `OpenRouter does not know the model ${model}.`, `Pick one at https://openrouter.ai/models and set it with co-maintainer set --high-model=...`, EXIT_USAGE);
39
+ return new CliError(`${host.code}_unknown_model`, `${host.name} does not know the model ${model}.`, host.modelHint, EXIT_USAGE);
23
40
  }
24
- return new CliError("openrouter_failed", `OpenRouter failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
41
+ return new CliError(`${host.code}_failed`, `${host.name} failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
25
42
  }
26
43
  export class OpenRouterProvider {
27
44
  supportsTools = true;
28
- /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
29
- * only a default override: with the env unset the real endpoint is used, so
30
- * nothing changes for users. */
31
- endpoint = getEnv("CM_OPENROUTER_URL") ??
32
- "https://openrouter.ai/api/v1/chat/completions";
45
+ host;
33
46
  apiKey;
34
47
  model;
35
- constructor(apiKey, model) {
48
+ constructor(apiKey, model, host = openRouterHost()) {
36
49
  if (!apiKey)
37
- throw new Error("OpenRouter requires OPENROUTER_API_KEY");
50
+ throw new Error(`${host.name} requires an API key`);
51
+ this.host = host;
38
52
  this.apiKey = apiKey;
39
53
  this.model = model;
40
54
  }
@@ -42,22 +56,24 @@ export class OpenRouterProvider {
42
56
  * failure is worth a retry without the schema. */
43
57
  async post(body) {
44
58
  try {
45
- return await fetch(this.endpoint, {
59
+ return await fetch(this.host.endpoint, {
46
60
  method: "POST",
47
61
  headers: {
48
62
  Authorization: `Bearer ${this.apiKey}`,
49
63
  "Content-Type": "application/json",
50
- "HTTP-Referer": "https://github.com/GroophyLifefor/co-maintainer",
51
- "X-Title": "co-maintainer",
64
+ ...this.host.headers,
52
65
  },
53
66
  body: JSON.stringify(body),
54
67
  });
55
68
  }
56
69
  catch (error) {
57
70
  // `fetch failed` alone names neither the host nor the cause.
58
- throw networkFailure(this.endpoint, error);
71
+ throw networkFailure(this.host.endpoint, error);
59
72
  }
60
73
  }
74
+ fail(status, body) {
75
+ return openRouterError(this.model, status, body, this.host);
76
+ }
61
77
  async complete(request) {
62
78
  let response = await this.post(chatBody(this.model, request));
63
79
  // A provider may refuse `response_format` next to `tools`. The schema is
@@ -69,15 +85,15 @@ export class OpenRouterProvider {
69
85
  const { responseFormat: _dropped, ...withoutSchema } = request;
70
86
  response = await this.post(chatBody(this.model, withoutSchema));
71
87
  if (response.ok) {
72
- return parseChatResponse((await response.json()), "openrouter", this.model);
88
+ return parseChatResponse((await response.json()), this.host.provider, this.model);
73
89
  }
74
- throw openRouterError(this.model, response.status, await response.text());
90
+ throw this.fail(response.status, await response.text());
75
91
  }
76
- throw openRouterError(this.model, response.status, detail);
92
+ throw this.fail(response.status, detail);
77
93
  }
78
94
  if (!response.ok) {
79
- throw openRouterError(this.model, response.status, await response.text());
95
+ throw this.fail(response.status, await response.text());
80
96
  }
81
- return parseChatResponse((await response.json()), "openrouter", this.model);
97
+ return parseChatResponse((await response.json()), this.host.provider, this.model);
82
98
  }
83
99
  }
@@ -2,7 +2,10 @@ import type { AiProvider, AiRequest, AiResponse, Json, Options } from "../types.
2
2
  /** The one list of accepted `--ai` values, so a new or retired provider is
3
3
  * added or removed in a single place instead of the half dozen copies
4
4
  * Hetzner's removal had to chase down. */
5
- export declare const AI_PROVIDERS: readonly ["none", "openrouter", "openai", "anthropic"];
5
+ export declare const AI_PROVIDERS: readonly ["none", "openrouter", "openai", "anthropic", "opencode-zen", "opencode-go"];
6
+ /** The provider's own key variable. It is read only when that provider runs,
7
+ * so a key meant for one provider is never sent to another. */
8
+ export declare function providerKeyEnv(ai: string, env: (name: string) => string | undefined): string | undefined;
6
9
  /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
7
10
  export declare function rejectRetiredProvider(value: string | undefined): void;
8
11
  export declare function createAiProvider(options: Options, model: string): AiProvider | undefined;
@@ -1,6 +1,7 @@
1
1
  import { OpenRouterProvider } from "./openrouter.js";
2
2
  import { OpenAiProvider } from "./openai.js";
3
3
  import { AnthropicProvider } from "./anthropic.js";
4
+ import { openCodeHost } from "./opencode.js";
4
5
  import { die } from "../cli/error.js";
5
6
  /** The one list of accepted `--ai` values, so a new or retired provider is
6
7
  * added or removed in a single place instead of the half dozen copies
@@ -10,7 +11,19 @@ export const AI_PROVIDERS = [
10
11
  "openrouter",
11
12
  "openai",
12
13
  "anthropic",
14
+ "opencode-zen",
15
+ "opencode-go",
13
16
  ];
17
+ /** The provider's own key variable. It is read only when that provider runs,
18
+ * so a key meant for one provider is never sent to another. */
19
+ export function providerKeyEnv(ai, env) {
20
+ if (ai === "openrouter")
21
+ return env("OPENROUTER_API_KEY");
22
+ if (ai === "opencode-zen" || ai === "opencode-go") {
23
+ return env("OPENCODE_API_KEY");
24
+ }
25
+ return undefined;
26
+ }
14
27
  /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
15
28
  export function rejectRetiredProvider(value) {
16
29
  if (value === "hetzner") {
@@ -29,6 +42,9 @@ export function createAiProvider(options, model) {
29
42
  if (options.ai === "anthropic") {
30
43
  return new AnthropicProvider(options.aiToken ?? "", model);
31
44
  }
45
+ if (options.ai === "opencode-zen" || options.ai === "opencode-go") {
46
+ return new OpenRouterProvider(options.aiToken ?? "", model, openCodeHost(options.ai));
47
+ }
32
48
  die(`${options.ai} support is not finished yet. Use openrouter or none.`);
33
49
  }
34
50
  export function parseChatResponse(json, provider, model) {
@@ -2,7 +2,7 @@ import { prepareConfig } from "../config.js";
2
2
  import { getEnv } from "../util/runtime.js";
3
3
  import { askLine } from "./prompt.js";
4
4
  import { die } from "./error.js";
5
- import { AI_PROVIDERS, rejectRetiredProvider } from "../ai/provider.js";
5
+ import { AI_PROVIDERS, providerKeyEnv, rejectRetiredProvider, } from "../ai/provider.js";
6
6
  import { detectRemoteRepo } from "../local/git_ops.js";
7
7
  import { reviewBlockingFrom } from "../review/blocking.js";
8
8
  import { renderCommandHelp, renderGlobalHelp, unknownCommandMessage, unknownOptionMessage, } from "./commands/registry.js";
@@ -190,10 +190,15 @@ export async function parseArgs(args) {
190
190
  rejectRetiredProvider(text("ai"));
191
191
  let ai = choice("ai", AI_PROVIDERS, configuredAi ?? "none");
192
192
  if (command === "review") {
193
- if (explicitAi && choice("ai", AI_PROVIDERS, "none") !== "openrouter") {
194
- die("review supports OpenRouter only");
193
+ // Review was pinned to OpenRouter only so Hetzner never wrote one. Hetzner
194
+ // is gone, so review follows the configured provider. With none set it
195
+ // stays on OpenRouter, which every review before this one used.
196
+ if (ai === "none") {
197
+ if (explicitAi) {
198
+ die(`review needs an AI provider. Pass --ai=${AI_PROVIDERS.filter((p) => p !== "none").join("|")}`);
199
+ }
200
+ ai = "openrouter";
195
201
  }
196
- ai = "openrouter";
197
202
  }
198
203
  else if (!explicitAi && !configuredAi && command !== "probe") {
199
204
  const selected = await ask(`AI provider (${AI_PROVIDERS.join("|")})`, "openrouter");
@@ -205,20 +210,23 @@ export async function parseArgs(args) {
205
210
  }
206
211
  let aiToken = text("token") ??
207
212
  env("CO_MAINTAINER_TOKEN") ??
208
- (ai === "openrouter" ? env("OPENROUTER_API_KEY") : undefined) ??
213
+ providerKeyEnv(ai, env) ??
209
214
  config.token;
215
+ // The OPENROUTER_ names would hand an OpenRouter model id to another
216
+ // provider, so they only count when OpenRouter is the one running.
217
+ const openRouterModelEnv = (name) => ai === "openrouter" ? env(name) : undefined;
210
218
  let lowModel = text("low-model") ??
211
- env("OPENROUTER_LOW_MODEL") ??
219
+ openRouterModelEnv("OPENROUTER_LOW_MODEL") ??
212
220
  env("LOW_MODEL") ??
213
221
  repoConfig.lowModel ??
214
222
  config.lowModel;
215
223
  let highModel = text("high-model") ??
216
- env("OPENROUTER_HIGH_MODEL") ??
224
+ openRouterModelEnv("OPENROUTER_HIGH_MODEL") ??
217
225
  env("HIGH_MODEL") ??
218
226
  repoConfig.highModel ??
219
227
  config.highModel;
220
228
  if (command === "review") {
221
- aiToken ??= await ask("openrouter API key", undefined, true);
229
+ aiToken ??= await ask(`${ai} API key`, undefined, true);
222
230
  // Review summarizes large files with the low model, so it needs one just
223
231
  // as init and sync do. Without a default to fall back on, ask here too.
224
232
  lowModel ??= await ask("low model", undefined, true);
@@ -39,7 +39,7 @@ export function maskSecret(value) {
39
39
  /** The env var that supplies each field, mirroring args.ts. Only the fields
40
40
  * that actually read env are listed. */
41
41
  export const ENV_BY_FIELD = {
42
- token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY"],
42
+ token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY", "OPENCODE_API_KEY"],
43
43
  githubPat: ["GITHUB_TOKEN", "GH_TOKEN"],
44
44
  ai: ["CO_MAINTAINER_AI"],
45
45
  auth: ["CO_MAINTAINER_AUTH"],
@@ -1,11 +1,3 @@
1
- /** The command registry.
2
- *
3
- * One place that knows every command, its aliases, its usage lines, its flags
4
- * and its examples. Everything the user reads as help is rendered from here,
5
- * so a flag cannot drift out of the help and a command cannot be documented
6
- * twice with different wording. It is also the source the command reference is
7
- * generated from, which is why it is data rather than template strings.
8
- */
9
1
  export type FlagSpec = {
10
2
  /** Without the leading dashes, e.g. `include-codebase`. */
11
3
  name: string;
@@ -6,6 +6,7 @@
6
6
  * twice with different wording. It is also the source the command reference is
7
7
  * generated from, which is why it is data rather than template strings.
8
8
  */
9
+ import { AI_PROVIDERS } from "../../ai/provider.js";
9
10
  const OUTPUT_FLAGS = {
10
11
  title: "Output and diagnostics",
11
12
  flags: [
@@ -34,7 +35,7 @@ const AI_FLAGS = {
34
35
  flags: [
35
36
  {
36
37
  name: "ai",
37
- value: "none|openrouter|openai|anthropic",
38
+ value: AI_PROVIDERS.join("|"),
38
39
  default: "none",
39
40
  description: "Which provider writes the review.",
40
41
  },
@@ -56,16 +57,6 @@ const AI_FLAGS = {
56
57
  },
57
58
  ],
58
59
  };
59
- /** `review` always calls OpenRouter regardless of what `--ai=` is passed,
60
- * so its own help must not advertise the other three names the
61
- * other AI commands accept. */
62
- const REVIEW_AI_FLAGS = {
63
- ...AI_FLAGS,
64
- flags: [
65
- { ...AI_FLAGS.flags[0], value: "openrouter" },
66
- ...AI_FLAGS.flags.slice(1),
67
- ],
68
- };
69
60
  const GITHUB_FLAGS = {
70
61
  title: "GitHub access",
71
62
  flags: [
@@ -364,7 +355,7 @@ export const COMMANDS = [
364
355
  },
365
356
  ],
366
357
  },
367
- REVIEW_AI_FLAGS,
358
+ AI_FLAGS,
368
359
  GITHUB_FLAGS,
369
360
  REVIEW_OUTPUT_FLAGS,
370
361
  ],
@@ -549,7 +540,7 @@ export const COMMANDS = [
549
540
  },
550
541
  {
551
542
  name: "no-verify",
552
- description: "Do not check the key and model against OpenRouter.",
543
+ description: "Do not check the key and model against the provider.",
553
544
  },
554
545
  {
555
546
  name: "unset",
@@ -2,6 +2,7 @@ import { readConfig, configPath, writeUserConfig } from "../../config.js";
2
2
  import { hashPassword, passwordProblem } from "../../util/password.js";
3
3
  import { readTextFile } from "../../util/runtime.js";
4
4
  import { verifyAnthropic, verifyOpenAi, verifyOpenRouter, } from "../../ai/verify.js";
5
+ import { verifyOpenCode } from "../../ai/opencode.js";
5
6
  import { die } from "../error.js";
6
7
  import { parsePolicy } from "../../services/review_policy.js";
7
8
  import { AI_PROVIDERS, rejectRetiredProvider } from "../../ai/provider.js";
@@ -266,16 +267,15 @@ export async function runSet(args) {
266
267
  const effectiveAi = effective("ai");
267
268
  const effectiveToken = effective("token");
268
269
  const effectiveHighModel = effective("highModel");
269
- if (verify &&
270
- (effectiveAi === "openrouter" ||
271
- effectiveAi === "openai" ||
272
- effectiveAi === "anthropic") &&
273
- effectiveToken) {
274
- const verifyProvider = effectiveAi === "openrouter"
275
- ? verifyOpenRouter
276
- : effectiveAi === "openai"
277
- ? verifyOpenAi
278
- : verifyAnthropic;
270
+ const verifiers = {
271
+ openrouter: verifyOpenRouter,
272
+ openai: verifyOpenAi,
273
+ anthropic: verifyAnthropic,
274
+ "opencode-zen": (key, model) => verifyOpenCode("opencode-zen", key, model),
275
+ "opencode-go": (key, model) => verifyOpenCode("opencode-go", key, model),
276
+ };
277
+ const verifyProvider = typeof effectiveAi === "string" ? verifiers[effectiveAi] : undefined;
278
+ if (verify && verifyProvider && effectiveToken) {
279
279
  const result = await verifyProvider(String(effectiveToken), effectiveHighModel === undefined ? undefined : String(effectiveHighModel));
280
280
  if (result.status === "rejected")
281
281
  throw result.error;
@@ -5,7 +5,7 @@ import { type Platform } from "./util/runtime.ts";
5
5
  * flags and no interactive prompts, reusing what `init` was told. */
6
6
  export type RepoConfig = {
7
7
  auth?: "gh" | "pat";
8
- ai?: "none" | "openrouter" | "openai" | "anthropic";
8
+ ai?: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
9
9
  lowModel?: string;
10
10
  highModel?: string;
11
11
  maxCommits?: number;
@@ -24,7 +24,7 @@ export type RepoConfig = {
24
24
  };
25
25
  export type UserConfig = {
26
26
  auth?: "gh" | "pat";
27
- ai?: "none" | "openrouter" | "openai" | "anthropic";
27
+ ai?: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
28
28
  lowModel?: string;
29
29
  highModel?: string;
30
30
  /** Written only by `co-maintainer set --token=...`. Every other write
@@ -1,4 +1,4 @@
1
- import { OpenRouterProvider } from "../ai/openrouter.js";
1
+ import { createAiProvider } from "../ai/provider.js";
2
2
  import { CliError, EXIT_USAGE } from "../cli/error.js";
3
3
  import { completeWithMermaidTools, } from "../ai/mermaid_loop.js";
4
4
  import { loadGuides } from "../review/guides.js";
@@ -11,6 +11,15 @@ import { FINDINGS_JSON_INSTRUCTIONS, FINDINGS_JSON_SCHEMA, findingsMarkdownFromJ
11
11
  /** A model id is never guessed here. Models go stale, and a build already
12
12
  * installed cannot fetch a fresher one, so the id to run comes from config
13
13
  * or a flag, set at least once by whoever runs it (Murat, 2026-09-28). */
14
+ /** The configured provider for one model. Built only when a call needs it:
15
+ * a provider's constructor requires a real key, which a fake-AI run never has. */
16
+ function reviewProvider(options, model, label) {
17
+ const provider = createAiProvider(options, requiredModel(model, label));
18
+ if (!provider) {
19
+ throw new CliError("missing_provider", "Review needs an AI provider.", "Run co-maintainer set --ai=... --token=...", EXIT_USAGE);
20
+ }
21
+ return provider;
22
+ }
14
23
  function requiredModel(model, label) {
15
24
  if (model)
16
25
  return model;
@@ -243,12 +252,10 @@ export async function reviewPullRequest(client, options, usage, snapshot, ai, pr
243
252
  if (scope) {
244
253
  report(`scope resolved · own=${ownFiles.length} files · upstream=${upstreamFiles.length} files`);
245
254
  }
246
- // Built only if needed: OpenRouterProvider's constructor requires a real
247
- // API key, which a fake-AI test run never has.
255
+ // Built only if needed, see reviewProvider.
248
256
  const filesNeedSummary = ownFiles.some(({ file }) => needsSummary(Number(file.changes ?? 0), String(file.patch ?? "")));
249
257
  const lowProvider = filesNeedSummary
250
- ? (ai ??
251
- new OpenRouterProvider(options.aiToken ?? "", requiredModel(options.lowModel, "low")))
258
+ ? (ai ?? reviewProvider(options, options.lowModel, "low"))
252
259
  : undefined;
253
260
  const patchByPath = new Map();
254
261
  const [ownSections, codegraphTools] = await Promise.all([
@@ -312,8 +319,7 @@ interaction if the pull request's own change above relies on or conflicts with
312
319
  one of these files, and never mark that finding blocking:
313
320
  ${upstreamListing}${unchangedListing}`;
314
321
  report(`diff prepared · ${ownPatch.length} chars${diffWasTruncated ? " · truncated for model context" : ""}`);
315
- const provider = ai ??
316
- new OpenRouterProvider(options.aiToken ?? "", requiredModel(options.highModel, "high"));
322
+ const provider = ai ?? reviewProvider(options, options.highModel, "high");
317
323
  const diagrams = provider.supportsTools !== false;
318
324
  const carryBlock = extras?.carryPrompt ? `${extras.carryPrompt}\n` : "";
319
325
  const prompt = `Review this pull request against the repository's review guide and
@@ -370,11 +376,11 @@ ${diff}`;
370
376
  reasoningEffort: "high",
371
377
  responseFormat: FINDINGS_JSON_SCHEMA,
372
378
  };
373
- report(`AI request · model=${options.highModel ?? "openrouter default"} · ` +
379
+ report(`AI request · ${options.ai} · model=${options.highModel ?? "unset"} · ` +
374
380
  `prompt=${prompt.length} chars · maxTokens=${request.maxTokens}`);
375
381
  if (options.debug) {
376
382
  console.log(`[debug] review prompt · ${prompt.length} chars · diff=${diff.length} chars`);
377
- console.log(`[debug] openrouter request · model=${options.highModel ?? "unset"} · maxTokens=${request.maxTokens}`);
383
+ console.log(`[debug] ${options.ai} request · model=${options.highModel ?? "unset"} · maxTokens=${request.maxTokens}`);
378
384
  }
379
385
  let response = await completeWithMermaidTools(provider, request, 1, extraTools, maxToolRounds);
380
386
  if (usage)
@@ -385,7 +391,7 @@ ${diff}`;
385
391
  console.log(`\n----- INITIAL REVIEW -----\n${response.text}\n`);
386
392
  }
387
393
  if (!response.text.trim()) {
388
- throw new Error("OpenRouter returned an empty review. The reasoning budget may have been exhausted");
394
+ throw new Error(`${options.ai} returned an empty review. The reasoning budget may have been exhausted`);
389
395
  }
390
396
  // The model returns JSON; we render the Markdown. A reply
391
397
  // that is not usable JSON is retried once and then falls back to the legacy
@@ -417,7 +423,7 @@ ${reviewText}`,
417
423
  report(`AI improvement response · input=${response.tokensIn} tokens · ` +
418
424
  `output=${response.tokensOut} tokens`);
419
425
  if (!response.text.trim()) {
420
- throw new Error(`OpenRouter returned an empty review improvement at pass ${pass - 1}`);
426
+ throw new Error(`${options.ai} returned an empty review improvement at pass ${pass - 1}`);
421
427
  }
422
428
  reviewText = await normalizeReviewResponse(provider, improvementRequest, response, extraTools, maxToolRounds, usage, report, options);
423
429
  if (options.debug) {
@@ -471,8 +477,7 @@ export async function reviewWorkspaceRevision(revision, options, headSha, usage,
471
477
  const changes = Number(file.changes ?? 0);
472
478
  const patch = String(file.patch ?? "");
473
479
  const lowProvider = needsSummary(changes, patch)
474
- ? (ai ??
475
- new OpenRouterProvider(options.aiToken ?? "", requiredModel(options.lowModel, "low")))
480
+ ? (ai ?? reviewProvider(options, options.lowModel, "low"))
476
481
  : undefined;
477
482
  if (!lowProvider)
478
483
  return `FILE: ${path}\n${filePatch(file)}`;
@@ -506,8 +511,7 @@ export async function reviewWorkspaceRevision(revision, options, headSha, usage,
506
511
  .join("\n")}`
507
512
  : "";
508
513
  const carryBlock = extras?.carryPrompt ? `${extras.carryPrompt}\n` : "";
509
- const provider = ai ??
510
- new OpenRouterProvider(options.aiToken ?? "", requiredModel(options.highModel, "high"));
514
+ const provider = ai ?? reviewProvider(options, options.highModel, "high");
511
515
  const diagrams = provider.supportsTools !== false;
512
516
  const prompt = `Review these local changes against the repository's review guide and
513
517
  codebase conventions. Find only actionable code-level violations supported by
@@ -541,7 +545,7 @@ ${ownDiff}${unchangedListing}`;
541
545
  if (usage)
542
546
  await usage(response);
543
547
  if (!response.text.trim()) {
544
- throw new Error("OpenRouter returned an empty review");
548
+ throw new Error(`${options.ai} returned an empty review`);
545
549
  }
546
550
  let reviewText = await normalizeReviewResponse(provider, request, response, extraTools, maxToolRounds, usage, report, options);
547
551
  for (let pass = 2; pass <= matrix; pass++) {
@@ -48,6 +48,8 @@ export function renderSettings(username, config, webhookUrl, baseUrl) {
48
48
  <option value="openrouter"${config.ai === "openrouter" ? " selected" : ""}>OpenRouter</option>
49
49
  <option value="openai"${config.ai === "openai" ? " selected" : ""}>OpenAI</option>
50
50
  <option value="anthropic"${config.ai === "anthropic" ? " selected" : ""}>Anthropic</option>
51
+ <option value="opencode-zen"${config.ai === "opencode-zen" ? " selected" : ""}>OpenCode Zen</option>
52
+ <option value="opencode-go"${config.ai === "opencode-go" ? " selected" : ""}>OpenCode Go</option>
51
53
  </select></div>
52
54
  <div class="field"><label>API key</label>
53
55
  <input id="token" type="password" placeholder="Leave blank to keep the current key">
@@ -38,9 +38,9 @@ class RemoteReviewError extends Error {
38
38
  function remoteQueueKey(repo, branch, tokenId) {
39
39
  return `remote:${repo}:${branch}:${tokenId}`;
40
40
  }
41
- /** True only for a provider *authentication* rejection: the three providers
42
- * raise `openrouter_unauthorized`, `openai_unauthorized` and
43
- * `anthropic_unauthorized`. The list is explicit so an unrelated error that
41
+ /** True only for a provider *authentication* rejection: the providers raise
42
+ * `openrouter_unauthorized`, `openai_unauthorized`, `anthropic_unauthorized`
43
+ * and `opencode_unauthorized`. The list is explicit so an unrelated error that
44
44
  * happens to end in `_unauthorized` is not relabelled as a bad client key.
45
45
  */
46
46
  function isProviderAuthError(error) {
@@ -49,6 +49,7 @@ function isProviderAuthError(error) {
49
49
  "openrouter_unauthorized",
50
50
  "openai_unauthorized",
51
51
  "anthropic_unauthorized",
52
+ "opencode_unauthorized",
52
53
  ].includes(error.code));
53
54
  }
54
55
  function reviewOptionsForRepo(repo, useCodegraph, aiToken) {
@@ -140,6 +140,8 @@ export function reviewOptions(repo, prNumber) {
140
140
  ghConcurrent: 1,
141
141
  aiConcurrent: 1,
142
142
  auth: "gh",
143
+ // With no provider saved the App review stays on OpenRouter, the same
144
+ // fallback the CLI review uses.
143
145
  ai: config.ai && config.ai !== "none" ? config.ai : "openrouter",
144
146
  aiToken: config.token,
145
147
  highModel: config.highModel,
@@ -24,7 +24,7 @@ export type Options = {
24
24
  envPath?: string;
25
25
  auth: "gh" | "pat";
26
26
  githubPat?: string;
27
- ai: "none" | "openrouter" | "openai" | "anthropic";
27
+ ai: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
28
28
  aiToken?: string;
29
29
  lowModel?: string;
30
30
  highModel?: string;
@@ -102,7 +102,7 @@ export type AiResponse = {
102
102
  unknown: number;
103
103
  };
104
104
  model: string;
105
- provider: "openrouter" | "openai" | "anthropic";
105
+ provider: "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
106
106
  };
107
107
  export type AiProvider = {
108
108
  supportsTools?: boolean;
@@ -5,6 +5,7 @@ const PATTERNS = [
5
5
  /github_pat_[A-Za-z0-9_]{20,}/g, // fine-grained GitHub PATs
6
6
  /sk-or-[A-Za-z0-9-]{10,}/g, // OpenRouter keys
7
7
  /sk-ant-[A-Za-z0-9_-]{10,}/g, // Anthropic keys
8
+ /sk-[A-Za-z0-9_-]{20,}/g, // OpenAI keys, old `sk-` and new `sk-proj-` alike
8
9
  /cmr_[A-Za-z0-9_-]{20,}/g, // remote review bearer tokens
9
10
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
10
11
  ];
@@ -37,8 +37,22 @@ export function printRunSummary(summary) {
37
37
  else
38
38
  console.error(line);
39
39
  }
40
+ /** A run that finished without one AI call, such as a `sync` served entirely
41
+ * from the cache, spent nothing. `settle` reads that as "ended before its cost
42
+ * was recorded", which is right for a review row and wrong for this line. */
43
+ function madeNoAiCall(metrics) {
44
+ return metrics.knownCalls === 0 && metrics.unknownCalls === 0;
45
+ }
40
46
  /** The metrics half of a summary, so call sites do not repeat the spread. */
41
47
  export function summaryFromMetrics(metrics, durationMs) {
48
+ if (madeNoAiCall(metrics)) {
49
+ return {
50
+ durationMs,
51
+ tokensIn: metrics.tokensIn,
52
+ tokensOut: metrics.tokensOut,
53
+ costUsd: 0,
54
+ };
55
+ }
42
56
  const outcome = settle(metrics);
43
57
  return {
44
58
  durationMs,
@@ -52,6 +66,8 @@ export function summaryFromMetrics(metrics, durationMs) {
52
66
  }
53
67
  /** `0.0016` for a known cost, `unknown` otherwise. For the timing log line. */
54
68
  export function costLabel(metrics) {
69
+ if (madeNoAiCall(metrics))
70
+ return "0.0000";
55
71
  const outcome = settle(metrics);
56
72
  return outcome.status === "known" ? outcome.usd.toFixed(4) : "unknown";
57
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1-beta.1",
3
+ "version": "0.5.1-beta.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",