co-maintainer 0.5.1 → 0.5.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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,4 +1,5 @@
1
1
  import { getEnv } from "../util/runtime.js";
2
+ import { outputLimitError } from "./provider.js";
2
3
  import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
3
4
  /** Required on every Messages request and the only version the errors below
4
5
  * are written against. */
@@ -197,8 +198,12 @@ export function parseMessagesBody(json, model) {
197
198
  }
198
199
  }
199
200
  const usage = json.usage;
201
+ const text = texts.join("");
202
+ if (json.stop_reason === "max_tokens" && !text.trim() && !toolCalls.length) {
203
+ throw outputLimitError("anthropic", model);
204
+ }
200
205
  return {
201
- text: texts.join(""),
206
+ text,
202
207
  ...(toolCalls.length ? { toolCalls } : {}),
203
208
  // Tokens read from or written to the prompt cache are input too, and with
204
209
  // the guides cached they are most of it.
@@ -1,4 +1,5 @@
1
1
  import { getEnv } from "../util/runtime.js";
2
+ import { outputLimitError, withoutRefusedPart } from "./provider.js";
2
3
  import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
3
4
  /** `CM_OPENAI_URL` points at a local fake in tests. Unset means the real
4
5
  * endpoint, so production behavior is unchanged. */
@@ -145,8 +146,12 @@ function parseResponsesBody(json, model) {
145
146
  }
146
147
  }
147
148
  const usage = json.usage;
149
+ const text = texts.join("");
150
+ if (json.status === "incomplete" && !text.trim() && !toolCalls.length) {
151
+ throw outputLimitError("openai", model);
152
+ }
148
153
  return {
149
- text: texts.join(""),
154
+ text,
150
155
  ...(toolCalls.length ? { toolCalls } : {}),
151
156
  tokensIn: Number(usage?.input_tokens ?? 0),
152
157
  tokensOut: Number(usage?.output_tokens ?? 0),
@@ -184,25 +189,19 @@ export class OpenAiProvider {
184
189
  }
185
190
  }
186
191
  async complete(request) {
187
- let response = await this.post(responsesBody(this.model, request));
188
- // A model may reject `text.format` next to tools. The schema is only a
189
- // hint (the prompt also asks for a fenced JSON block), so one retry
190
- // without it beats failing the review outright.
191
- if (response.status === 400 && request.responseFormat) {
192
- const detail = await response.text();
193
- if (/text\.format|json_schema|structured/i.test(detail)) {
194
- const { responseFormat: _dropped, ...withoutSchema } = request;
195
- response = await this.post(responsesBody(this.model, withoutSchema));
196
- if (response.ok) {
197
- return parseResponsesBody((await response.json()), this.model);
198
- }
199
- throw openAiError(this.model, response.status, await response.text());
192
+ let current = request;
193
+ for (;;) {
194
+ const response = await this.post(responsesBody(this.model, current));
195
+ if (response.ok) {
196
+ return parseResponsesBody((await response.json()), this.model);
200
197
  }
201
- throw openAiError(this.model, response.status, detail);
202
- }
203
- if (!response.ok) {
204
- throw openAiError(this.model, response.status, await response.text());
198
+ const detail = await response.text();
199
+ const lighter = response.status === 400
200
+ ? withoutRefusedPart(current, detail, /text\.format|json_schema|structured/i)
201
+ : undefined;
202
+ if (!lighter)
203
+ throw openAiError(this.model, response.status, detail);
204
+ current = lighter;
205
205
  }
206
- return parseResponsesBody((await response.json()), this.model);
207
206
  }
208
207
  }
@@ -1,4 +1,4 @@
1
- import { chatBody, parseChatResponse } from "./provider.js";
1
+ import { chatBody, parseChatResponse, withoutRefusedPart } 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
4
  /** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
@@ -75,25 +75,19 @@ export class OpenRouterProvider {
75
75
  return openRouterError(this.model, status, body, this.host);
76
76
  }
77
77
  async complete(request) {
78
- let response = await this.post(chatBody(this.model, request));
79
- // A provider may refuse `response_format` next to `tools`. The schema is
80
- // only a hint — the prompt also asks for a fenced JSON block — so one
81
- // retry without it is better than failing the review.
82
- if (response.status === 400 && request.responseFormat) {
83
- const detail = await response.text();
84
- if (/response_format|json_schema|structured/i.test(detail)) {
85
- const { responseFormat: _dropped, ...withoutSchema } = request;
86
- response = await this.post(chatBody(this.model, withoutSchema));
87
- if (response.ok) {
88
- return parseChatResponse((await response.json()), this.host.provider, this.model);
89
- }
90
- throw this.fail(response.status, await response.text());
78
+ let current = request;
79
+ for (;;) {
80
+ const response = await this.post(chatBody(this.model, current));
81
+ if (response.ok) {
82
+ return parseChatResponse((await response.json()), this.host.provider, this.model);
91
83
  }
92
- throw this.fail(response.status, detail);
93
- }
94
- if (!response.ok) {
95
- throw this.fail(response.status, await response.text());
84
+ const detail = await response.text();
85
+ const lighter = response.status === 400
86
+ ? withoutRefusedPart(current, detail, /response_format|json_schema|structured/i)
87
+ : undefined;
88
+ if (!lighter)
89
+ throw this.fail(response.status, detail);
90
+ current = lighter;
96
91
  }
97
- return parseChatResponse((await response.json()), this.host.provider, this.model);
98
92
  }
99
93
  }
@@ -7,5 +7,14 @@ export { providerKeyEnv } from "../config.ts";
7
7
  /** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
8
8
  export declare function rejectRetiredProvider(value: string | undefined): void;
9
9
  export declare function createAiProvider(options: Options, model: string): AiProvider | undefined;
10
+ /** A 400 that names one optional part of the request earns one retry without
11
+ * it: the JSON schema, which the prompt asks for anyway, or "thinking off",
12
+ * which a model that always thinks refuses. Undefined when nothing is left to
13
+ * drop, so the caller reports the error. */
14
+ export declare function withoutRefusedPart(request: AiRequest, detail: string, schemaPattern: RegExp): AiRequest | undefined;
15
+ /** The model stopped at its output limit before writing a word. Thinking counts
16
+ * toward that limit, so a thinking model can spend all of it there. Without
17
+ * this the empty answer read as a normal one and failed later as "not JSON". */
18
+ export declare function outputLimitError(provider: string, model: string): Error;
10
19
  export declare function parseChatResponse(json: Json, provider: AiResponse["provider"], model: string): AiResponse;
11
20
  export declare function chatBody(model: string, request: AiRequest): Json;
@@ -38,6 +38,28 @@ export function createAiProvider(options, model) {
38
38
  }
39
39
  die(`${options.ai} support is not finished yet. Use openrouter or none.`);
40
40
  }
