opencode-cmd-provider 1.2.1 → 1.3.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/CHANGELOG.md +56 -0
- package/dist/src/catalog/facts.d.ts +4 -4
- package/dist/src/catalog/facts.js +8 -4
- package/dist/src/catalog/snapshot.js +1 -0
- package/dist/src/deals/catalog.d.ts +1 -1
- package/dist/src/deals/catalog.js +3 -1
- package/dist/src/deals/plan-summary.d.ts +33 -1
- package/dist/src/deals/plan-summary.js +35 -19
- package/dist/src/env.d.ts +9 -0
- package/dist/src/env.js +11 -0
- package/dist/src/provider/command-code-model.d.ts +49 -0
- package/dist/src/provider/command-code-model.js +419 -171
- package/dist/src/provider/converters.d.ts +18 -0
- package/dist/src/provider/converters.js +464 -3
- package/dist/src/provider/redact.d.ts +11 -0
- package/dist/src/provider/redact.js +38 -0
- package/dist/src/provider/stream.d.ts +6 -0
- package/dist/src/provider/stream.js +467 -3
- package/package.json +2 -2
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// surfaced error, v3 stream parts on the wire.
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { resolveApiKey } from "./auth-key.js";
|
|
9
|
-
import { messagesToCC, toolsToJson, systemPromptToText, getEnvironmentInfo, isRecord, } from "./converters.js";
|
|
10
|
-
import { parseStreamEventLine, ccEventToStreamPart } from "./stream.js";
|
|
11
|
-
import {
|
|
9
|
+
import { messagesToCC, messagesToAnthropic, messagesToOpenAI, toolsToJson, systemPromptToText, getEnvironmentInfo, isRecord, stringValue, } from "./converters.js";
|
|
10
|
+
import { parseStreamEventLine, ccEventToStreamPart, createOpenAIStreamParser, createAnthropicStreamParser, } from "./stream.js";
|
|
11
|
+
import { getApiBase, getCmdZdr } from "../env.js";
|
|
12
|
+
import { resolvePlan } from "../deals/plan-summary.js";
|
|
13
|
+
import { redactCommandCodeErrorText, commandCodeErrorMessage, isUpgradeRequiredError, } from "./redact.js";
|
|
12
14
|
import { calculateCommandCodeCost, costUsageFromAiSdkUsage } from "./cost.js";
|
|
13
15
|
import { ZERO_MODEL_COST, MODEL_COSTS } from "./pricing.js";
|
|
14
16
|
import { mappedReasoningEffort, resolveProviderReasoning, thinkingMetadataForModel, isReasoningModel, } from "./reasoning.js";
|
|
@@ -19,7 +21,28 @@ const COMMAND_CODE_CLI_VERSION = "1.15.1";
|
|
|
19
21
|
const DEFAULT_GENERATE_MAX_TOKENS = 64_000;
|
|
20
22
|
const DEFAULT_MAX_RETRIES = 0;
|
|
21
23
|
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
|
22
|
-
|
|
24
|
+
function isClaudeModel(modelId) {
|
|
25
|
+
return modelId.startsWith("claude-");
|
|
26
|
+
}
|
|
27
|
+
function planFromProviderOptions(providerOptions) {
|
|
28
|
+
if (!isRecord(providerOptions))
|
|
29
|
+
return undefined;
|
|
30
|
+
const top = stringValue(providerOptions.plan);
|
|
31
|
+
if (top)
|
|
32
|
+
return top;
|
|
33
|
+
const topEnv = stringValue(providerOptions.COMMANDCODE_PLAN);
|
|
34
|
+
if (topEnv)
|
|
35
|
+
return topEnv;
|
|
36
|
+
for (const key of ["commandcode", "commandCode", "cmd", "command-code"]) {
|
|
37
|
+
const ns = providerOptions[key];
|
|
38
|
+
if (isRecord(ns)) {
|
|
39
|
+
const v = stringValue(ns.plan) ?? stringValue(ns.COMMANDCODE_PLAN);
|
|
40
|
+
if (v)
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
23
46
|
function promptSystem(prompt) {
|
|
24
47
|
const system = prompt.filter((m) => m.role === "system").map((m) => m.content);
|
|
25
48
|
return system.length > 0 ? system.join("\n") : undefined;
|
|
@@ -32,6 +55,17 @@ function errorStream(message) {
|
|
|
32
55
|
},
|
|
33
56
|
});
|
|
34
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Internal marker (issue #56 safety net): the Provider API returned a
|
|
60
|
+
* documented `403 upgrade_required` ("You're on the Go plan, the only plan
|
|
61
|
+
* without API access"). The transport flips the session to the legacy
|
|
62
|
+
* `/alpha/generate` transport and retries once. Never surfaced to callers.
|
|
63
|
+
*/
|
|
64
|
+
class UpgradeRequiredError extends Error {
|
|
65
|
+
constructor() {
|
|
66
|
+
super("Command Code Provider API requires a plan upgrade (403 upgrade_required)");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
35
69
|
export class CommandCodeLanguageModel {
|
|
36
70
|
options;
|
|
37
71
|
specificationVersion = "v3";
|
|
@@ -45,11 +79,59 @@ export class CommandCodeLanguageModel {
|
|
|
45
79
|
this.modelId = modelId;
|
|
46
80
|
}
|
|
47
81
|
apiBase() {
|
|
48
|
-
return this.options.baseURL ??
|
|
82
|
+
return this.options.baseURL ?? getApiBase();
|
|
49
83
|
}
|
|
50
84
|
costForModel() {
|
|
51
85
|
return { cost: MODEL_COSTS[this.modelId] ?? ZERO_MODEL_COST };
|
|
52
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Per-instance whoami cache: the `GET /alpha/whoami` fetch happens at most
|
|
89
|
+
* once for the lifetime of this model instance and is reused across turns.
|
|
90
|
+
*/
|
|
91
|
+
planCache = {};
|
|
92
|
+
/**
|
|
93
|
+
* Safety-net flag (issue #56): once the Provider API answers a documented
|
|
94
|
+
* `403 upgrade_required`, the session is pinned to the legacy
|
|
95
|
+
* `/alpha/generate` transport for the lifetime of this model instance —
|
|
96
|
+
* subsequent turns stay on legacy without re-hitting the Provider API (no
|
|
97
|
+
* second 403). The Provider API has no path for Go-plan users (that is
|
|
98
|
+
* exactly what the 403 documents), so the plugin's legacy transport is the
|
|
99
|
+
* only way to keep serving a plan-detection miss that routed a true Go user
|
|
100
|
+
* there.
|
|
101
|
+
*/
|
|
102
|
+
pinnedToLegacy = false;
|
|
103
|
+
/**
|
|
104
|
+
* Resolves the transport plan through the shared plan-resolution seam:
|
|
105
|
+
* explicit override (providerOptions plan, model option `plan`) →
|
|
106
|
+
* COMMANDCODE_PLAN env → cached whoami → default Provider API. Only a
|
|
107
|
+
* resolved `go` selects the legacy transport; every other resolution
|
|
108
|
+
* selects the Provider API. The whoami fetch is cached for the lifetime of
|
|
109
|
+
* this instance (see planCache) and honours the same resolved key, base URL
|
|
110
|
+
* and injected fetch as inference.
|
|
111
|
+
*/
|
|
112
|
+
async shouldUseProviderTransport(options) {
|
|
113
|
+
if (this.pinnedToLegacy)
|
|
114
|
+
return false;
|
|
115
|
+
const plan = await resolvePlan(this.planArgFor(options), process.env, {
|
|
116
|
+
defaultPlan: "provider",
|
|
117
|
+
cache: this.planCache,
|
|
118
|
+
apiKey: resolveApiKey({
|
|
119
|
+
apiKey: this.options.apiKey,
|
|
120
|
+
authPaths: this.options.authPaths,
|
|
121
|
+
}),
|
|
122
|
+
baseURL: this.options.baseURL,
|
|
123
|
+
fetch: this.options.fetch,
|
|
124
|
+
});
|
|
125
|
+
return plan !== "go";
|
|
126
|
+
}
|
|
127
|
+
planArgFor(options) {
|
|
128
|
+
return planFromProviderOptions(options.providerOptions) ?? this.options.plan;
|
|
129
|
+
}
|
|
130
|
+
providerEndpoint() {
|
|
131
|
+
return isClaudeModel(this.modelId)
|
|
132
|
+
? `${this.apiBase()}/provider/v1/messages`
|
|
133
|
+
: `${this.apiBase()}/provider/v1/chat/completions`;
|
|
134
|
+
}
|
|
53
135
|
async doGenerate(options) {
|
|
54
136
|
const { parts, error } = await this.runOnce(options);
|
|
55
137
|
if (error)
|
|
@@ -98,6 +180,14 @@ export class CommandCodeLanguageModel {
|
|
|
98
180
|
return { content, finishReason, usage, warnings: [] };
|
|
99
181
|
}
|
|
100
182
|
async doStream(options) {
|
|
183
|
+
if (await this.shouldUseProviderTransport(options)) {
|
|
184
|
+
const isClaude = isClaudeModel(this.modelId);
|
|
185
|
+
const body = this.providerBodyFor(options, isClaude);
|
|
186
|
+
const headers = this.providerHeadersFor(options);
|
|
187
|
+
return {
|
|
188
|
+
stream: this.providerRunStream(body, headers, options.abortSignal, options, isClaude),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
101
191
|
return {
|
|
102
192
|
stream: this.runStream(this.bodyFor(options), this.headersFor(options), options.abortSignal, options),
|
|
103
193
|
};
|
|
@@ -119,7 +209,16 @@ export class CommandCodeLanguageModel {
|
|
|
119
209
|
};
|
|
120
210
|
}
|
|
121
211
|
const parts = [];
|
|
122
|
-
|
|
212
|
+
let stream;
|
|
213
|
+
if (await this.shouldUseProviderTransport(options)) {
|
|
214
|
+
const isClaude = isClaudeModel(this.modelId);
|
|
215
|
+
const body = this.providerBodyFor(options, isClaude);
|
|
216
|
+
const headers = this.providerHeadersFor(options);
|
|
217
|
+
stream = this.providerRunStream(body, headers, options.abortSignal, options, isClaude, parts);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
stream = this.runStream(this.bodyFor(options), this.headersFor(options), options.abortSignal, options, parts);
|
|
221
|
+
}
|
|
123
222
|
const reader = stream.getReader();
|
|
124
223
|
for (;;) {
|
|
125
224
|
const { done } = await reader.read();
|
|
@@ -193,13 +292,101 @@ export class CommandCodeLanguageModel {
|
|
|
193
292
|
...(options.headers ?? {}),
|
|
194
293
|
};
|
|
195
294
|
}
|
|
295
|
+
providerBodyFor(options, isClaude) {
|
|
296
|
+
const allowImages = modelSupportsImageInput(this.modelId);
|
|
297
|
+
if (isClaude) {
|
|
298
|
+
return messagesToAnthropic(options.prompt, {
|
|
299
|
+
model: this.modelId,
|
|
300
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
301
|
+
providerOptions: options.providerOptions,
|
|
302
|
+
tools: options.tools,
|
|
303
|
+
allowImages,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return messagesToOpenAI(options.prompt, {
|
|
307
|
+
model: this.modelId,
|
|
308
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
309
|
+
providerOptions: options.providerOptions,
|
|
310
|
+
tools: options.tools,
|
|
311
|
+
allowImages,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
providerHeadersFor(options) {
|
|
315
|
+
const apiKey = resolveApiKey({
|
|
316
|
+
apiKey: this.options.apiKey,
|
|
317
|
+
authPaths: this.options.authPaths,
|
|
318
|
+
});
|
|
319
|
+
const headers = {
|
|
320
|
+
"Content-Type": "application/json",
|
|
321
|
+
Authorization: `Bearer ${apiKey ?? ""}`,
|
|
322
|
+
...this.options.headers,
|
|
323
|
+
...(options.headers ?? {}),
|
|
324
|
+
};
|
|
325
|
+
// ZDR passthrough (issue #57): the Provider API honours the CLI's own
|
|
326
|
+
// opt-in — CMD_ZDR=1 → every Provider API request carries x-cmd-zdr: 1
|
|
327
|
+
// (https://commandcode.ai/docs/provider "Zero data retention (ZDR)").
|
|
328
|
+
// Only the exact value "1" opts in; the legacy /alpha/generate transport
|
|
329
|
+
// never sends the header (headersFor is untouched). The header's presence
|
|
330
|
+
// is owned solely by the env opt-in, not by caller-supplied headers: with
|
|
331
|
+
// CMD_ZDR=1 the value is forced to "1", and with it off any caller-supplied
|
|
332
|
+
// x-cmd-zdr (any casing) is stripped so a non-opted-in session never emits
|
|
333
|
+
// ZDR.
|
|
334
|
+
for (const key of Object.keys(headers)) {
|
|
335
|
+
if (key.toLowerCase() === "x-cmd-zdr")
|
|
336
|
+
delete headers[key];
|
|
337
|
+
}
|
|
338
|
+
if (getCmdZdr())
|
|
339
|
+
headers["x-cmd-zdr"] = "1";
|
|
340
|
+
return headers;
|
|
341
|
+
}
|
|
342
|
+
providerRunStream(body, headers, signal, options, isClaude, sink) {
|
|
343
|
+
const url = this.providerEndpoint();
|
|
344
|
+
const bodyStr = JSON.stringify(body);
|
|
345
|
+
// Per-stream stateful parsers complete tool calls whose arguments arrive
|
|
346
|
+
// across multiple SSE events (issue #55 tool-call parity); the stateless
|
|
347
|
+
// mappers are kept for direct codec use.
|
|
348
|
+
const parser = isClaude
|
|
349
|
+
? createAnthropicStreamParser()
|
|
350
|
+
: createOpenAIStreamParser();
|
|
351
|
+
// Safety net (issue #56): a documented `403 upgrade_required` pins this
|
|
352
|
+
// session to the legacy transport and retries the same call once via
|
|
353
|
+
// POST {base}/alpha/generate with the legacy CLI wire format. The legacy
|
|
354
|
+
// descriptor itself never flips, so the retry is bounded to one.
|
|
355
|
+
const legacyFallback = {
|
|
356
|
+
url: `${this.apiBase()}/alpha/generate`,
|
|
357
|
+
bodyStr: JSON.stringify(this.bodyFor(options)),
|
|
358
|
+
headers: this.headersFor(options),
|
|
359
|
+
eventToParts: ccEventToStreamPart,
|
|
360
|
+
flipOnUpgradeRequired: false,
|
|
361
|
+
};
|
|
362
|
+
return this.transportStream({ url, bodyStr, headers, eventToParts: parser, flipOnUpgradeRequired: true }, signal, sink, legacyFallback);
|
|
363
|
+
}
|
|
196
364
|
runStream(body, headers, signal, options, sink) {
|
|
365
|
+
const url = `${this.apiBase()}/alpha/generate`;
|
|
366
|
+
const bodyStr = JSON.stringify(body);
|
|
367
|
+
return this.transportStream({ url, bodyStr, headers, eventToParts: ccEventToStreamPart, flipOnUpgradeRequired: false }, signal, sink);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Deep internal seam: single SSE transport behind a small interface.
|
|
371
|
+
* All retry/timeout/abort/redaction/stream-parsing/cost/fallback logic
|
|
372
|
+
* lives here; callers supply only the endpoint URL, body, headers and
|
|
373
|
+
* the event→parts mapper. Depth gives leverage (N callers) and locality
|
|
374
|
+
* (fix once, fixed everywhere). The eventToParts adapter varies across
|
|
375
|
+
* the seam (CC vs OpenAI vs Anthropic) while the transport stays fixed.
|
|
376
|
+
*
|
|
377
|
+
* The optional legacyFallback implements the issue #56 safety net: when the
|
|
378
|
+
* Provider API answers a documented `403 upgrade_required` (Go plan, no API
|
|
379
|
+
* access), the session is pinned to the legacy `/alpha/generate` transport
|
|
380
|
+
* and the same call retries once there — the retry is bounded because only
|
|
381
|
+
* the provider descriptor carries flipOnUpgradeRequired. The pin is sticky
|
|
382
|
+
* for the lifetime of this model instance (no second Provider API hit on
|
|
383
|
+
* later turns).
|
|
384
|
+
*/
|
|
385
|
+
transportStream(descriptor, signal, sink, legacyFallback) {
|
|
197
386
|
const timeoutMs = this.options.timeout;
|
|
198
387
|
const maxRetries = this.options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
199
388
|
const maxRetryDelayMs = this.options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
200
389
|
const fetchImpl = this.options.fetch ?? fetch;
|
|
201
|
-
const url = `${this.apiBase()}/alpha/generate`;
|
|
202
|
-
const bodyStr = JSON.stringify(body);
|
|
203
390
|
return new ReadableStream({
|
|
204
391
|
start: async (streamController) => {
|
|
205
392
|
const emit = (part) => {
|
|
@@ -224,192 +411,253 @@ export class CommandCodeLanguageModel {
|
|
|
224
411
|
fail("No Command Code API key. Run /connect and select Command Code, set the COMMANDCODE_API_KEY env var, or configure an auth file.");
|
|
225
412
|
return;
|
|
226
413
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
let reader;
|
|
248
|
-
const controller = new AbortController();
|
|
249
|
-
const onOuterAbort = () => controller.abort();
|
|
250
|
-
try {
|
|
251
|
-
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
252
|
-
if (signal?.aborted)
|
|
253
|
-
throw abortError("Aborted");
|
|
254
|
-
let response;
|
|
255
|
-
let finished = false;
|
|
256
|
-
retryLoop: for (let attempt = 0;; attempt++) {
|
|
257
|
-
const attemptController = new AbortController();
|
|
258
|
-
let attemptTimedOut = false;
|
|
259
|
-
let attemptTimeoutId;
|
|
260
|
-
const clearAttemptTimeout = () => {
|
|
261
|
-
if (attemptTimeoutId !== undefined) {
|
|
262
|
-
clearTimeout(attemptTimeoutId);
|
|
263
|
-
attemptTimeoutId = undefined;
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
if (timeoutMs !== undefined) {
|
|
267
|
-
attemptTimeoutId = setTimeout(() => {
|
|
268
|
-
attemptTimedOut = true;
|
|
269
|
-
attemptController.abort();
|
|
270
|
-
}, timeoutMs);
|
|
271
|
-
}
|
|
272
|
-
const onOuterAbort2 = () => attemptController.abort();
|
|
273
|
-
controller.signal.addEventListener("abort", onOuterAbort2, { once: true });
|
|
274
|
-
const raceAttempt = (promise) => raceAbort(promise, attemptController.signal).catch((error) => {
|
|
275
|
-
if (attemptTimedOut)
|
|
276
|
-
throw timeoutError(timeoutMs);
|
|
277
|
-
throw error;
|
|
278
|
-
});
|
|
414
|
+
/**
|
|
415
|
+
* Runs one full request/read pass against a transport descriptor.
|
|
416
|
+
* Emits parts as they arrive and closes the stream on success or on
|
|
417
|
+
* an outer abort; any other error is rethrown so the caller decides
|
|
418
|
+
* (upgrade fallback vs. surface as an error part).
|
|
419
|
+
*/
|
|
420
|
+
const runTransport = async (t) => {
|
|
421
|
+
/**
|
|
422
|
+
* The single `finish` part is held back and emitted only after the
|
|
423
|
+
* response body is fully drained. OpenAI-style Provider streams send
|
|
424
|
+
* `finish_reason` on the last content chunk and the real `usage` on
|
|
425
|
+
* a *separate* trailing usage-only chunk (choices:[]); emitting the
|
|
426
|
+
* finish as soon as a finish_reason chunk is seen would drop that
|
|
427
|
+
* trailing usage and report zeroed usage/cost. Holding the finish
|
|
428
|
+
* lets a later usage-bearing finish replace the earlier one.
|
|
429
|
+
*/
|
|
430
|
+
let heldFinish;
|
|
431
|
+
const handleEvent = (event) => {
|
|
432
|
+
if (!isRecord(event))
|
|
433
|
+
return false;
|
|
279
434
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
body: bodyStr,
|
|
285
|
-
signal: attemptController.signal,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
catch (fetchError) {
|
|
289
|
-
if (controller.signal.aborted)
|
|
290
|
-
throw abortError("Aborted");
|
|
291
|
-
if (attemptTimedOut) {
|
|
292
|
-
if (attempt < maxRetries)
|
|
293
|
-
continue retryLoop;
|
|
294
|
-
throw timeoutError(timeoutMs);
|
|
435
|
+
const parts = t.eventToParts(event);
|
|
436
|
+
for (const part of parts) {
|
|
437
|
+
if (part.type === "finish") {
|
|
438
|
+
heldFinish = part;
|
|
295
439
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// --- HTTP-level retry ---
|
|
299
|
-
if (!response.ok && isRetryableStatus(response.status)) {
|
|
300
|
-
const retryAfter = response.headers.get("retry-after");
|
|
301
|
-
const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs);
|
|
302
|
-
if (waitMs < 0) {
|
|
303
|
-
throw new Error(`Command Code API error ${response.status}: Retry-After delay exceeds max retry delay`);
|
|
440
|
+
else {
|
|
441
|
+
emit(part);
|
|
304
442
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
443
|
+
}
|
|
444
|
+
return heldFinish !== undefined;
|
|
445
|
+
}
|
|
446
|
+
catch (streamError) {
|
|
447
|
+
fail(streamError);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
let reader;
|
|
452
|
+
const controller = new AbortController();
|
|
453
|
+
const onOuterAbort = () => controller.abort();
|
|
454
|
+
try {
|
|
455
|
+
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
456
|
+
if (signal?.aborted)
|
|
457
|
+
throw abortError("Aborted");
|
|
458
|
+
let response;
|
|
459
|
+
let finished = false;
|
|
460
|
+
retryLoop: for (let attempt = 0;; attempt++) {
|
|
461
|
+
const attemptController = new AbortController();
|
|
462
|
+
let attemptTimedOut = false;
|
|
463
|
+
let attemptTimeoutId;
|
|
464
|
+
const clearAttemptTimeout = () => {
|
|
465
|
+
if (attemptTimeoutId !== undefined) {
|
|
466
|
+
clearTimeout(attemptTimeoutId);
|
|
467
|
+
attemptTimeoutId = undefined;
|
|
310
468
|
}
|
|
469
|
+
};
|
|
470
|
+
if (timeoutMs !== undefined) {
|
|
471
|
+
attemptTimeoutId = setTimeout(() => {
|
|
472
|
+
attemptTimedOut = true;
|
|
473
|
+
attemptController.abort();
|
|
474
|
+
}, timeoutMs);
|
|
311
475
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
476
|
+
const onOuterAbort2 = () => attemptController.abort();
|
|
477
|
+
controller.signal.addEventListener("abort", onOuterAbort2, { once: true });
|
|
478
|
+
const raceAttempt = (promise) => raceAbort(promise, attemptController.signal).catch((error) => {
|
|
479
|
+
if (attemptTimedOut)
|
|
480
|
+
throw timeoutError(timeoutMs);
|
|
481
|
+
throw error;
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
315
484
|
try {
|
|
316
|
-
|
|
317
|
-
|
|
485
|
+
response = await fetchImpl(t.url, {
|
|
486
|
+
method: "POST",
|
|
487
|
+
headers: t.headers,
|
|
488
|
+
body: t.bodyStr,
|
|
489
|
+
signal: attemptController.signal,
|
|
490
|
+
});
|
|
318
491
|
}
|
|
319
|
-
catch {
|
|
320
|
-
|
|
321
|
-
|
|
492
|
+
catch (fetchError) {
|
|
493
|
+
if (controller.signal.aborted)
|
|
494
|
+
throw abortError("Aborted");
|
|
495
|
+
if (attemptTimedOut) {
|
|
496
|
+
if (attempt < maxRetries)
|
|
497
|
+
continue retryLoop;
|
|
498
|
+
throw timeoutError(timeoutMs);
|
|
499
|
+
}
|
|
500
|
+
throw fetchError;
|
|
322
501
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const { done, value } = await raceAbort(reader.read(), attemptController.signal);
|
|
337
|
-
if (done) {
|
|
338
|
-
if (buffer.trim())
|
|
339
|
-
handleEvent(parseStreamEventLine(buffer));
|
|
340
|
-
break;
|
|
502
|
+
// --- HTTP-level retry ---
|
|
503
|
+
if (!response.ok && isRetryableStatus(response.status)) {
|
|
504
|
+
const retryAfter = response.headers.get("retry-after");
|
|
505
|
+
const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs);
|
|
506
|
+
if (waitMs < 0) {
|
|
507
|
+
throw new Error(`Command Code API error ${response.status}: Retry-After delay exceeds max retry delay`);
|
|
508
|
+
}
|
|
509
|
+
if (attempt < maxRetries) {
|
|
510
|
+
await response.text().catch(() => "");
|
|
511
|
+
if (waitMs > 0)
|
|
512
|
+
await delay(waitMs, controller.signal);
|
|
513
|
+
continue retryLoop;
|
|
514
|
+
}
|
|
341
515
|
}
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
const errBody = await raceAttempt(response.text().catch(() => ""));
|
|
518
|
+
let parsedBody;
|
|
519
|
+
let errorDetail;
|
|
520
|
+
try {
|
|
521
|
+
parsedBody = JSON.parse(errBody);
|
|
522
|
+
errorDetail = commandCodeErrorMessage(parsedBody);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// Preserve useful plain-text provider errors only after secret
|
|
526
|
+
// redaction; upstream/proxy bodies may echo credentials.
|
|
527
|
+
}
|
|
528
|
+
// Safety net (issue #56): a documented 403 upgrade_required
|
|
529
|
+
// on the Provider API flips the session to the legacy
|
|
530
|
+
// transport; the legacy descriptor itself never flips (so
|
|
531
|
+
// the retry is bounded to one), and any other status flows
|
|
532
|
+
// through the existing error/redaction pipeline unchanged.
|
|
533
|
+
if (t.flipOnUpgradeRequired &&
|
|
534
|
+
isUpgradeRequiredError(response.status, parsedBody ?? errBody)) {
|
|
535
|
+
throw new UpgradeRequiredError();
|
|
536
|
+
}
|
|
537
|
+
const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500);
|
|
538
|
+
const detail = redactCommandCodeErrorText(errorDetail ?? (safeBody || "Provider returned an error"));
|
|
539
|
+
throw new Error(`Command Code API error ${response.status}: ${detail}`);
|
|
540
|
+
}
|
|
541
|
+
// --- Read response stream ---
|
|
542
|
+
reader = response.body?.getReader();
|
|
543
|
+
if (!reader)
|
|
544
|
+
throw new Error("No response body");
|
|
545
|
+
const decoder = new TextDecoder();
|
|
546
|
+
let buffer = "";
|
|
547
|
+
readLoop: for (;;) {
|
|
348
548
|
if (controller.signal.aborted)
|
|
349
549
|
throw abortError("Aborted");
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
550
|
+
const { done, value } = await raceAbort(reader.read(), attemptController.signal);
|
|
551
|
+
if (done) {
|
|
552
|
+
if (buffer.trim()) {
|
|
553
|
+
if (handleEvent(parseStreamEventLine(buffer)))
|
|
554
|
+
finished = true;
|
|
555
|
+
}
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
if (controller.signal.aborted)
|
|
559
|
+
throw abortError("Aborted");
|
|
560
|
+
buffer += decoder.decode(value, { stream: true });
|
|
561
|
+
const lines = buffer.split("\n");
|
|
562
|
+
buffer = lines.pop() ?? "";
|
|
563
|
+
for (const line of lines) {
|
|
564
|
+
if (controller.signal.aborted)
|
|
565
|
+
throw abortError("Aborted");
|
|
566
|
+
if (handleEvent(parseStreamEventLine(line)))
|
|
567
|
+
finished = true;
|
|
568
|
+
// Do NOT break on a finish event: an OpenAI Provider stream
|
|
569
|
+
// may send the terminal `usage`-only chunk (choices:[]) after
|
|
570
|
+
// a finish_reason chunk. Keep draining so heldFinish is
|
|
571
|
+
// replaced with the usage-bearing finish before we emit it.
|
|
353
572
|
}
|
|
354
573
|
}
|
|
574
|
+
// Stream completed successfully.
|
|
575
|
+
break retryLoop;
|
|
355
576
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
reader
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
577
|
+
catch (streamError) {
|
|
578
|
+
// Stream-level error (e.g. API returned 200 OK but sent an error
|
|
579
|
+
// event) or per-attempt timeout during stream reading.
|
|
580
|
+
await reader?.cancel().catch(() => { });
|
|
581
|
+
try {
|
|
582
|
+
reader?.releaseLock();
|
|
583
|
+
}
|
|
584
|
+
catch { }
|
|
585
|
+
reader = undefined;
|
|
586
|
+
// 403 upgrade_required is a transport flip, never a retry:
|
|
587
|
+
// fall back to the legacy transport immediately (issue #56),
|
|
588
|
+
// regardless of maxRetries.
|
|
589
|
+
if (streamError instanceof UpgradeRequiredError)
|
|
590
|
+
throw streamError;
|
|
591
|
+
if (controller.signal.aborted)
|
|
592
|
+
throw streamError;
|
|
593
|
+
// Never retry after visible content was emitted (including timeout mid-stream).
|
|
594
|
+
const canRetry = !finished && attempt < maxRetries;
|
|
595
|
+
if (canRetry) {
|
|
596
|
+
finished = false;
|
|
597
|
+
const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs);
|
|
598
|
+
if (waitMs > 0)
|
|
599
|
+
await delay(waitMs, controller.signal);
|
|
600
|
+
continue retryLoop;
|
|
601
|
+
}
|
|
602
|
+
if (attemptTimedOut)
|
|
603
|
+
throw timeoutError(timeoutMs);
|
|
369
604
|
throw streamError;
|
|
370
|
-
// Never retry after visible content was emitted (including timeout mid-stream).
|
|
371
|
-
const canRetry = !finished && attempt < maxRetries;
|
|
372
|
-
if (canRetry) {
|
|
373
|
-
finished = false;
|
|
374
|
-
const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs);
|
|
375
|
-
if (waitMs > 0)
|
|
376
|
-
await delay(waitMs, controller.signal);
|
|
377
|
-
continue retryLoop;
|
|
378
605
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
606
|
+
finally {
|
|
607
|
+
controller.signal.removeEventListener("abort", onOuterAbort2);
|
|
608
|
+
clearAttemptTimeout();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (heldFinish) {
|
|
612
|
+
// The finish part is emitted after the body is fully drained so
|
|
613
|
+
// the terminal usage chunk (OpenAI: separate usage-only chunk;
|
|
614
|
+
// Anthropic: message_delta) is incorporated.
|
|
615
|
+
calculateCommandCodeCost(this.costForModel(), costUsageFromAiSdkUsage(heldFinish.usage));
|
|
616
|
+
emit(heldFinish);
|
|
382
617
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
618
|
+
else if (!finished) {
|
|
619
|
+
// The server closed the stream without a finish event; the AI SDK
|
|
620
|
+
// expects a finish part to terminate a stream.
|
|
621
|
+
emit({
|
|
622
|
+
type: "finish",
|
|
623
|
+
finishReason: { unified: "stop", raw: "stop" },
|
|
624
|
+
usage: {
|
|
625
|
+
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
|
626
|
+
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
|
627
|
+
},
|
|
628
|
+
});
|
|
386
629
|
}
|
|
630
|
+
streamController.close();
|
|
387
631
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
|
397
|
-
},
|
|
398
|
-
});
|
|
632
|
+
catch (error) {
|
|
633
|
+
if (controller.signal.aborted) {
|
|
634
|
+
// Outer abort: emit a proper AbortError part (AI SDK contract).
|
|
635
|
+
fail(abortError());
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
399
640
|
}
|
|
400
|
-
|
|
641
|
+
finally {
|
|
642
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
try {
|
|
646
|
+
await runTransport(descriptor);
|
|
401
647
|
}
|
|
402
648
|
catch (error) {
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
649
|
+
if (legacyFallback && error instanceof UpgradeRequiredError) {
|
|
650
|
+
this.pinnedToLegacy = true;
|
|
651
|
+
try {
|
|
652
|
+
await runTransport(legacyFallback);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
catch (fallbackError) {
|
|
656
|
+
fail(fallbackError);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
409
659
|
}
|
|
410
|
-
|
|
411
|
-
finally {
|
|
412
|
-
signal?.removeEventListener("abort", onOuterAbort);
|
|
660
|
+
fail(error);
|
|
413
661
|
}
|
|
414
662
|
},
|
|
415
663
|
});
|