opencode-cmd-provider 1.2.2 → 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.
@@ -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 { redactCommandCodeErrorText, commandCodeErrorMessage } from "./redact.js";
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
- const DEFAULT_BASE_URL = "https://api.commandcode.ai";
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 ?? DEFAULT_BASE_URL;
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
- const stream = this.runStream(this.bodyFor(options), this.headersFor(options), options.abortSignal, options, parts);
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
- const handleEvent = (event) => {
228
- if (!isRecord(event))
229
- return false;
230
- try {
231
- const parts = ccEventToStreamPart(event);
232
- let finished = false;
233
- for (const part of parts) {
234
- if (part.type === "finish") {
235
- finished = true;
236
- calculateCommandCodeCost(this.costForModel(), costUsageFromAiSdkUsage(part.usage));
237
- }
238
- emit(part);
239
- }
240
- return finished;
241
- }
242
- catch (streamError) {
243
- fail(streamError);
244
- return true;
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
- try {
281
- response = await fetchImpl(url, {
282
- method: "POST",
283
- headers,
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
- throw fetchError;
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
- if (attempt < maxRetries) {
306
- await response.text().catch(() => "");
307
- if (waitMs > 0)
308
- await delay(waitMs, controller.signal);
309
- continue retryLoop;
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
- if (!response.ok) {
313
- const errBody = await raceAttempt(response.text().catch(() => ""));
314
- let errorDetail;
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
- const parsedBody = JSON.parse(errBody);
317
- errorDetail = commandCodeErrorMessage(parsedBody);
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
- // Preserve useful plain-text provider errors only after secret
321
- // redaction; upstream/proxy bodies may echo credentials.
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
- const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500);
324
- const detail = redactCommandCodeErrorText(errorDetail ?? (safeBody || "Provider returned an error"));
325
- throw new Error(`Command Code API error ${response.status}: ${detail}`);
326
- }
327
- // --- Read response stream ---
328
- reader = response.body?.getReader();
329
- if (!reader)
330
- throw new Error("No response body");
331
- const decoder = new TextDecoder();
332
- let buffer = "";
333
- readLoop: for (;;) {
334
- if (controller.signal.aborted)
335
- throw abortError("Aborted");
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 (controller.signal.aborted)
343
- throw abortError("Aborted");
344
- buffer += decoder.decode(value, { stream: true });
345
- const lines = buffer.split("\n");
346
- buffer = lines.pop() ?? "";
347
- for (const line of lines) {
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
- if (handleEvent(parseStreamEventLine(line))) {
351
- finished = true;
352
- break readLoop;
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
- // Stream completed successfully.
357
- break retryLoop;
358
- }
359
- catch (streamError) {
360
- // Stream-level error (e.g. API returned 200 OK but sent an error
361
- // event) or per-attempt timeout during stream reading.
362
- await reader?.cancel().catch(() => { });
363
- try {
364
- reader?.releaseLock();
365
- }
366
- catch { }
367
- reader = undefined;
368
- if (controller.signal.aborted)
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
- if (attemptTimedOut)
380
- throw timeoutError(timeoutMs);
381
- throw streamError;
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
- finally {
384
- controller.signal.removeEventListener("abort", onOuterAbort2);
385
- clearAttemptTimeout();
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
- if (!finished) {
389
- // The server closed the stream without a finish event; the AI SDK
390
- // expects a finish part to terminate a stream.
391
- emit({
392
- type: "finish",
393
- finishReason: { unified: "stop", raw: "stop" },
394
- usage: {
395
- inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
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
- streamController.close();
641
+ finally {
642
+ signal?.removeEventListener("abort", onOuterAbort);
643
+ }
644
+ };
645
+ try {
646
+ await runTransport(descriptor);
401
647
  }
402
648
  catch (error) {
403
- if (controller.signal.aborted) {
404
- // Outer abort: emit a proper AbortError part (AI SDK contract).
405
- fail(abortError());
406
- }
407
- else {
408
- fail(error);
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
  });