co-maintainer 0.5.0 → 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.
Files changed (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ import { getEnv } from "../util/runtime.js";
2
+ import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
3
+ /** `CM_OPENAI_URL` points at a local fake in tests. Unset means the real
4
+ * endpoint, so production behavior is unchanged. */
5
+ export function openAiBase() {
6
+ const endpoint = getEnv("CM_OPENAI_URL") ?? "https://api.openai.com/v1/responses";
7
+ return endpoint.replace(/\/responses\/?$/, "");
8
+ }
9
+ /** A failed response as actionable text. Only `error.message` is
10
+ * shown: the raw body can carry request ids that mean nothing in a terminal. */
11
+ export function openAiError(model, status, body) {
12
+ let detail = "";
13
+ let code = "";
14
+ try {
15
+ const parsed = JSON.parse(body);
16
+ detail = String(parsed.error?.message ?? "");
17
+ code = String(parsed.error?.code ?? "");
18
+ }
19
+ catch {
20
+ detail = body.trim().slice(0, 200);
21
+ }
22
+ if (status === 401) {
23
+ return new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
24
+ }
25
+ if (status === 400 && (code === "model_not_found" || /model/i.test(detail))) {
26
+ return new CliError("openai_unknown_model", `OpenAI does not know the model ${model}.`, "Pick one at https://platform.openai.com/docs/models and set it with co-maintainer set --high-model=...", EXIT_USAGE);
27
+ }
28
+ return new CliError("openai_failed", `OpenAI failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
29
+ }
30
+ /** The Chat-Completions-shaped message list every caller already builds
31
+ * (`chatBody` does the same fallback for OpenRouter). */
32
+ function effectiveMessages(request) {
33
+ return (request.messages ?? [
34
+ ...(request.system
35
+ ? [{ role: "system", content: request.system }]
36
+ : []),
37
+ { role: "user", content: request.prompt },
38
+ ]);
39
+ }
40
+ /** `{type:"function",function:{name,parameters}}` (what `mermaid.ts` and the
41
+ * codegraph tools build) to Responses' flat shape. */
42
+ function toResponsesTool(tool) {
43
+ const fn = (tool.function ?? {});
44
+ return {
45
+ type: "function",
46
+ name: String(fn.name ?? ""),
47
+ description: fn.description ? String(fn.description) : undefined,
48
+ parameters: fn.parameters,
49
+ };
50
+ }
51
+ /** A tool call and its result do not live inside a message the way Chat
52
+ * Completions nests them; each is its own item in `input`. */
53
+ function toResponsesInput(messages) {
54
+ const input = [];
55
+ let instructions;
56
+ for (const message of messages) {
57
+ if (message.role === "system") {
58
+ instructions = instructions
59
+ ? `${instructions}\n\n${message.content ?? ""}`
60
+ : String(message.content ?? "");
61
+ continue;
62
+ }
63
+ if (message.role === "tool") {
64
+ input.push({
65
+ type: "function_call_output",
66
+ call_id: message.tool_call_id,
67
+ output: message.content ?? "",
68
+ });
69
+ continue;
70
+ }
71
+ if (message.tool_calls?.length) {
72
+ if (message.content) {
73
+ input.push({ role: "assistant", content: message.content });
74
+ }
75
+ for (const call of message.tool_calls) {
76
+ input.push({
77
+ type: "function_call",
78
+ call_id: call.id,
79
+ name: call.function.name,
80
+ arguments: call.function.arguments,
81
+ });
82
+ }
83
+ continue;
84
+ }
85
+ input.push({ role: message.role, content: message.content ?? "" });
86
+ }
87
+ return { instructions, input };
88
+ }
89
+ function responsesBody(model, request) {
90
+ const { instructions, input } = toResponsesInput(effectiveMessages(request));
91
+ const schema = request.responseFormat?.json_schema;
92
+ return {
93
+ model,
94
+ ...(instructions ? { instructions } : {}),
95
+ input,
96
+ max_output_tokens: request.maxTokens,
97
+ ...(request.reasoningEffort
98
+ ? { reasoning: { effort: request.reasoningEffort } }
99
+ : {}),
100
+ ...(request.tools?.length
101
+ ? {
102
+ tools: request.tools.map(toResponsesTool),
103
+ tool_choice: "auto",
104
+ }
105
+ : {}),
106
+ ...(schema
107
+ ? {
108
+ text: {
109
+ format: {
110
+ type: "json_schema",
111
+ name: schema.name,
112
+ schema: schema.schema,
113
+ strict: schema.strict ?? false,
114
+ },
115
+ },
116
+ }
117
+ : {}),
118
+ };
119
+ }
120
+ function parseResponsesBody(json, model) {
121
+ const output = Array.isArray(json.output)
122
+ ? json.output
123
+ : [];
124
+ const texts = [];
125
+ const toolCalls = [];
126
+ for (const item of output) {
127
+ if (item.type === "function_call" && item.call_id && item.name) {
128
+ toolCalls.push({
129
+ id: item.call_id,
130
+ type: "function",
131
+ function: { name: item.name, arguments: item.arguments ?? "{}" },
132
+ });
133
+ continue;
134
+ }
135
+ if (item.type !== "message")
136
+ continue;
137
+ for (const part of item.content ?? []) {
138
+ if (part.type === "refusal") {
139
+ throw new CliError("openai_refusal", `OpenAI refused to answer: ${part.refusal ?? "no reason given"}`, undefined, EXIT_RUNTIME);
140
+ }
141
+ if (part.type === "output_text" && part.text)
142
+ texts.push(part.text);
143
+ }
144
+ }
145
+ const usage = json.usage;
146
+ return {
147
+ text: texts.join(""),
148
+ ...(toolCalls.length ? { toolCalls } : {}),
149
+ tokensIn: Number(usage?.input_tokens ?? 0),
150
+ tokensOut: Number(usage?.output_tokens ?? 0),
151
+ // OpenAI's Responses API reports no dollar cost, so this is always
152
+ // unknown (`costReasonText`'s "provider_did_not_report").
153
+ cost: undefined,
154
+ model,
155
+ provider: "openai",
156
+ };
157
+ }
158
+ export class OpenAiProvider {
159
+ supportsTools = true;
160
+ endpoint = `${openAiBase()}/responses`;
161
+ apiKey;
162
+ model;
163
+ constructor(apiKey, model) {
164
+ if (!apiKey)
165
+ throw new Error("OpenAI requires an API key");
166
+ this.apiKey = apiKey;
167
+ this.model = model;
168
+ }
169
+ async post(body) {
170
+ try {
171
+ return await fetch(this.endpoint, {
172
+ method: "POST",
173
+ headers: {
174
+ Authorization: `Bearer ${this.apiKey}`,
175
+ "Content-Type": "application/json",
176
+ },
177
+ body: JSON.stringify(body),
178
+ });
179
+ }
180
+ catch (error) {
181
+ throw networkFailure(this.endpoint, error);
182
+ }
183
+ }
184
+ async complete(request) {
185
+ let response = await this.post(responsesBody(this.model, request));
186
+ // A model may reject `text.format` next to tools. The schema is only a
187
+ // hint (the prompt also asks for a fenced JSON block), so one retry
188
+ // without it beats failing the review outright.
189
+ if (response.status === 400 && request.responseFormat) {
190
+ const detail = await response.text();
191
+ if (/text\.format|json_schema|structured/i.test(detail)) {
192
+ const { responseFormat: _dropped, ...withoutSchema } = request;
193
+ response = await this.post(responsesBody(this.model, withoutSchema));
194
+ if (response.ok) {
195
+ return parseResponsesBody((await response.json()), this.model);
196
+ }
197
+ throw openAiError(this.model, response.status, await response.text());
198
+ }
199
+ throw openAiError(this.model, response.status, detail);
200
+ }
201
+ if (!response.ok) {
202
+ throw openAiError(this.model, response.status, await response.text());
203
+ }
204
+ return parseResponsesBody((await response.json()), this.model);
205
+ }
206
+ }
@@ -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
- /** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
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;
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
- /** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
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
+ }
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,42 +56,44 @@ 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
- // `fetch failed` alone names neither the host nor the cause (CORE-12).
58
- throw networkFailure(this.endpoint, error);
70
+ // `fetch failed` alone names neither the host nor the cause.
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
64
80
  // only a hint — the prompt also asks for a fenced JSON block — so one
65
- // retry without it is better than failing the review (CORE-40).
81
+ // retry without it is better than failing the review.
66
82
  if (response.status === 400 && request.responseFormat) {
67
83
  const detail = await response.text();
68
84
  if (/response_format|json_schema|structured/i.test(detail)) {
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
  }
@@ -1,4 +1,4 @@
1
- /** OpenRouter model prices for the probe estimate (CORE-24).
1
+ /** OpenRouter model prices for the probe estimate.
2
2
  *
3
3
  * `/api/v1/models` is public: no key needed. The list is large and changes
4
4
  * slowly, so it is cached for 24 hours in cache.db. A network failure is not
@@ -1,4 +1,13 @@
1
1
  import type { AiProvider, AiRequest, AiResponse, Json, Options } from "../types.ts";
2
+ /** The one list of accepted `--ai` values, so a new or retired provider is
3
+ * added or removed in a single place instead of the half dozen copies
4
+ * Hetzner's removal had to chase down. */
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;
9
+ /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
10
+ export declare function rejectRetiredProvider(value: string | undefined): void;
2
11
  export declare function createAiProvider(options: Options, model: string): AiProvider | undefined;
3
12
  export declare function parseChatResponse(json: Json, provider: AiResponse["provider"], model: string): AiResponse;
4
13
  export declare function chatBody(model: string, request: AiRequest): Json;
@@ -1,12 +1,51 @@
1
- import { HetznerProvider } from "./hetzner.js";
2
1
  import { OpenRouterProvider } from "./openrouter.js";
2
+ import { OpenAiProvider } from "./openai.js";
3
+ import { AnthropicProvider } from "./anthropic.js";
4
+ import { openCodeHost } from "./opencode.js";
5
+ import { die } from "../cli/error.js";
6
+ /** The one list of accepted `--ai` values, so a new or retired provider is
7
+ * added or removed in a single place instead of the half dozen copies
8
+ * Hetzner's removal had to chase down. */
9
+ export const AI_PROVIDERS = [
10
+ "none",
11
+ "openrouter",
12
+ "openai",
13
+ "anthropic",
14
+ "opencode-zen",
15
+ "opencode-go",
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
+ }
27
+ /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
28
+ export function rejectRetiredProvider(value) {
29
+ if (value === "hetzner") {
30
+ die("Hetzner is no longer supported. Use openrouter or none.");
31
+ }
32
+ }
3
33
  export function createAiProvider(options, model) {
4
34
  if (options.ai === "none")
5
35
  return undefined;
6
36
  if (options.ai === "openrouter") {
7
37
  return new OpenRouterProvider(options.aiToken ?? "", model);
8
38
  }
9
- return new HetznerProvider(options.aiToken ?? "", model);
39
+ if (options.ai === "openai") {
40
+ return new OpenAiProvider(options.aiToken ?? "", model);
41
+ }
42
+ if (options.ai === "anthropic") {
43
+ return new AnthropicProvider(options.aiToken ?? "", model);
44
+ }
45
+ if (options.ai === "opencode-zen" || options.ai === "opencode-go") {
46
+ return new OpenRouterProvider(options.aiToken ?? "", model, openCodeHost(options.ai));
47
+ }
48
+ die(`${options.ai} support is not finished yet. Use openrouter or none.`);
10
49
  }
11
50
  export function parseChatResponse(json, provider, model) {
12
51
  const choice = Array.isArray(json.choices)
@@ -59,7 +98,7 @@ export function chatBody(model, request) {
59
98
  ...(request.tools?.length
60
99
  ? { tools: request.tools, tool_choice: "auto" }
61
100
  : {}),
62
- // A review asks for structured output (CORE-40). The schema is sent even
101
+ // A review asks for structured output. The schema is sent even
63
102
  // when tools are present, because most providers accept the pair; one that
64
103
  // rejects it gets a single retry without the schema (see
65
104
  // `OpenRouterProvider.complete`), and the prompt also asks for a fenced
@@ -1,4 +1,4 @@
1
- /** Pre-flight checks for `config set` (CORE-22, F35).
1
+ /** Pre-flight checks for `config set`.
2
2
  *
3
3
  * A wrong API key or an unknown model used to be accepted by `set` and only
4
4
  * surfaced during a review, after the pull request had been fetched and
@@ -23,3 +23,11 @@ export type VerifyResult = {
23
23
  * lists it. The first failure decides: a bad key is reported before a model
24
24
  * question, since the model check would fail on the key too. */
25
25
  export declare function verifyOpenRouter(apiKey: string, model?: string): Promise<VerifyResult>;
26
+ /** Same shape as {@link verifyOpenRouter}: `/models` both authenticates the
27
+ * key and, when `model` is given, confirms OpenAI actually has it. */
28
+ export declare function verifyOpenAi(apiKey: string, model?: string): Promise<VerifyResult>;
29
+ /** Anthropic authenticates the key and lists models at `/models`, but with its
30
+ * own headers (`x-api-key` + `anthropic-version`) instead of a Bearer token.
31
+ * Without a model the call only proves the key; with one it also confirms
32
+ * Anthropic serves it. */
33
+ export declare function verifyAnthropic(apiKey: string, model?: string): Promise<VerifyResult>;