41
+ /** A 400 that names one optional part of the request earns one retry without
42
+ * it: the JSON schema, which the prompt asks for anyway, or "thinking off",
43
+ * which a model that always thinks refuses. Undefined when nothing is left to
44
+ * drop, so the caller reports the error. */
45
+ export function withoutRefusedPart(request, detail, schemaPattern) {
46
+ if (request.responseFormat && schemaPattern.test(detail)) {
47
+ const { responseFormat: _dropped, ...rest } = request;
48
+ return rest;
49
+ }
50
+ if (request.reasoningEffort === "none" &&
51
+ /reasoning|effort|thinking/i.test(detail)) {
52
+ const { reasoningEffort: _dropped, ...rest } = request;
53
+ return rest;
54
+ }
55
+ return undefined;
56
+ }
57
+ /** The model stopped at its output limit before writing a word. Thinking counts
58
+ * toward that limit, so a thinking model can spend all of it there. Without
59
+ * this the empty answer read as a normal one and failed later as "not JSON". */
60
+ export function outputLimitError(provider, model) {
61
+ return new Error(`${provider} model ${model} reached its output limit before writing an answer. Thinking counts toward that limit.`);
62
+ }
41
63
  export function parseChatResponse(json, provider, model) {
42
64
  const choice = Array.isArray(json.choices)
43
65
  ? json.choices[0]
@@ -61,8 +83,14 @@ export function parseChatResponse(json, provider, model) {
61
83
  })
62
84
  .filter((call) => call.id && call.function.name)
63
85
  : undefined;
86
+ const text = String(message?.content ?? "");
87
+ if (choice?.finish_reason === "length" &&
88
+ !text.trim() &&
89
+ !toolCalls?.length) {
90
+ throw outputLimitError(provider, model);
91
+ }
64
92
  return {
65
- text: String(message?.content ?? ""),
93
+ text,
66
94
  ...(toolCalls?.length ? { toolCalls } : {}),
67
95
  tokensIn: Number(usage?.prompt_tokens ?? 0),
68
96
  tokensOut: Number(usage?.completion_tokens ?? 0),
@@ -157,6 +157,9 @@ function extractRequest(prompt, maxTokens) {
157
157
  job: "extract_unit",
158
158
  prompt,
159
159
  maxTokens,
160
+ // Pulling facts out of a text needs no thinking, and on a thinking model
161
+ // the thinking used the whole limit and left the answer empty.
162
+ reasoningEffort: "none",
160
163
  system: "Extract only repository-specific facts. Never invent files, commands, or process.",
161
164
  };
162
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",