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
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Murat Kirazkaya
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Murat Kirazkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![NPM Version](https://img.shields.io/npm/v/co-maintainer)](https://www.npmjs.com/package/co-maintainer) [![](https://img.shields.io/badge/blog-co_maintainer_an_imitator_of_yours-blue)](https://muratkirazkaya.com/blogs/co-maintainer-an-imitator-of-yours)
4
4
 
5
5
  [Website](https://co-maintainer.com) ·
6
- [Docs](https://co-maintainer.com/docs) ·
6
+ [Docs](https://co-maintainer.com/docs/getting-started.html) ·
7
7
  [Cloud](https://cloud.co-maintainer.com) (hosted, free, early access, invite only for now)
8
8
 
9
9
  An imitator of yours, A PR review tool like co-maintainer.
@@ -37,19 +37,21 @@ co-maintainer review owner/repo 123 --improve-matrix=2 --debug
37
37
 
38
38
  co-maintainer review # local branch (staged, unstaged, untracked)
39
39
  co-maintainer review --json # machine-readable stdout, logs on stderr
40
+ co-maintainer review --output=github # annotations and a job summary inside GitHub Actions
40
41
  ```
41
42
 
42
43
  Settings live in `config.json`, so a flag you use every time can be saved once:
43
44
 
44
45
  ```sh
45
- co-maintainer config set ai-key sk-or-... # OpenRouter key
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)
46
48
  co-maintainer config set high-model openai/gpt-5.6-luna
47
49
  co-maintainer config set low-model openai/gpt-oss-120b
48
50
  co-maintainer config list # every key, its value and its source
49
51
  ```
50
52
 
51
53
  Documentation: [Getting started](https://co-maintainer.com/docs/getting-started.html)
52
- (install to first review) and the [full docs site](https://co-maintainer.com/docs),
54
+ (install to first review) and the [full docs site](https://co-maintainer.com/docs/getting-started.html),
53
55
  sources in [`docs/md/`](docs/md/).
54
56
 
55
57
  Use `co-maintainer help`, `co-maintainer -h`, or `co-maintainer --help` for the
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.0",
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,55 @@
1
+ /** Anthropic via the Messages API.
2
+ *
3
+ * The raw fetch shape every other provider uses, against Anthropic's own
4
+ * contract (docs.anthropic.com/en/api/messages). The request/response shapes
5
+ * differ from the Chat-Completions style this codebase's `AiRequest`/
6
+ * `AiMessage` types follow the way OpenAI's Responses API does, so this file is
7
+ * the one place that translates:
8
+ *
9
+ * - the system message becomes the top-level `system` field, not a message
10
+ * - an assistant tool call is a `tool_use` content block, not a `tool_calls`
11
+ * field on the message
12
+ * - tool results are `tool_result` blocks inside a `user` message, referenced
13
+ * by `tool_use_id`
14
+ * - a function tool is `{name, description, input_schema}`, not the
15
+ * Chat-Completions `{type:"function", function:{...}}` nesting
16
+ * - structured output is `output_config.format`, reasoning effort is
17
+ * `output_config.effort`, and there is no `temperature`: Claude Opus 5 and
18
+ * Sonnet 5 reject sampling parameters with a 400
19
+ *
20
+ * `cache_control` is set on the system prompt, which every review sends as the
21
+ * same guide prefix. Anthropic caches the longest stable prefix, so the
22
+ * guides are billed at the cached rate from the second review on and the
23
+ * `cache_read_input_tokens` count shows up in the usage log.
24
+ *
25
+ * ponytail: this talks to Messages without streaming. The original design
26
+ * called for the SDK's `stream()` + `finalMessage()` helper for large `max_tokens` (a single
27
+ * review can ask for 96k). The SDK is not a dependency (kept fetch-only, the
28
+ * same as every other provider), and a hand-rolled SSE accumulation loop is a
29
+ * lot of code for a first cut. Anthropic's HTTP request timeout is the risk;
30
+ * if a real review ever hits it, add the streaming loop here. Upgrade path:
31
+ * `stream:true` + parse `content_block_delta` events into the same
32
+ * `parseMessagesBody` result shape.
33
+ */
34
+ import type { AiProvider, AiRequest, AiResponse, Json } from "../types.ts";
35
+ import { CliError } from "../cli/error.ts";
36
+ /** Required on every Messages request and the only version the errors below
37
+ * are written against. */
38
+ export declare const ANTHROPIC_VERSION = "2023-06-01";
39
+ /** `CM_ANTHROPIC_URL` points at a local fake in tests. Unset means the real
40
+ * endpoint, so production behavior is unchanged. */
41
+ export declare function anthropicBase(): string;
42
+ /** A failed response as actionable text. Only the error's own
43
+ * `message` is surfaced: the raw body can carry request ids that mean nothing
44
+ * in a terminal. */
45
+ export declare function anthropicError(model: string, status: number, body: string): CliError;
46
+ export declare function parseMessagesBody(json: Json, model: string): AiResponse;
47
+ export declare class AnthropicProvider implements AiProvider {
48
+ readonly supportsTools = true;
49
+ private readonly endpoint;
50
+ private readonly apiKey;
51
+ private readonly model;
52
+ constructor(apiKey: string, model: string);
53
+ private post;
54
+ complete(request: AiRequest): Promise<AiResponse>;
55
+ }
@@ -0,0 +1,262 @@
1
+ import { getEnv } from "../util/runtime.js";
2
+ import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
3
+ /** Required on every Messages request and the only version the errors below
4
+ * are written against. */
5
+ export const ANTHROPIC_VERSION = "2023-06-01";
6
+ /** `CM_ANTHROPIC_URL` points at a local fake in tests. Unset means the real
7
+ * endpoint, so production behavior is unchanged. */
8
+ export function anthropicBase() {
9
+ const endpoint = getEnv("CM_ANTHROPIC_URL") ?? "https://api.anthropic.com/v1";
10
+ return endpoint.replace(/\/$/, "");
11
+ }
12
+ /** A failed response as actionable text. Only the error's own
13
+ * `message` is surfaced: the raw body can carry request ids that mean nothing
14
+ * in a terminal. */
15
+ export function anthropicError(model, status, body) {
16
+ let detail = "";
17
+ let type = "";
18
+ try {
19
+ const parsed = JSON.parse(body);
20
+ detail = String(parsed.error?.message ?? "");
21
+ type = String(parsed.error?.type ?? "");
22
+ }
23
+ catch {
24
+ detail = body.trim().slice(0, 200);
25
+ }
26
+ if (status === 401 || status === 403) {
27
+ return new CliError("anthropic_unauthorized", "Anthropic rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
28
+ }
29
+ if ((status === 400 || status === 404) &&
30
+ (type === "not_found_error" || /model/i.test(detail))) {
31
+ return new CliError("anthropic_unknown_model", `Anthropic does not know the model ${model}.`, "Pick one at https://docs.anthropic.com/en/docs/about-claude/models and set it with co-maintainer set --high-model=...", EXIT_USAGE);
32
+ }
33
+ return new CliError("anthropic_failed", `Anthropic failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
34
+ }
35
+ /** The Chat-Completions-shaped message list every caller already builds
36
+ * (`chatBody` does the same fallback for OpenRouter). */
37
+ function effectiveMessages(request) {
38
+ return (request.messages ?? [
39
+ ...(request.system
40
+ ? [{ role: "system", content: request.system }]
41
+ : []),
42
+ { role: "user", content: request.prompt },
43
+ ]);
44
+ }
45
+ /** `{type:"function",function:{name,parameters}}` (what `mermaid.ts` and the
46
+ * codegraph tools build) to Anthropic's `input_schema` shape. */
47
+ function toAnthropicTool(tool) {
48
+ const fn = (tool.function ?? {});
49
+ return {
50
+ name: String(fn.name ?? ""),
51
+ description: fn.description ? String(fn.description) : undefined,
52
+ input_schema: fn.parameters ?? { type: "object" },
53
+ };
54
+ }
55
+ /** A tool call and its result do not live inside a message the way Chat
56
+ * Completions nests them. Anthropic delivers `tool_use` blocks in the
57
+ * assistant turn, and the caller must send every result back in a single
58
+ * `user` message with one `tool_result` block each, in the same order. */
59
+ function toAnthropicMessages(messages) {
60
+ const system = [];
61
+ const out = [];
62
+ // Consecutive `tool` messages collapse into one `user` turn: Anthropic
63
+ // rejects two user messages in a row.
64
+ let pendingResults = [];
65
+ const flushResults = () => {
66
+ if (!pendingResults.length)
67
+ return;
68
+ out.push({ role: "user", content: pendingResults });
69
+ pendingResults = [];
70
+ };
71
+ for (const message of messages) {
72
+ if (message.role === "system") {
73
+ system.push(String(message.content ?? ""));
74
+ continue;
75
+ }
76
+ if (message.role === "tool") {
77
+ pendingResults.push({
78
+ type: "tool_result",
79
+ tool_use_id: message.tool_call_id ?? "",
80
+ content: message.content ?? "",
81
+ });
82
+ continue;
83
+ }
84
+ flushResults();
85
+ if (message.tool_calls?.length) {
86
+ const content = [];
87
+ if (message.content)
88
+ content.push({ type: "text", text: message.content });
89
+ for (const call of message.tool_calls) {
90
+ let input = {};
91
+ try {
92
+ input = JSON.parse(call.function.arguments);
93
+ }
94
+ catch {
95
+ input = {};
96
+ }
97
+ content.push({
98
+ type: "tool_use",
99
+ id: call.id,
100
+ name: call.function.name,
101
+ input,
102
+ });
103
+ }
104
+ out.push({ role: "assistant", content });
105
+ continue;
106
+ }
107
+ out.push({ role: message.role, content: message.content ?? "" });
108
+ }
109
+ flushResults();
110
+ return {
111
+ system: system.length ? system.join("\n\n") : undefined,
112
+ messages: out,
113
+ };
114
+ }
115
+ /** Haiku 4.5 does not support `output_config.effort`; sending it is a 400.
116
+ * Anthropic's effort accepts low/medium/high/xhigh/max, so the only level this
117
+ * has to drop is "none": it means "reasoning off" in `AiRequest` and is not an
118
+ * effort value Anthropic lists. */
119
+ function supportsEffort(model) {
120
+ return !/haiku/i.test(model);
121
+ }
122
+ function messagesBody(model, request) {
123
+ const { system, messages } = toAnthropicMessages(effectiveMessages(request));
124
+ const schema = request.responseFormat?.json_schema;
125
+ const effort = request.reasoningEffort && request.reasoningEffort !== "none"
126
+ ? request.reasoningEffort
127
+ : undefined;
128
+ // `output_config` carries both the structured-output schema and the effort
129
+ // level. It is only sent when one of them applies, so a plain request stays
130
+ // on the request shape every model understands.
131
+ const outputConfig = {
132
+ ...(schema?.schema
133
+ ? { format: { type: "json_schema", schema: schema.schema } }
134
+ : {}),
135
+ ...(effort && supportsEffort(model) ? { effort } : {}),
136
+ };
137
+ return {
138
+ model,
139
+ max_tokens: request.maxTokens,
140
+ // `cache_control` marks the guide prefix every review repeats, so it is
141
+ // read from cache instead of re-billed.
142
+ ...(system
143
+ ? {
144
+ system: [
145
+ {
146
+ type: "text",
147
+ text: system,
148
+ cache_control: { type: "ephemeral" },
149
+ },
150
+ ],
151
+ }
152
+ : {}),
153
+ messages,
154
+ ...(request.tools?.length
155
+ ? {
156
+ tools: request.tools.map(toAnthropicTool),
157
+ tool_choice: { type: "auto" },
158
+ }
159
+ : {}),
160
+ ...(Object.keys(outputConfig).length
161
+ ? { output_config: outputConfig }
162
+ : {}),
163
+ };
164
+ }
165
+ /** `refusal` stop reason with the human-readable explanation when Anthropic
166
+ * supplies one. Kept here so both the non-streaming body and any future
167
+ * streaming loop share one message. */
168
+ function refusalError(details) {
169
+ const explanation = details && typeof details.explanation === "string"
170
+ ? details.explanation
171
+ : "no reason given";
172
+ return new CliError("anthropic_refusal", `Anthropic refused to answer: ${explanation}`, undefined, EXIT_RUNTIME);
173
+ }
174
+ export function parseMessagesBody(json, model) {
175
+ const stopReason = String(json.stop_reason ?? "");
176
+ if (stopReason === "refusal") {
177
+ throw refusalError(json.stop_details);
178
+ }
179
+ const content = Array.isArray(json.content)
180
+ ? json.content
181
+ : [];
182
+ const texts = [];
183
+ const toolCalls = [];
184
+ for (const block of content) {
185
+ if (block.type === "text" && block.text) {
186
+ texts.push(block.text);
187
+ continue;
188
+ }
189
+ if (block.type === "tool_use" && block.id && block.name) {
190
+ toolCalls.push({
191
+ id: block.id,
192
+ type: "function",
193
+ function: {
194
+ name: block.name,
195
+ arguments: JSON.stringify(block.input ?? {}),
196
+ },
197
+ });
198
+ }
199
+ }
200
+ const usage = json.usage;
201
+ return {
202
+ text: texts.join(""),
203
+ ...(toolCalls.length ? { toolCalls } : {}),
204
+ tokensIn: Number(usage?.input_tokens ?? 0),
205
+ tokensOut: Number(usage?.output_tokens ?? 0),
206
+ // Anthropic reports only token counts, never a dollar cost, so this is
207
+ // always unknown (`costReasonText`'s "provider_did_not_report").
208
+ cost: undefined,
209
+ model,
210
+ provider: "anthropic",
211
+ };
212
+ }
213
+ export class AnthropicProvider {
214
+ supportsTools = true;
215
+ endpoint = `${anthropicBase()}/messages`;
216
+ apiKey;
217
+ model;
218
+ constructor(apiKey, model) {
219
+ if (!apiKey)
220
+ throw new Error("Anthropic requires an API key");
221
+ this.apiKey = apiKey;
222
+ this.model = model;
223
+ }
224
+ async post(body) {
225
+ try {
226
+ return await fetch(this.endpoint, {
227
+ method: "POST",
228
+ headers: {
229
+ "x-api-key": this.apiKey,
230
+ "anthropic-version": ANTHROPIC_VERSION,
231
+ "Content-Type": "application/json",
232
+ },
233
+ body: JSON.stringify(body),
234
+ });
235
+ }
236
+ catch (error) {
237
+ throw networkFailure(this.endpoint, error);
238
+ }
239
+ }
240
+ async complete(request) {
241
+ let response = await this.post(messagesBody(this.model, request));
242
+ // A model may reject `output_config.format` next to tools. The schema is
243
+ // only a hint (the prompt also asks for a fenced JSON block), so one retry
244
+ // without it beats failing the review outright.
245
+ if (response.status === 400 && request.responseFormat) {
246
+ const detail = await response.text();
247
+ if (/output_config|json_schema|structured/i.test(detail)) {
248
+ const { responseFormat: _dropped, ...withoutSchema } = request;
249
+ response = await this.post(messagesBody(this.model, withoutSchema));
250
+ if (response.ok) {
251
+ return parseMessagesBody((await response.json()), this.model);
252
+ }
253
+ throw anthropicError(this.model, response.status, await response.text());
254
+ }
255
+ throw anthropicError(this.model, response.status, detail);
256
+ }
257
+ if (!response.ok) {
258
+ throw anthropicError(this.model, response.status, await response.text());
259
+ }
260
+ return parseMessagesBody((await response.json()), this.model);
261
+ }
262
+ }
@@ -3,7 +3,7 @@ type UsageSink = (job: string, response: AiResponse) => Promise<void>;
3
3
  /** Returns `null` when a response is usable, or a short reason when it is not.
4
4
  * `synthesis.ts` supplies one that parses the model text, because a syntactically
5
5
  * fine HTTP response can still be unparseable output, and caching that is what
6
- * poisoned every later `sync` (CORE-30 / F04). */
6
+ * poisoned every later `sync`. */
7
7
  export type AiValidator = (request: AiRequest, response: AiResponse) => string | null;
8
8
  /** A unit whose output never parsed, kept so the run can say what it dropped. */
9
9
  export type SkippedUnit = {
@@ -28,7 +28,7 @@ export declare class AiBatch {
28
28
  /** Runs one unit, caching its response only when the validator accepts it.
29
29
  * A rejected response is retried once; a second rejection is recorded as
30
30
  * skipped and never cached, so the next `sync` retries it rather than
31
- * replaying the same unusable output (CORE-30 / F04). */
31
+ * replaying the same unusable output. */
32
32
  private completeUnit;
33
33
  /** Records a unit as skipped: no cache write, so it stays retried and its
34
34
  * absence is never mistaken for a deliberate empty result. */
@@ -91,7 +91,7 @@ export class AiBatch {
91
91
  /** Runs one unit, caching its response only when the validator accepts it.
92
92
  * A rejected response is retried once; a second rejection is recorded as
93
93
  * skipped and never cached, so the next `sync` retries it rather than
94
- * replaying the same unusable output (CORE-30 / F04). */
94
+ * replaying the same unusable output. */
95
95
  async completeUnit(item, results, usage, startedAt) {
96
96
  for (const attempt of [1, 2]) {
97
97
  try {
@@ -28,6 +28,10 @@ export type Estimate = {
28
28
  usd?: [number, number];
29
29
  basis: "history" | "calibration";
30
30
  };
31
+ /** The probe's cost line: a dollar range when one is known, otherwise why
32
+ * not, so a provider with no price list (OpenAI, Anthropic) gets its own
33
+ * reason instead of being told to switch to OpenRouter. */
34
+ export declare function estimateCostLine(estimate: Estimate, ai: string, openRouterPricesUnavailable: boolean): string;
31
35
  /** The number of `extract_unit` jobs the recommended init would queue: one per
32
36
  * pull request plus one for the codebase/documents, matching
33
37
  * `extractAiFacts`. */
@@ -1,4 +1,4 @@
1
- /** Cost and time estimate for the recommended `init` (CORE-24, F17).
1
+ /** Cost and time estimate for the recommended `init`.
2
2
  *
3
3
  * Three sources, in order of trust:
4
4
  *
@@ -11,7 +11,7 @@
11
11
  * 3. Time and per-job tokens come from this repository's recorded jobs when
12
12
  * there are enough, and from the calibration below otherwise.
13
13
  *
14
- * Calibration (plan §CORE-24): cm-dx-lab, 4 pull requests and 8 files, 5
14
+ * Calibration: cm-dx-lab, 4 pull requests and 8 files, 5
15
15
  * extract and 9 synth jobs, 151 s, $0.0014 — about 151/14 ≈ 10.8 s per job.
16
16
  * Token counts per job are near 2.4k in and 0.7k out. The text always says
17
17
  * the numbers are an estimate. */
@@ -67,6 +67,21 @@ export async function readJobHistory(repo) {
67
67
  function band(value) {
68
68
  return [value * (1 - SPREAD), value * (1 + SPREAD)];
69
69
  }
70
+ /** The probe's cost line: a dollar range when one is known, otherwise why
71
+ * not, so a provider with no price list (OpenAI, Anthropic) gets its own
72
+ * reason instead of being told to switch to OpenRouter. */
73
+ export function estimateCostLine(estimate, ai, openRouterPricesUnavailable) {
74
+ if (estimate.usd) {
75
+ return `$${estimate.usd[0].toFixed(4)}-$${estimate.usd[1].toFixed(4)}`;
76
+ }
77
+ if (ai !== "none" && ai !== "openrouter") {
78
+ return "estimate unavailable for this provider";
79
+ }
80
+ if (openRouterPricesUnavailable) {
81
+ return "unknown (could not read OpenRouter prices)";
82
+ }
83
+ return "set --ai=openrouter with both models to estimate dollars";
84
+ }
70
85
  /** The number of `extract_unit` jobs the recommended init would queue: one per
71
86
  * pull request plus one for the codebase/documents, matching
72
87
  * `extractAiFacts`. */
@@ -40,18 +40,21 @@ export async function completeWithMermaidTools(provider, request, maxTools, extr
40
40
  tokensOut: 0,
41
41
  cost: undefined,
42
42
  };
43
- let costKnown = true;
43
+ const calls = { known: 0, unknown: 0 };
44
44
  const merge = (response) => {
45
45
  totals.tokensIn += response.tokensIn;
46
46
  totals.tokensOut += response.tokensOut;
47
47
  if (response.cost === undefined)
48
- costKnown = false;
49
- else if (costKnown)
48
+ calls.unknown++;
49
+ else {
50
+ calls.known++;
50
51
  totals.cost = (totals.cost ?? 0) + response.cost;
52
+ }
51
53
  return {
52
54
  ...response,
53
55
  ...totals,
54
- cost: costKnown ? totals.cost : undefined,
56
+ cost: calls.unknown === 0 ? totals.cost : undefined,
57
+ costCalls: { ...calls },
55
58
  };
56
59
  };
57
60
  for (let round = 0; round < maxToolRounds; round++) {
@@ -0,0 +1,45 @@
1
+ /** OpenAI via the Responses API.
2
+ *
3
+ * Chosen over Chat Completions on OpenAI's own advice: "Reasoning models work
4
+ * better with the Responses API" (platform.openai.com/docs/guides/reasoning).
5
+ * Responses has a different shape from the Chat-Completions style the rest of
6
+ * this codebase's `AiRequest`/`AiMessage` types follow (OpenRouter speaks Chat
7
+ * Completions directly), so this file is the one place that translates:
8
+ *
9
+ * - the system message becomes the top-level `instructions` field
10
+ * - a tool call is its own `function_call` item, not a field on a message
11
+ * - a tool's result is a `function_call_output` item, referenced by `call_id`
12
+ * - a function tool is `{type:"function", name, parameters}`, not nested
13
+ * under a `function` key
14
+ * - usage is `input_tokens`/`output_tokens`, not `prompt_tokens`/
15
+ * `completion_tokens`, and carries no dollar cost at all
16
+ *
17
+ * ponytail: a tool round replays only the function call and its result, not
18
+ * the `reasoning` items a reasoning model's own turn produced alongside them.
19
+ * OpenAI's function-calling guide says those "must also be passed back with
20
+ * tool call outputs" for a reasoning model, so a multi-round tool loop with
21
+ * one (`gpt-6-astra` and similar) may reason less well on later rounds than
22
+ * it could, or in the worst case have a round rejected for missing context.
23
+ * The full fix is `previous_response_id`, which needs a response id to
24
+ * survive between calls, and `AiProvider.complete` is stateless for every
25
+ * provider. Upgrade path: thread a response id through `AiResponse` if this
26
+ * turns out to matter in practice; codegraph's own tool loop caps rounds low
27
+ * (mermaid_loop.ts's `maxToolRounds`), which limits how much is at stake.
28
+ */
29
+ import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
30
+ import { CliError } from "../cli/error.ts";
31
+ /** `CM_OPENAI_URL` points at a local fake in tests. Unset means the real
32
+ * endpoint, so production behavior is unchanged. */
33
+ export declare function openAiBase(): string;
34
+ /** A failed response as actionable text. Only `error.message` is
35
+ * shown: the raw body can carry request ids that mean nothing in a terminal. */
36
+ export declare function openAiError(model: string, status: number, body: string): CliError;
37
+ export declare class OpenAiProvider implements AiProvider {
38
+ readonly supportsTools = true;
39
+ private readonly endpoint;
40
+ private readonly apiKey;
41
+ private readonly model;
42
+ constructor(apiKey: string, model: string);
43
+ private post;
44
+ complete(request: AiRequest): Promise<AiResponse>;
45
+ }