@momentiq/dark-factory-cli 2.2.4 → 2.4.0
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 +5 -1
- package/dist/adapters/_shared.d.ts +84 -0
- package/dist/adapters/_shared.d.ts.map +1 -1
- package/dist/adapters/_shared.js +163 -0
- package/dist/adapters/_shared.js.map +1 -1
- package/dist/adapters/gemini-sdk.d.ts.map +1 -1
- package/dist/adapters/gemini-sdk.js +70 -1
- package/dist/adapters/gemini-sdk.js.map +1 -1
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -1
- package/dist/adapters/grok-direct-sdk.js +71 -1
- package/dist/adapters/grok-direct-sdk.js.map +1 -1
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +6 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/minimax-direct-sdk.d.ts +139 -0
- package/dist/adapters/minimax-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/minimax-direct-sdk.js +659 -0
- package/dist/adapters/minimax-direct-sdk.js.map +1 -0
- package/dist/adapters/static-schema-lint.d.ts.map +1 -1
- package/dist/adapters/static-schema-lint.js +1 -0
- package/dist/adapters/static-schema-lint.js.map +1 -1
- package/dist/cli.d.ts +32 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +67 -6
- package/dist/cli.js.map +1 -1
- package/dist/evidence/index.d.ts +1 -0
- package/dist/evidence/index.d.ts.map +1 -1
- package/dist/evidence/index.js +5 -0
- package/dist/evidence/index.js.map +1 -1
- package/dist/evidence/route-runner.d.ts +39 -0
- package/dist/evidence/route-runner.d.ts.map +1 -0
- package/dist/evidence/route-runner.js +113 -0
- package/dist/evidence/route-runner.js.map +1 -0
- package/dist/exit.d.ts +27 -0
- package/dist/exit.d.ts.map +1 -0
- package/dist/exit.js +68 -0
- package/dist/exit.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/tools/doctor.d.ts.map +1 -1
- package/dist/mcp/tools/doctor.js +2 -0
- package/dist/mcp/tools/doctor.js.map +1 -1
- package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
- package/dist/mcp/tools/review-bypass.js +2 -0
- package/dist/mcp/tools/review-bypass.js.map +1 -1
- package/dist/policy/gate.d.ts +25 -0
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +63 -7
- package/dist/policy/gate.js.map +1 -1
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.d.ts.map +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/index.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +19 -1
- package/dist/runner.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Cycle 20 — MiniMax M3 critic adapter via the `openai` npm package
|
|
2
|
+
// against OpenRouter's OpenAI-compatible inference endpoint.
|
|
3
|
+
//
|
|
4
|
+
// Provider decision (cycle20 D1, pivoted 2026-06-08): MiniMax M3 is
|
|
5
|
+
// served through OpenRouter, whose `minimax/minimax-m3` endpoint routes
|
|
6
|
+
// to MiniMax's own provider — headquartered in SG, with inference
|
|
7
|
+
// datacenters in the US (per OpenRouter's per-provider residency
|
|
8
|
+
// metadata). The pivot from the original Together AI plan (#302: Together
|
|
9
|
+
// never shipped M3) keeps the inference compute US-based, which is the
|
|
10
|
+
// data-residency property the hosted critic requires. The SG corporate
|
|
11
|
+
// jurisdiction is a compliance-posture note surfaced to compliance, not a
|
|
12
|
+
// runtime concern of this adapter.
|
|
13
|
+
//
|
|
14
|
+
// Why a fifth adapter (cycle20 § Scope): the four-vendor critic fleet
|
|
15
|
+
// (cursor + codex + gemini + grok) leaves four vendor lineages
|
|
16
|
+
// (Anthropic-adjacent / OpenAI / Google / xAI). MiniMax M3 is an OSS-
|
|
17
|
+
// weights model whose training distribution + RLHF process are
|
|
18
|
+
// uncorrelated with those four, so adding it as a 5th critic carries
|
|
19
|
+
// the same § "uncorrelated lineage" information value that motivated
|
|
20
|
+
// the original Grok add (cycle 322.3) — a single-vendor outage can
|
|
21
|
+
// never paralyze the gate, AND inter-critic disagreement on a hard PR
|
|
22
|
+
// carries more signal than four-of-four agreement.
|
|
23
|
+
//
|
|
24
|
+
// The adapter:
|
|
25
|
+
// - implements `CriticAdapter` from `critic.ts` with
|
|
26
|
+
// `requiredEnvVars = [OPEN_ROUTER_API_KEY_ENV]`
|
|
27
|
+
// - calls OpenRouter's `/v1/chat/completions` endpoint (OpenAI-
|
|
28
|
+
// compatible Chat Completions API; cycle20 D3 explicitly chose
|
|
29
|
+
// this shape over the Responses API because OpenRouter exposes
|
|
30
|
+
// Chat Completions for MiniMax M3)
|
|
31
|
+
// - sends `provider.data_collection: "deny"` so OpenRouter only routes
|
|
32
|
+
// to a provider that does not retain/train on the prompt — the
|
|
33
|
+
// prompt is third-party customer diff content. This is a fail-LOUD
|
|
34
|
+
// compliance default: if the request 404s with "no allowed
|
|
35
|
+
// providers", that means MiniMax-on-OpenRouter can NOT guarantee
|
|
36
|
+
// no-retention, which is a compliance finding to escalate — NOT a
|
|
37
|
+
// reason to silently flip to "allow". Overridable via the
|
|
38
|
+
// `dataCollection` constructor option, and sent for any OpenRouter
|
|
39
|
+
// host (matched by hostname, not exact string — so trailing-slash /
|
|
40
|
+
// path variants still get it); a custom `baseUrl` pointing at a
|
|
41
|
+
// genuinely different OpenAI-compatible endpoint omits this
|
|
42
|
+
// OpenRouter-specific `provider` field.
|
|
43
|
+
// - token-accounts off the OpenAI-format `usage` field on the
|
|
44
|
+
// terminal `chunk.usage` of the streamed response (matching the
|
|
45
|
+
// OpenAI SDK contract — `stream_options: { include_usage: true }`
|
|
46
|
+
// surfaces `usage` on the final chunk), including the cached-prefix
|
|
47
|
+
// token count (`usage.prompt_tokens_details.cached_tokens`) that
|
|
48
|
+
// OpenRouter bills at the cache-read rate ($0.06/Mtok vs $0.30 input)
|
|
49
|
+
// - mirrors the 322.1 retry shape (`runRetryLoop` + `attemptReview`)
|
|
50
|
+
// from a single source of truth in `cursor-sdk.ts`, so the policy +
|
|
51
|
+
// budget are byte-identical across adapters
|
|
52
|
+
// - routes diagnostic-redaction + JSON parsing + reviewer-metadata
|
|
53
|
+
// merge + error-result construction through `_shared.ts` so the
|
|
54
|
+
// security boundary (redactSecrets at the only writeFileSync site)
|
|
55
|
+
// cannot drift
|
|
56
|
+
// - is read-only by structure: no `tools` configured on the request;
|
|
57
|
+
// the only output channel is the JSON response itself
|
|
58
|
+
//
|
|
59
|
+
// The implementation uses the dependency-injection ESCAPE hatch on the
|
|
60
|
+
// constructor (`createClient` factory) so unit tests can supply a mock
|
|
61
|
+
// OpenAI client without forcing the SDK to be present at test time —
|
|
62
|
+
// matching the testing posture of the cycle 322.3 grok adapter tests.
|
|
63
|
+
import OpenAI, { APIError } from "openai";
|
|
64
|
+
import { compileCriticPrompt } from "../prompt.js";
|
|
65
|
+
import { parseCriticResult, } from "@momentiq/dark-factory-schemas";
|
|
66
|
+
import { buildErrorResult, mergeAdapterMetadata, normalizeCriticEcho, parseAssistantJson, writeRedactedDiagnostic, } from "./_shared.js";
|
|
67
|
+
import { runRetryLoop, } from "./_retry.js";
|
|
68
|
+
export const MINIMAX_DIRECT_SDK_ADAPTER_ID = "minimax-direct-sdk";
|
|
69
|
+
export const OPEN_ROUTER_API_KEY_ENV = "OPEN_ROUTER_API_KEY";
|
|
70
|
+
// OpenRouter's OpenAI-compatible inference endpoint. The MiniMax M3
|
|
71
|
+
// model id (`minimax/minimax-m3`) is configured via `critic.model.id`
|
|
72
|
+
// and routed by OpenRouter's model dispatch on `/v1/chat/completions`.
|
|
73
|
+
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
74
|
+
/**
|
|
75
|
+
* True iff `baseUrl`'s host is OpenRouter (any path / trailing-slash variant,
|
|
76
|
+
* and regional `*.openrouter.ai` subdomains). Gates the OpenRouter-specific
|
|
77
|
+
* `provider` routing field by HOST rather than exact string equality, so the
|
|
78
|
+
* no-retention compliance constraint is applied to ALL OpenRouter targets and
|
|
79
|
+
* omitted only for a genuinely different OpenAI-compatible host. A malformed
|
|
80
|
+
* URL returns `false` (treated as non-OpenRouter). Exported for unit testing.
|
|
81
|
+
*/
|
|
82
|
+
export function isOpenRouterEndpoint(baseUrl) {
|
|
83
|
+
try {
|
|
84
|
+
const host = new URL(baseUrl).hostname.toLowerCase();
|
|
85
|
+
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export const OPENROUTER_DATA_COLLECTION_DEFAULT = "deny";
|
|
92
|
+
// Chat Completions permanent-failure HTTP statuses — same buckets as
|
|
93
|
+
// the Grok adapter uses against the Responses API since both OpenRouter
|
|
94
|
+
// (OpenAI-compatible) and OpenAI itself share status semantics. Burning
|
|
95
|
+
// retry budget on these wastes wall-clock AND can mask the real fault
|
|
96
|
+
// (e.g., a wrong API key would silently exhaust retries before
|
|
97
|
+
// surfacing).
|
|
98
|
+
// 400 invalid_request — bad request shape, model id typo
|
|
99
|
+
// 401 / 403 — auth failure
|
|
100
|
+
// 404 model_not_found — model id not in the provider's catalog, OR
|
|
101
|
+
// no allowed provider (data_collection=deny +
|
|
102
|
+
// no eligible provider — a compliance signal)
|
|
103
|
+
// 429 rate_limit — quota / rate-limit (retrying within 20s
|
|
104
|
+
// burns budget; surface immediately so the
|
|
105
|
+
// operator can investigate)
|
|
106
|
+
// Anything else (5xx, 504, transient network) is retryable. The retry
|
|
107
|
+
// budget is bounded by `runRetryLoop` to 2 retries / 20s wall-clock.
|
|
108
|
+
export const MINIMAX_PERMANENT_STATUS = new Set([
|
|
109
|
+
400,
|
|
110
|
+
401,
|
|
111
|
+
403,
|
|
112
|
+
404,
|
|
113
|
+
429,
|
|
114
|
+
]);
|
|
115
|
+
/**
|
|
116
|
+
* Probe a thrown error for an OpenAI-format API HTTP status code.
|
|
117
|
+
* Returns `null` when the error didn't carry one (network error,
|
|
118
|
+
* non-API exception). Exported for direct unit testing.
|
|
119
|
+
*
|
|
120
|
+
* The `openai` SDK throws `APIError extends Error` with a `status:
|
|
121
|
+
* number` field on HTTP-level failures; a non-API failure (DNS, timeout
|
|
122
|
+
* in fetch, etc.) won't have this field set. Treating "no status" as
|
|
123
|
+
* retryable lets the loop catch real transient blips while not silently
|
|
124
|
+
* retrying logic errors.
|
|
125
|
+
*/
|
|
126
|
+
export function extractOpenRouterApiErrorStatus(err) {
|
|
127
|
+
if (!err || typeof err !== "object")
|
|
128
|
+
return null;
|
|
129
|
+
const e = err;
|
|
130
|
+
if (typeof e["status"] === "number")
|
|
131
|
+
return e["status"];
|
|
132
|
+
// Some SDK error shapes nest the status under `response.status`:
|
|
133
|
+
const response = e["response"];
|
|
134
|
+
if (response && typeof response === "object") {
|
|
135
|
+
const status = response["status"];
|
|
136
|
+
if (typeof status === "number")
|
|
137
|
+
return status;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Policy gate: decide whether an OpenRouter / Chat Completions failure is
|
|
143
|
+
* retryable. Returns `false` for HTTP statuses in
|
|
144
|
+
* {@link MINIMAX_PERMANENT_STATUS}, `true` otherwise (including
|
|
145
|
+
* no-status network errors).
|
|
146
|
+
*
|
|
147
|
+
* Exported for direct unit testing.
|
|
148
|
+
*/
|
|
149
|
+
export function isMinimaxPermanentFailure(status) {
|
|
150
|
+
if (status === null)
|
|
151
|
+
return false; // no status → treat as transient
|
|
152
|
+
return MINIMAX_PERMANENT_STATUS.has(status);
|
|
153
|
+
}
|
|
154
|
+
export class MinimaxDirectSdkAdapter {
|
|
155
|
+
options;
|
|
156
|
+
id = MINIMAX_DIRECT_SDK_ADAPTER_ID;
|
|
157
|
+
requiredEnvVars = [OPEN_ROUTER_API_KEY_ENV];
|
|
158
|
+
createClient;
|
|
159
|
+
baseUrl;
|
|
160
|
+
dataCollection;
|
|
161
|
+
constructor(options = {}) {
|
|
162
|
+
this.options = options;
|
|
163
|
+
this.baseUrl = options.baseUrl ?? OPENROUTER_BASE_URL;
|
|
164
|
+
this.dataCollection = options.dataCollection ?? OPENROUTER_DATA_COLLECTION_DEFAULT;
|
|
165
|
+
this.createClient =
|
|
166
|
+
options.createClient ??
|
|
167
|
+
((apiKey, baseUrl) => new OpenAI({
|
|
168
|
+
apiKey,
|
|
169
|
+
baseURL: baseUrl,
|
|
170
|
+
// OpenRouter app-attribution headers (optional; recommended so
|
|
171
|
+
// usage is identifiable in the OpenRouter dashboard).
|
|
172
|
+
defaultHeaders: {
|
|
173
|
+
"HTTP-Referer": "https://github.com/momentiq-ai/dark-factory",
|
|
174
|
+
"X-Title": "Dark Factory critic",
|
|
175
|
+
},
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
async review(packet, critic, options) {
|
|
179
|
+
return runRetryLoop({
|
|
180
|
+
attempt: (idx) => this.attemptReview(packet, critic, options, idx),
|
|
181
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
182
|
+
...(this.options.sleep !== undefined ? { sleep: this.options.sleep } : {}),
|
|
183
|
+
buildExhausted: ({ last, totalAttempts, aborted }) => {
|
|
184
|
+
const retriesUsed = Math.max(0, totalAttempts - 1);
|
|
185
|
+
const summary = aborted
|
|
186
|
+
? last
|
|
187
|
+
? `minimax SDK run aborted after ${retriesUsed} retries: ${last.message}`
|
|
188
|
+
: "minimax SDK run aborted before any attempt completed"
|
|
189
|
+
: last
|
|
190
|
+
? `minimax SDK run failed after ${retriesUsed} retries: ${last.message}`
|
|
191
|
+
: "minimax SDK run failed with no captured failure metadata";
|
|
192
|
+
return buildErrorResult({
|
|
193
|
+
critic,
|
|
194
|
+
message: summary,
|
|
195
|
+
retryable: true,
|
|
196
|
+
...(last?.errorCode != null ? { code: last.errorCode } : {}),
|
|
197
|
+
retryCount: retriesUsed,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// One attempt. Mirrors `GrokDirectSdkAdapter.attemptReview` shape so
|
|
203
|
+
// the outer retry loop dispatches identically; differences are
|
|
204
|
+
// Chat-Completions-specific (delta event shape on `choices[0].delta`,
|
|
205
|
+
// `finish_reason='length'`/`'content_filter'` as truncation,
|
|
206
|
+
// `usage` on the terminal chunk) and surface here.
|
|
207
|
+
async attemptReview(packet, critic, options, attemptIdx) {
|
|
208
|
+
const apiKey = this.options.apiKey ?? process.env[OPEN_ROUTER_API_KEY_ENV];
|
|
209
|
+
if (!apiKey) {
|
|
210
|
+
// Missing key is permanent — no retry can fix a missing secret.
|
|
211
|
+
return {
|
|
212
|
+
kind: "permanent_failure",
|
|
213
|
+
errorCode: null,
|
|
214
|
+
statusMessage: null,
|
|
215
|
+
result: buildErrorResult({
|
|
216
|
+
critic,
|
|
217
|
+
message: `${OPEN_ROUTER_API_KEY_ENV} is not set; cannot run MiniMax critic`,
|
|
218
|
+
retryable: false,
|
|
219
|
+
retryCount: attemptIdx,
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const prompt = compileCriticPrompt({
|
|
224
|
+
packet,
|
|
225
|
+
critic,
|
|
226
|
+
blockingSeverities: options.blockingSeverities,
|
|
227
|
+
treatDiffAsUntrusted: true,
|
|
228
|
+
});
|
|
229
|
+
const startMs = Date.now();
|
|
230
|
+
if (attemptIdx === 0) {
|
|
231
|
+
options.emit?.({
|
|
232
|
+
ts: new Date().toISOString(),
|
|
233
|
+
event: "critic_run_started",
|
|
234
|
+
commit: packet.commit.sha,
|
|
235
|
+
criticId: critic.id,
|
|
236
|
+
adapter: this.id,
|
|
237
|
+
model: critic.model.id,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const client = this.createClient(apiKey, this.baseUrl);
|
|
241
|
+
let assistantText = "";
|
|
242
|
+
let lastUsage;
|
|
243
|
+
// OpenAI Chat Completions emits `finish_reason: 'length'` when the
|
|
244
|
+
// response was truncated at `max_tokens`, or `'content_filter'` when
|
|
245
|
+
// the model's safety filter triggered. Treat as permanent failure
|
|
246
|
+
// with preserved accumulated text — retrying the same prompt
|
|
247
|
+
// re-trips the same truncation, AND the partial text is the most
|
|
248
|
+
// informative thing the operator can read.
|
|
249
|
+
let truncationReason;
|
|
250
|
+
try {
|
|
251
|
+
// `chat.completions.create` returns either a single ChatCompletion
|
|
252
|
+
// (for non-streaming) or an AsyncIterable<ChatCompletionChunk>
|
|
253
|
+
// (for streaming). We always stream so the result is the
|
|
254
|
+
// async-iterable. Request options second-arg carries the
|
|
255
|
+
// AbortSignal — the SDK threads it to the underlying fetch.
|
|
256
|
+
const stream = await client.chat.completions.create({
|
|
257
|
+
model: critic.model.id,
|
|
258
|
+
messages: [
|
|
259
|
+
{
|
|
260
|
+
role: "user",
|
|
261
|
+
content: prompt.text,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
// Force JSON-only response. `parseAssistantJson` still runs as
|
|
265
|
+
// a safety net for occasional format drift — adapters never
|
|
266
|
+
// trust the structured-output guarantee because a malformed
|
|
267
|
+
// terminal text would otherwise produce an unparseable
|
|
268
|
+
// artifact that the gate can't evaluate.
|
|
269
|
+
response_format: { type: "json_object" },
|
|
270
|
+
stream: true,
|
|
271
|
+
// OpenAI streaming contract: `usage` surfaces on the terminal
|
|
272
|
+
// chunk only when `stream_options.include_usage: true` is
|
|
273
|
+
// sent. Without it `lastUsage` stays undefined and the
|
|
274
|
+
// telemetry payload omits token counts → null cost rows.
|
|
275
|
+
stream_options: { include_usage: true },
|
|
276
|
+
// Compliance: constrain OpenRouter routing to a no-retention
|
|
277
|
+
// provider for the third-party customer diff. Fail-loud — see
|
|
278
|
+
// file header. `provider` routing is OpenRouter-specific, so it is
|
|
279
|
+
// sent for ALL OpenRouter hosts (default + path/trailing-slash
|
|
280
|
+
// variants + regional shards, matched by HOST not raw string) and
|
|
281
|
+
// omitted only for a genuinely different OpenAI-compatible endpoint,
|
|
282
|
+
// whose caller owns its own data policy (the field is unknown there).
|
|
283
|
+
...(isOpenRouterEndpoint(this.baseUrl)
|
|
284
|
+
? { provider: { data_collection: this.dataCollection } }
|
|
285
|
+
: {}),
|
|
286
|
+
}, options.signal !== undefined ? { signal: options.signal } : {});
|
|
287
|
+
for await (const chunk of stream) {
|
|
288
|
+
if (options.signal?.aborted)
|
|
289
|
+
break;
|
|
290
|
+
// Primary path: accumulate text deltas from `choices[0].delta.content`.
|
|
291
|
+
// OpenAI Chat Completions streams emit one chunk per token (or
|
|
292
|
+
// per server-flush boundary); the role is set on the first chunk
|
|
293
|
+
// only.
|
|
294
|
+
const choice = chunk.choices?.[0];
|
|
295
|
+
if (choice) {
|
|
296
|
+
const delta = choice.delta?.content;
|
|
297
|
+
if (typeof delta === "string") {
|
|
298
|
+
assistantText += delta;
|
|
299
|
+
}
|
|
300
|
+
// `finish_reason` is set on the terminal chunk for that
|
|
301
|
+
// choice. `stop` = normal completion; `length` = max_tokens
|
|
302
|
+
// hit; `content_filter` = safety block.
|
|
303
|
+
const finishReason = choice.finish_reason;
|
|
304
|
+
if (finishReason === "length" || finishReason === "content_filter") {
|
|
305
|
+
truncationReason = finishReason;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Per the OpenAI streaming contract with
|
|
309
|
+
// `stream_options.include_usage: true`, the terminal chunk
|
|
310
|
+
// carries `usage` populated and `choices: []`. Capture it here
|
|
311
|
+
// (latest-non-null wins so a future provider that re-emits
|
|
312
|
+
// usage mid-stream does not silently drop the count).
|
|
313
|
+
if (chunk.usage) {
|
|
314
|
+
lastUsage = chunk.usage;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
const e = err;
|
|
320
|
+
const status = err instanceof APIError ? err.status : extractOpenRouterApiErrorStatus(err);
|
|
321
|
+
const permanent = isMinimaxPermanentFailure(status);
|
|
322
|
+
const codeStr = status !== null ? `http_${status}` : "transport_error";
|
|
323
|
+
options.emit?.({
|
|
324
|
+
ts: new Date().toISOString(),
|
|
325
|
+
event: "critic_run_error",
|
|
326
|
+
commit: packet.commit.sha,
|
|
327
|
+
criticId: critic.id,
|
|
328
|
+
adapter: this.id,
|
|
329
|
+
model: critic.model.id,
|
|
330
|
+
durationMs: Date.now() - startMs,
|
|
331
|
+
error: e.message,
|
|
332
|
+
status: permanent ? "run_failure_permanent" : "run_failure",
|
|
333
|
+
retryCount: attemptIdx,
|
|
334
|
+
errorCode: codeStr,
|
|
335
|
+
});
|
|
336
|
+
if (permanent) {
|
|
337
|
+
return {
|
|
338
|
+
kind: "permanent_failure",
|
|
339
|
+
errorCode: codeStr,
|
|
340
|
+
statusMessage: null,
|
|
341
|
+
result: buildErrorResult({
|
|
342
|
+
critic,
|
|
343
|
+
message: `minimax SDK run failed (permanent, status=${status ?? "?"}): ${e.message}`,
|
|
344
|
+
retryable: false,
|
|
345
|
+
code: codeStr,
|
|
346
|
+
retryCount: attemptIdx,
|
|
347
|
+
}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
kind: "retryable_failure",
|
|
352
|
+
errorCode: codeStr,
|
|
353
|
+
statusMessage: null,
|
|
354
|
+
message: `minimax SDK run failed: ${e.message}`,
|
|
355
|
+
runId: null,
|
|
356
|
+
agentId: null,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (truncationReason) {
|
|
360
|
+
// Truncation / content-filter — permanent. Preserve the
|
|
361
|
+
// accumulated partial text in the diagnostic artifact for
|
|
362
|
+
// operator inspection. The distinct `incomplete` errorCode lets
|
|
363
|
+
// operators discriminate truncation patterns from transport
|
|
364
|
+
// failures in `_runs.ndjson` — important because the remediation
|
|
365
|
+
// differs (raise max_tokens / revise prompt vs. fix vendor
|
|
366
|
+
// incident).
|
|
367
|
+
const diagPath = writeRedactedDiagnostic({
|
|
368
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
369
|
+
criticId: critic.id,
|
|
370
|
+
commit: packet.commit.sha,
|
|
371
|
+
rawText: assistantText,
|
|
372
|
+
});
|
|
373
|
+
const msg = `minimax critic response truncated: ${truncationReason} (partial text preserved)`;
|
|
374
|
+
options.emit?.({
|
|
375
|
+
ts: new Date().toISOString(),
|
|
376
|
+
event: "critic_run_error",
|
|
377
|
+
commit: packet.commit.sha,
|
|
378
|
+
criticId: critic.id,
|
|
379
|
+
adapter: this.id,
|
|
380
|
+
model: critic.model.id,
|
|
381
|
+
durationMs: Date.now() - startMs,
|
|
382
|
+
error: msg,
|
|
383
|
+
status: "incomplete",
|
|
384
|
+
retryCount: attemptIdx,
|
|
385
|
+
errorCode: "incomplete",
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
kind: "permanent_failure",
|
|
389
|
+
errorCode: "incomplete",
|
|
390
|
+
statusMessage: null,
|
|
391
|
+
result: buildErrorResult({
|
|
392
|
+
critic,
|
|
393
|
+
message: msg,
|
|
394
|
+
retryable: false,
|
|
395
|
+
code: "incomplete",
|
|
396
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
397
|
+
retryCount: attemptIdx,
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const parseOutcome = parseAssistantJson(assistantText);
|
|
402
|
+
if (!parseOutcome.ok) {
|
|
403
|
+
const diagPath = writeRedactedDiagnostic({
|
|
404
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
405
|
+
criticId: critic.id,
|
|
406
|
+
commit: packet.commit.sha,
|
|
407
|
+
rawText: assistantText,
|
|
408
|
+
});
|
|
409
|
+
options.emit?.({
|
|
410
|
+
ts: new Date().toISOString(),
|
|
411
|
+
event: "critic_run_error",
|
|
412
|
+
commit: packet.commit.sha,
|
|
413
|
+
criticId: critic.id,
|
|
414
|
+
adapter: this.id,
|
|
415
|
+
model: critic.model.id,
|
|
416
|
+
durationMs: Date.now() - startMs,
|
|
417
|
+
error: `invalid critic JSON: ${parseOutcome.message}`,
|
|
418
|
+
status: "invalid_json",
|
|
419
|
+
retryCount: attemptIdx,
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
kind: "permanent_failure",
|
|
423
|
+
errorCode: null,
|
|
424
|
+
statusMessage: null,
|
|
425
|
+
result: buildErrorResult({
|
|
426
|
+
critic,
|
|
427
|
+
message: `minimax critic returned invalid JSON: ${parseOutcome.message}`,
|
|
428
|
+
retryable: false,
|
|
429
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
430
|
+
retryCount: attemptIdx,
|
|
431
|
+
}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
let result;
|
|
435
|
+
try {
|
|
436
|
+
// Drop schema-invalid `validation.qualityGateResults[]` entries
|
|
437
|
+
// (e.g. model emits `gate` instead of `command`) BEFORE strict
|
|
438
|
+
// parsing — the validation block is informational and gets
|
|
439
|
+
// overwritten below with deterministic packet evidence. Issue
|
|
440
|
+
// #1484 (Grok same behavior).
|
|
441
|
+
const normalized = normalizeCriticEcho(parseOutcome.value);
|
|
442
|
+
const enriched = mergeAdapterMetadata(normalized, { critic });
|
|
443
|
+
result = parseCriticResult(enriched, options.blockingSeverities);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const e = err;
|
|
447
|
+
const diagPath = writeRedactedDiagnostic({
|
|
448
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
449
|
+
criticId: critic.id,
|
|
450
|
+
commit: packet.commit.sha,
|
|
451
|
+
rawText: assistantText,
|
|
452
|
+
});
|
|
453
|
+
options.emit?.({
|
|
454
|
+
ts: new Date().toISOString(),
|
|
455
|
+
event: "critic_run_error",
|
|
456
|
+
commit: packet.commit.sha,
|
|
457
|
+
criticId: critic.id,
|
|
458
|
+
adapter: this.id,
|
|
459
|
+
model: critic.model.id,
|
|
460
|
+
durationMs: Date.now() - startMs,
|
|
461
|
+
error: `schema validation failed: ${e.message}`,
|
|
462
|
+
status: "schema_violation",
|
|
463
|
+
retryCount: attemptIdx,
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
kind: "permanent_failure",
|
|
467
|
+
errorCode: null,
|
|
468
|
+
statusMessage: null,
|
|
469
|
+
result: buildErrorResult({
|
|
470
|
+
critic,
|
|
471
|
+
message: `minimax critic JSON failed schema validation: ${e.message}`,
|
|
472
|
+
retryable: false,
|
|
473
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
474
|
+
retryCount: attemptIdx,
|
|
475
|
+
}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const durationMs = Date.now() - startMs;
|
|
479
|
+
const cachedTokens = lastUsage?.prompt_tokens_details?.cached_tokens;
|
|
480
|
+
const enriched = {
|
|
481
|
+
...result,
|
|
482
|
+
durationMs,
|
|
483
|
+
// Cycle 6.3 — surface per-critic telemetry on the artifact-shaped
|
|
484
|
+
// result. The OpenAI Chat Completions usage block exposes
|
|
485
|
+
// `prompt_tokens` / `completion_tokens`; OpenRouter's MiniMax M3
|
|
486
|
+
// path additionally breaks out the cached-prefix portion under
|
|
487
|
+
// `prompt_tokens_details.cached_tokens` (billed at the cache-read
|
|
488
|
+
// rate), captured as `tokensCached` for accurate cost attribution.
|
|
489
|
+
retries: attemptIdx,
|
|
490
|
+
...(typeof lastUsage?.prompt_tokens === "number"
|
|
491
|
+
? { tokensInput: lastUsage.prompt_tokens }
|
|
492
|
+
: {}),
|
|
493
|
+
...(typeof lastUsage?.completion_tokens === "number"
|
|
494
|
+
? { tokensOutput: lastUsage.completion_tokens }
|
|
495
|
+
: {}),
|
|
496
|
+
...(typeof cachedTokens === "number" ? { tokensCached: cachedTokens } : {}),
|
|
497
|
+
validation: {
|
|
498
|
+
qualityGateResults: packet.validation.evidence,
|
|
499
|
+
qualityGatesMissing: packet.validation.missing,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
const blockerCount = enriched.findings.filter((f) => f.severity === "blocker").length;
|
|
503
|
+
const highCount = enriched.findings.filter((f) => f.severity === "high").length;
|
|
504
|
+
options.emit?.({
|
|
505
|
+
ts: new Date().toISOString(),
|
|
506
|
+
event: "critic_run_finished",
|
|
507
|
+
commit: packet.commit.sha,
|
|
508
|
+
criticId: critic.id,
|
|
509
|
+
adapter: this.id,
|
|
510
|
+
model: critic.model.id,
|
|
511
|
+
durationMs,
|
|
512
|
+
...(enriched.verdict !== undefined ? { verdict: enriched.verdict } : {}),
|
|
513
|
+
findingCount: enriched.findings.length,
|
|
514
|
+
blockerCount,
|
|
515
|
+
highCount,
|
|
516
|
+
...(typeof lastUsage?.prompt_tokens === "number"
|
|
517
|
+
? { tokensIn: lastUsage.prompt_tokens }
|
|
518
|
+
: {}),
|
|
519
|
+
...(typeof lastUsage?.completion_tokens === "number"
|
|
520
|
+
? { tokensOut: lastUsage.completion_tokens }
|
|
521
|
+
: {}),
|
|
522
|
+
status: "complete",
|
|
523
|
+
retryCount: attemptIdx,
|
|
524
|
+
});
|
|
525
|
+
return { kind: "success", result: enriched };
|
|
526
|
+
}
|
|
527
|
+
async doctor(critic) {
|
|
528
|
+
const checks = [];
|
|
529
|
+
const apiKey = this.options.apiKey ?? process.env[OPEN_ROUTER_API_KEY_ENV];
|
|
530
|
+
const missingOptionalKey = !apiKey && !critic.required;
|
|
531
|
+
checks.push({
|
|
532
|
+
name: "open_router_api_key",
|
|
533
|
+
passed: Boolean(apiKey) || missingOptionalKey,
|
|
534
|
+
detail: apiKey
|
|
535
|
+
? `${OPEN_ROUTER_API_KEY_ENV} present`
|
|
536
|
+
: missingOptionalKey
|
|
537
|
+
? `${OPEN_ROUTER_API_KEY_ENV} missing; optional shadow critic will be skipped at review time`
|
|
538
|
+
: `${OPEN_ROUTER_API_KEY_ENV} missing`,
|
|
539
|
+
...(apiKey || missingOptionalKey
|
|
540
|
+
? {}
|
|
541
|
+
: {
|
|
542
|
+
remediation: `export ${OPEN_ROUTER_API_KEY_ENV}=... or add it to the Doppler scope (dark-factory/prd). MiniMax M3 is served via OpenRouter's OpenAI-compatible inference endpoint.`,
|
|
543
|
+
}),
|
|
544
|
+
});
|
|
545
|
+
let sdkLoaded = false;
|
|
546
|
+
try {
|
|
547
|
+
// The dynamic import path catches both "package missing on disk"
|
|
548
|
+
// and "package present but no exports we recognize" (older shape)
|
|
549
|
+
// cases.
|
|
550
|
+
const mod = (await import("openai"));
|
|
551
|
+
sdkLoaded = typeof mod["default"] === "function" || typeof mod["OpenAI"] === "function";
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
sdkLoaded = false;
|
|
555
|
+
}
|
|
556
|
+
checks.push({
|
|
557
|
+
name: "minimax_sdk_loaded",
|
|
558
|
+
passed: sdkLoaded,
|
|
559
|
+
detail: sdkLoaded
|
|
560
|
+
? "openai SDK imported (used as OpenRouter client via baseURL)"
|
|
561
|
+
: "openai SDK missing or shape unexpected",
|
|
562
|
+
...(sdkLoaded
|
|
563
|
+
? {}
|
|
564
|
+
: { remediation: "npm ci --include=dev" }),
|
|
565
|
+
});
|
|
566
|
+
// Diagnostic family-prefix check: a stale config that pins to a
|
|
567
|
+
// non-MiniMax model id can be flagged BEFORE the live models.list()
|
|
568
|
+
// call below — useful when the operator's API key isn't yet
|
|
569
|
+
// provisioned but the doctor is still expected to catch obvious
|
|
570
|
+
// config errors. We match `minimax`-prefixed ids case-insensitively
|
|
571
|
+
// (OpenRouter's slug is `minimax/minimax-m3`).
|
|
572
|
+
const familyOk = critic.model.id.toLowerCase().includes("minimax");
|
|
573
|
+
checks.push({
|
|
574
|
+
name: "minimax_model_id_family",
|
|
575
|
+
passed: familyOk,
|
|
576
|
+
detail: familyOk
|
|
577
|
+
? `${critic.model.id} matches minimax-* family pattern`
|
|
578
|
+
: `${critic.model.id} does NOT match minimax-* family pattern`,
|
|
579
|
+
...(familyOk
|
|
580
|
+
? {}
|
|
581
|
+
: {
|
|
582
|
+
remediation: "the configured MiniMax critic's model.id should contain 'minimax' (e.g., 'minimax/minimax-m3'). Update .agent-review/config.json:critics[].model.id.",
|
|
583
|
+
}),
|
|
584
|
+
});
|
|
585
|
+
if (!sdkLoaded || !apiKey)
|
|
586
|
+
return checks;
|
|
587
|
+
// Verify the configured model id resolves via models.list(). Mirrors
|
|
588
|
+
// the Grok doctor's live-catalog check so a future OpenRouter model
|
|
589
|
+
// retirement / id rename is caught before review time.
|
|
590
|
+
try {
|
|
591
|
+
const client = this.createClient(apiKey, this.baseUrl);
|
|
592
|
+
const list = client.models.list;
|
|
593
|
+
if (typeof list !== "function") {
|
|
594
|
+
checks.push({
|
|
595
|
+
name: "minimax_model_listing",
|
|
596
|
+
passed: false,
|
|
597
|
+
detail: "openai SDK models.list not exposed; cannot verify model id",
|
|
598
|
+
remediation: "verify the openai SDK version exposes Models.list (>= 4.x); upgrade if needed.",
|
|
599
|
+
});
|
|
600
|
+
return checks;
|
|
601
|
+
}
|
|
602
|
+
// Three shapes the doctor must handle:
|
|
603
|
+
// 1. openai SDK production: `PagePromise` — thenable AND
|
|
604
|
+
// `AsyncIterable<Item>` (verified against openai@^6
|
|
605
|
+
// `node_modules/openai/core/pagination.d.ts`). Per the SDK's
|
|
606
|
+
// own doc comment on `PagePromise`: "Allow auto-paginating
|
|
607
|
+
// iteration on an unawaited list call." Direct-iterate.
|
|
608
|
+
// 2. Plain AsyncIterable test mock — `list: () => ({
|
|
609
|
+
// async *[Symbol.asyncIterator]() {...} })`. Direct-iterate.
|
|
610
|
+
// 3. Promise<AsyncIterable> test mock — `list: async () =>
|
|
611
|
+
// ({...})` — the result is a Promise wrapping an
|
|
612
|
+
// AsyncIterable, NOT itself async-iterable. Must await first.
|
|
613
|
+
//
|
|
614
|
+
// Discriminator: if the returned value implements
|
|
615
|
+
// `Symbol.asyncIterator`, it's case (1) or (2) — direct-iterate.
|
|
616
|
+
// Else if it's thenable, it's case (3) — await to unwrap.
|
|
617
|
+
// Awaiting a `PagePromise` works too but it discards
|
|
618
|
+
// auto-pagination (drops to a single Page's worth of items —
|
|
619
|
+
// codex's original finding on commit c24256f), so the order of
|
|
620
|
+
// the checks matters: AsyncIterable FIRST, thenable SECOND.
|
|
621
|
+
const listed = list.call(client.models);
|
|
622
|
+
const hasAsyncIterator = (v) => v !== null && typeof v === "object" && Symbol.asyncIterator in v;
|
|
623
|
+
const isThenable = (v) => v !== null && typeof v === "object" && typeof v.then === "function";
|
|
624
|
+
const iterable = hasAsyncIterator(listed)
|
|
625
|
+
? listed
|
|
626
|
+
: isThenable(listed)
|
|
627
|
+
? (await listed)
|
|
628
|
+
: listed;
|
|
629
|
+
const ids = [];
|
|
630
|
+
for await (const m of iterable) {
|
|
631
|
+
if (typeof m.id === "string")
|
|
632
|
+
ids.push(m.id);
|
|
633
|
+
}
|
|
634
|
+
const matched = ids.some((n) => n === critic.model.id);
|
|
635
|
+
checks.push({
|
|
636
|
+
name: "minimax_model_id",
|
|
637
|
+
passed: matched,
|
|
638
|
+
detail: matched
|
|
639
|
+
? `model ${critic.model.id} available`
|
|
640
|
+
: `model ${critic.model.id} not in available list (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? "..." : ""})`,
|
|
641
|
+
...(matched
|
|
642
|
+
? {}
|
|
643
|
+
: {
|
|
644
|
+
remediation: "update .agent-review/config.json:critics[].model.id to a model id surfaced by OpenRouter's /v1/models endpoint (e.g. 'minimax/minimax-m3')",
|
|
645
|
+
}),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
checks.push({
|
|
650
|
+
name: "minimax_model_id",
|
|
651
|
+
passed: false,
|
|
652
|
+
detail: `models.list() failed: ${err.message}`,
|
|
653
|
+
remediation: `verify ${OPEN_ROUTER_API_KEY_ENV} and network connectivity (OpenRouter endpoint: ${this.baseUrl})`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return checks;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=minimax-direct-sdk.js.map
|