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 +2 -2
- package/dist/package.json +1 -1
- package/dist/src/ai/opencode.d.ts +10 -0
- package/dist/src/ai/opencode.js +76 -0
- package/dist/src/ai/openrouter.d.ts +22 -6
- package/dist/src/ai/openrouter.js +36 -20
- package/dist/src/ai/provider.d.ts +4 -1
- package/dist/src/ai/provider.js +16 -0
- package/dist/src/cli/args.js +16 -8
- package/dist/src/cli/commands/config.js +1 -1
- package/dist/src/cli/commands/registry.d.ts +0 -8
- package/dist/src/cli/commands/registry.js +4 -13
- package/dist/src/cli/commands/set.js +10 -10
- package/dist/src/config.d.ts +2 -2
- package/dist/src/pr/reviewer.js +20 -16
- package/dist/src/server/pages/settings.js +2 -0
- package/dist/src/services/remote_review.js +4 -3
- package/dist/src/services/review.js +2 -0
- package/dist/src/types.d.ts +2 -2
- package/dist/src/util/redact.js +1 -0
- package/dist/src/util/run_summary.js +16 -0
- package/package.json +1 -1
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,
|
|
47
|
-
co-maintainer config set ai-key sk-or-... # your provider's key (OpenRouter, OpenAI or
|
|
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
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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()),
|
|
88
|
+
return parseChatResponse((await response.json()), this.host.provider, this.model);
|
|
73
89
|
}
|
|
74
|
-
throw
|
|
90
|
+
throw this.fail(response.status, await response.text());
|
|
75
91
|
}
|
|
76
|
-
throw
|
|
92
|
+
throw this.fail(response.status, detail);
|
|
77
93
|
}
|
|
78
94
|
if (!response.ok) {
|
|
79
|
-
throw
|
|
95
|
+
throw this.fail(response.status, await response.text());
|
|
80
96
|
}
|
|
81
|
-
return parseChatResponse((await response.json()),
|
|
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;
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -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) {
|
package/dist/src/cli/args.js
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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
|
package/dist/src/pr/reviewer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 ?? "
|
|
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]
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
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,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/util/redact.js
CHANGED
|
@@ -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
|
}
|