@oh-my-pi/pi-ai 18.6.0 → 18.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -38
  2. package/README.md +15 -0
  3. package/dist/types/auth/types.d.ts +4 -0
  4. package/dist/types/auth-broker/refresher.d.ts +6 -1
  5. package/dist/types/error/flags.d.ts +15 -0
  6. package/dist/types/providers/anthropic.d.ts +15 -2
  7. package/dist/types/providers/openai-responses.d.ts +32 -3
  8. package/dist/types/registry/oauth/snowflake.d.ts +3 -0
  9. package/dist/types/registry/snowflake.d.ts +4 -0
  10. package/dist/types/types.d.ts +40 -3
  11. package/dist/types/utils/idle-iterator.d.ts +11 -0
  12. package/dist/types/utils/stream-markup-healing.d.ts +4 -4
  13. package/dist/types/utils.d.ts +6 -0
  14. package/package.json +6 -6
  15. package/src/auth/cascade.ts +6 -2
  16. package/src/auth/health.ts +1 -0
  17. package/src/auth/select.ts +3 -1
  18. package/src/auth/types.ts +4 -0
  19. package/src/auth-broker/refresher.ts +8 -1
  20. package/src/dialect/deepseek.ts +24 -3
  21. package/src/error/flags.ts +22 -0
  22. package/src/providers/anthropic.ts +111 -45
  23. package/src/providers/devin.ts +3 -1
  24. package/src/providers/factory-droid.ts +2 -0
  25. package/src/providers/gitlab-duo.ts +1 -0
  26. package/src/providers/openai-anthropic-shim.ts +1 -0
  27. package/src/providers/openai-codex-responses.ts +14 -11
  28. package/src/providers/openai-completions.ts +3 -0
  29. package/src/providers/openai-responses.ts +349 -6
  30. package/src/providers/openai-shared.ts +39 -16
  31. package/src/providers/pi-native-server.ts +1 -0
  32. package/src/registry/hooks/custom.ts +2 -0
  33. package/src/registry/oauth/snowflake.ts +183 -0
  34. package/src/registry/registry.ts +2 -0
  35. package/src/registry/snowflake.ts +149 -0
  36. package/src/stream.ts +35 -33
  37. package/src/types.ts +41 -3
  38. package/src/usage/claude.ts +3 -0
  39. package/src/utils/idle-iterator.ts +80 -35
  40. package/src/utils/stream-markup-healing.ts +4 -4
  41. package/src/utils/thinking-loop.ts +18 -10
  42. package/src/utils.ts +19 -4
@@ -187,6 +187,25 @@ export function isResponsesRequestBodyReadTimeout(message: {
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * Codex code for the experimental native turn lane refusing a mid-response
192
+ * `response.steer`; the server then drops the in-flight response and closes
193
+ * the socket.
194
+ */
195
+ export const CODEX_NATIVE_LANE_STEER_REJECTED_CODE = "unsupported_native_inflight_message";
196
+
197
+ /**
198
+ * A Codex turn the native turn lane dropped because omp steered it. The
199
+ * rejection answers our own `response.steer`, not the model's health: the
200
+ * provider stops steering the session, so the same model replays cleanly.
201
+ */
202
+ export function isCodexSteerRejection(message: { api?: Api; errorMessage?: string }): boolean {
203
+ return (
204
+ message.api === "openai-codex-responses" &&
205
+ message.errorMessage?.includes(`code=${CODEX_NATIVE_LANE_STEER_REJECTED_CODE}`) === true
206
+ );
207
+ }
208
+
190
209
  export const TRANSIENT_TRANSPORT_PATTERN =
191
210
  /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
192
211
  const AUTH_FAILURE_PATTERN =
@@ -586,6 +605,9 @@ function classifyText(
586
605
  if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) {
587
606
  kinds |= Flag.StaleResponsesItem;
588
607
  }
608
+ // Retryable like the provider's own classification of the code, so a
609
+ // message reclassified from its text alone still retries.
610
+ if (isCodexSteerRejection({ api, errorMessage })) kinds |= Flag.Transient;
589
611
 
590
612
  // Fireworks mid-generation NaN 400 is a model-side decode fault, not a bad
591
613
  // request; a byte-identical replay succeeds, so treat it as transient.
@@ -3,6 +3,7 @@ import { scheduler } from "node:timers/promises";
3
3
  import * as tls from "node:tls";
4
4
  import { isAnthropicSigningProxyUrl, isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
5
5
  import { hostMatchesUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
6
+ import type { Effort } from "@oh-my-pi/pi-catalog/effort";
6
7
  import { mapEffortToAnthropicAdaptiveEffort } from "@oh-my-pi/pi-catalog/model-thinking";
7
8
  import { calculateCost, getBundledModel } from "@oh-my-pi/pi-catalog/models";
8
9
  import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
@@ -21,7 +22,9 @@ import * as AIError from "../error";
21
22
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
22
23
  import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
23
24
  import type {
25
+ AnthropicCompactionFiles,
24
26
  AnthropicCompactionPayload,
27
+ AnthropicCompactionRequest,
25
28
  AnthropicFallbackContent,
26
29
  AnthropicMessagePayload,
27
30
  AnthropicOutputEffort,
@@ -646,7 +649,7 @@ const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
646
649
  function createClaudeBillingHeader(firstUserMessageText: string): string {
647
650
  // Fingerprint: SHA256(salt + msg[4] + msg[7] + msg[20] + version)[:3]
648
651
  // Matches CC's computeFingerprint in utils/fingerprint.ts.
649
- // Uses chars from the first user message (not the system prompt).
652
+ // Uses chars from the first conversational wire user turn (not the system prompt).
650
653
  const k = [4, 7, 20].map(i => firstUserMessageText[i] ?? "0").join("");
651
654
  const version = getClaudeCodeVersion();
652
655
  const versionSuffix = Bun.SHA256.hash(`59cf53e54c78${k}${version}`, "hex").slice(0, 3);
@@ -3498,7 +3501,7 @@ export type AnthropicSystemBlock = {
3498
3501
  type SystemBlockOptions = {
3499
3502
  includeClaudeCodeInstruction?: boolean;
3500
3503
  extraInstructions?: string[];
3501
- /** Text of the first user message — used as fingerprint seed for the billing header. */
3504
+ /** Text of the first conversational wire user turn, used as the billing fingerprint seed. */
3502
3505
  firstUserMessageText?: string;
3503
3506
  /** Cache lifetime shared by the OAuth system breakpoint and later message breakpoints. */
3504
3507
  cacheControl?: AnthropicCacheControl;
@@ -4185,16 +4188,19 @@ function resolveAnthropicAdaptiveEffort(
4185
4188
  return mapEffortToAnthropicAdaptiveEffort(model, requestedEffort);
4186
4189
  }
4187
4190
 
4188
- function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): string {
4191
+ function extractClaudeCodeFirstWireUserMessageText(messages: readonly Message[]): string {
4189
4192
  for (const message of messages) {
4190
- if (message.role !== "user") continue;
4193
+ if ((message.role !== "user" && message.role !== "developer") || anthropicControlOf(message)) continue;
4191
4194
  const { content } = message;
4192
- if (typeof content === "string") return content;
4193
- if (!Array.isArray(content)) return "";
4195
+ if (typeof content === "string") {
4196
+ if (content.trim()) return content;
4197
+ continue;
4198
+ }
4199
+ if (!Array.isArray(content)) continue;
4194
4200
  for (const block of content) {
4195
- if (block.type === "text") return block.text;
4201
+ if (block.type === "text" && block.text.trim()) return block.text;
4196
4202
  }
4197
- return "";
4203
+ if (content.some(block => block.type === "image")) return "";
4198
4204
  }
4199
4205
  return "";
4200
4206
  }
@@ -4306,11 +4312,16 @@ function diffAnthropicActiveTools(
4306
4312
  * at the tail. Records at a different index than they were written at are
4307
4313
  * rewritten history: they keep the declaration but emit no controls, so the
4308
4314
  * net change from the declared baseline lands at the first live position.
4315
+ * Without `ownChange` (an on-demand compaction request, which ends inside the
4316
+ * conversation) the tail control is left out: there it would sit between the
4317
+ * summarized prefix and the retained turns, where live requests never sent
4318
+ * it, and invalidate their signed thinking. The next live turn sends it.
4309
4319
  */
4310
4320
  function planAnthropicToolControls(
4311
4321
  context: Context,
4312
4322
  records: readonly AnthropicControlRecord[],
4313
4323
  enabled: boolean,
4324
+ ownChange: boolean,
4314
4325
  ): { tools: Tool[] | undefined; inserts: AnthropicControlInsert[]; record: AnthropicToolControls | undefined } {
4315
4326
  if (!enabled || !context.tools) return { tools: context.tools, inserts: [], record: undefined };
4316
4327
  const definitions = new Map<string, Tool>();
@@ -4353,7 +4364,7 @@ function planAnthropicToolControls(
4353
4364
  if (toolChanges.length > 0) inserts.push({ index: record.index, spec: { toolChanges } });
4354
4365
  previous = record.tools.active;
4355
4366
  }
4356
- const toolChanges = diffAnthropicActiveTools(previous, activeNames, declaredSet);
4367
+ const toolChanges = ownChange ? diffAnthropicActiveTools(previous, activeNames, declaredSet) : [];
4357
4368
  if (toolChanges.length > 0) inserts.push({ index: context.messages.length, spec: { toolChanges } });
4358
4369
  }
4359
4370
 
@@ -4380,7 +4391,8 @@ function planAnthropicToolControls(
4380
4391
  * restore, so a request without an explicit effort keeps the level in force.
4381
4392
  * Records at a different index than they were written at are rewritten history:
4382
4393
  * they keep the top-level effort but emit no controls, so the net change lands
4383
- * at the first live position.
4394
+ * at the first live position. Without `ownChange` (an on-demand compaction
4395
+ * request) `current` is not applied: the request runs at the effort in force.
4384
4396
  */
4385
4397
  function planAnthropicEffortControls(
4386
4398
  current: AnthropicOutputEffort | undefined,
@@ -4388,6 +4400,7 @@ function planAnthropicEffortControls(
4388
4400
  records: readonly AnthropicControlRecord[],
4389
4401
  enabled: boolean,
4390
4402
  compactionReplay: AnthropicCompactionReplay | undefined,
4403
+ ownChange: boolean,
4391
4404
  ): {
4392
4405
  topLevel: AnthropicOutputEffort | undefined;
4393
4406
  inserts: AnthropicControlInsert[];
@@ -4418,7 +4431,7 @@ function planAnthropicEffortControls(
4418
4431
  }
4419
4432
  tail = recorded ?? tail;
4420
4433
  }
4421
- if (current !== undefined && current !== tail) {
4434
+ if (ownChange && current !== undefined && current !== tail) {
4422
4435
  inserts.push({
4423
4436
  index: anthropicEffortInsertIndex(messages, messages.length, compactionReplay),
4424
4437
  spec: { toolChanges: [], effort: current },
@@ -4428,6 +4441,32 @@ function planAnthropicEffortControls(
4428
4441
  return { topLevel, inserts, record: { topLevel: topLevel ?? null, tail: tail ?? null } };
4429
4442
  }
4430
4443
 
4444
+ /**
4445
+ * The effort whose thinking allowance an on-demand compaction request over
4446
+ * `messages` gets. It sends no effort change of its own, so once an earlier
4447
+ * request recorded an effort, the one it left in force applies, even when
4448
+ * `requested` differs or is off. When that request named none, the API's
4449
+ * per-model default applies, which is not known here: the request gets the
4450
+ * largest effort's allowance (`max_tokens` is a cap, not spend). Without a
4451
+ * recorded effort the request sets its own.
4452
+ */
4453
+ export function resolveAnthropicCompactionEffort(
4454
+ model: Model<"anthropic-messages">,
4455
+ messages: readonly Message[],
4456
+ requested: Effort | undefined,
4457
+ ): Effort | undefined {
4458
+ if (model.compat.supportsPerMessageEffort !== true) return requested;
4459
+ const records = collectAnthropicControlRecords(messages);
4460
+ if (!records.some(record => record.controls.effort)) return requested;
4461
+ const inForce = planAnthropicEffortControls(undefined, messages, records, true, undefined, false).record?.tail;
4462
+ if (!inForce) return requested && (model.thinking?.efforts.at(-1) ?? requested);
4463
+ if (requested && mapEffortToAnthropicAdaptiveEffort(model, requested) === inForce) return requested;
4464
+ return (
4465
+ model.thinking?.efforts?.findLast(effort => mapEffortToAnthropicAdaptiveEffort(model, effort) === inForce) ??
4466
+ requested
4467
+ );
4468
+ }
4469
+
4431
4470
  /**
4432
4471
  * Splice control markers into `messages`. Inserts sharing an index come from
4433
4472
  * one request and merge into one control: tool changes first, then the effort.
@@ -4593,7 +4632,7 @@ function buildParams(
4593
4632
  // Pre-compute system blocks so they occupy the right slot in the serialized body.
4594
4633
  const shouldInjectClaudeCodeInstruction = isOAuthToken && model.compat.injectClaudeCodeInstruction !== false;
4595
4634
  const firstUserMessageText = shouldInjectClaudeCodeInstruction
4596
- ? extractClaudeCodeFirstUserMessageText(context.messages)
4635
+ ? extractClaudeCodeFirstWireUserMessageText(context.messages)
4597
4636
  : "";
4598
4637
  const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
4599
4638
  includeClaudeCodeInstruction: shouldInjectClaudeCodeInstruction,
@@ -4601,6 +4640,9 @@ function buildParams(
4601
4640
  cacheControl,
4602
4641
  });
4603
4642
 
4643
+ // An on-demand compaction request ends inside the conversation: it gets
4644
+ // nothing live requests lack there, so it makes no tool or effort change.
4645
+ const compactionRequest = compactionSupported ? options?.anthropicCompaction : undefined;
4604
4646
  // Controls earlier requests recorded on their responses fix the declared
4605
4647
  // tools, the top-level effort and every control message in between.
4606
4648
  const records = collectAnthropicControlRecords(context.messages);
@@ -4608,6 +4650,7 @@ function buildParams(
4608
4650
  context,
4609
4651
  records,
4610
4652
  model.compat.supportsMidConversationToolChanges === true,
4653
+ !compactionRequest,
4611
4654
  );
4612
4655
 
4613
4656
  // Pre-compute tools.
@@ -4750,7 +4793,6 @@ function buildParams(
4750
4793
  // A new on-demand compaction request cannot carry context_management.
4751
4794
  // Later turns carrying its signed block may keep clear_thinking as usual.
4752
4795
  // Persisted encrypted threshold blocks alone need a legacy replay edit.
4753
- const compactionRequest = compactionSupported ? options?.anthropicCompaction : undefined;
4754
4796
  const signedReplay = compactionSupported && contextReplaysAnthropicCompaction(context.messages, model, "signed");
4755
4797
  const legacyReplay =
4756
4798
  compactionSupported && !signedReplay && contextReplaysAnthropicCompaction(context.messages, model, "legacy");
@@ -4776,6 +4818,7 @@ function buildParams(
4776
4818
  records,
4777
4819
  model.compat.supportsPerMessageEffort === true,
4778
4820
  compactionReplay,
4821
+ !compactionRequest,
4779
4822
  );
4780
4823
  // `between_tools` returns a 400 at `xhigh`/`max` effort, and the effort in
4781
4824
  // force from earlier turns outlives a thinking toggle. Fall back to the
@@ -4794,6 +4837,7 @@ function buildParams(
4794
4837
  dropAllThinking: dropAllThinking || stripThinkingHistory,
4795
4838
  droppedThinkingBlocks,
4796
4839
  credentialId: options?.credentialId,
4840
+ compactionRequest,
4797
4841
  },
4798
4842
  );
4799
4843
  // Anchor the stable tools+system head so it stays cached across turns; the
@@ -5053,6 +5097,8 @@ export function convertAnthropicMessages(
5053
5097
  dropAllThinking?: boolean;
5054
5098
  droppedThinkingBlocks?: ReadonlySet<string>;
5055
5099
  credentialId?: number;
5100
+ /** On-demand compaction request: it ends inside the conversation, so it gets nothing live requests lack there. */
5101
+ compactionRequest?: AnthropicCompactionRequest;
5056
5102
  },
5057
5103
  ): AnthropicMessageParam[] {
5058
5104
  // Indices of params emitted from `developer` messages. After the main pass,
@@ -5064,21 +5110,33 @@ export function convertAnthropicMessages(
5064
5110
  // and the developer upgrade below look through them: they were inserted
5065
5111
  // by this provider, not authored in the conversation.
5066
5112
  const controlParams = new Set<AnthropicMessageParam>();
5067
- // Harness file metadata queued behind a replayed compaction block. Flushed
5068
- // after the next param boundary that keeps it clear of both the block (the
5069
- // fold below must still join the block with a following assistant turn, or
5070
- // that turn's thinking prefix changes) and any open tool_use turn (its
5071
- // results must follow it contiguously).
5072
- const pendingCompactionFiles: string[] = [];
5073
- const flushCompactionFiles = (): void => {
5074
- while (pendingCompactionFiles.length > 0) {
5075
- const filesText = pendingCompactionFiles.shift();
5076
- if (filesText === undefined || filesText.trim().length === 0) continue;
5077
- // The payload bypassed the `transformMessages` redaction pass, so
5078
- // the metadata takes the same credential redaction here that the
5079
- // dropped message text received there.
5080
- params.push({ role: "user", content: redactSensitiveCredentials(filesText) });
5081
- }
5113
+ // Harness file metadata queued behind a replayed compaction block, oldest
5114
+ // replay point first. Messages kept after a summary must reach the API
5115
+ // exactly as they were sent before, or their signed thinking no longer
5116
+ // matches its prefix; each entry therefore waits for the first message
5117
+ // created after its replay point (past any tool results that close an open
5118
+ // tool_use turn), or the end of the list.
5119
+ let pendingCompactionFiles: AnthropicCompactionFiles[] = [];
5120
+ const pushCompactionFiles = (filesText: string): void => {
5121
+ if (filesText.trim().length === 0) return;
5122
+ // The payload bypassed the `transformMessages` redaction pass, so
5123
+ // the metadata takes the same credential redaction here that the
5124
+ // dropped message text received there.
5125
+ params.push({ role: "user", content: redactSensitiveCredentials(filesText) });
5126
+ };
5127
+ const flushCompactionFiles = (before: number): void => {
5128
+ const due = pendingCompactionFiles.filter(files => files.after < before);
5129
+ pendingCompactionFiles = pendingCompactionFiles.filter(files => files.after >= before);
5130
+ for (const files of due) pushCompactionFiles(files.text);
5131
+ };
5132
+ // Summaries without `exactTail` replay their metadata after the first param
5133
+ // boundary past the block (clear of the fold and of an open tool_use turn):
5134
+ // thinking created after them was signed against that layout.
5135
+ let legacyCompactionFiles: string | undefined;
5136
+ const flushLegacyCompactionFiles = (): void => {
5137
+ if (legacyCompactionFiles === undefined) return;
5138
+ pushCompactionFiles(legacyCompactionFiles);
5139
+ legacyCompactionFiles = undefined;
5082
5140
  };
5083
5141
 
5084
5142
  const transformedMessages = transformMessages(
@@ -5118,17 +5176,23 @@ export function convertAnthropicMessages(
5118
5176
  params.push(compactionParam);
5119
5177
  // The block carries the verbatim API summary, so the message text
5120
5178
  // (which holds the harness file lists) would be dropped with it.
5121
- // Queue the file metadata for after the block: it sits past the
5122
- // compaction boundary the API enforces, unlike anything before it.
5123
- // The flush waits past a following assistant turn (see above).
5124
- if (msg.providerPayload.filesText !== undefined) {
5125
- pendingCompactionFiles.push(msg.providerPayload.filesText);
5179
+ // Queue the file metadata past the retained tail (see above); the
5180
+ // summary message carries the compaction's commit time.
5181
+ const { filesText, retainedFiles, exactTail } = msg.providerPayload;
5182
+ if (exactTail) {
5183
+ pendingCompactionFiles = [
5184
+ ...(retainedFiles ?? []),
5185
+ ...(filesText !== undefined ? [{ text: filesText, after: msg.timestamp }] : []),
5186
+ ].sort((a, b) => a.after - b.after);
5187
+ } else {
5188
+ pendingCompactionFiles = [];
5189
+ legacyCompactionFiles = filesText;
5126
5190
  }
5127
5191
  continue;
5128
5192
  }
5193
+ if (msg.role !== "toolResult") flushCompactionFiles(msg.timestamp);
5129
5194
  if (msg.role === "user" || msg.role === "developer") {
5130
- // Queued file metadata predates this message, so it emits first.
5131
- flushCompactionFiles();
5195
+ flushLegacyCompactionFiles();
5132
5196
  const payload =
5133
5197
  msg.role === "developer" && msg.providerPayload?.type === "anthropicMessage"
5134
5198
  ? msg.providerPayload
@@ -5328,12 +5392,7 @@ export function convertAnthropicMessages(
5328
5392
  };
5329
5393
  copyPerCallContextMessage(assistantParam, msg);
5330
5394
  params.push(assistantParam);
5331
- // Flush queued file metadata unless this turn left tool calls open:
5332
- // their results must follow the turn contiguously, so the metadata
5333
- // waits for the merged result message (or the end of the list).
5334
- if (!blocks.some(block => block.type === "tool_use")) {
5335
- flushCompactionFiles();
5336
- }
5395
+ if (!blocks.some(block => block.type === "tool_use")) flushLegacyCompactionFiles();
5337
5396
  } else if (msg.role === "toolResult") {
5338
5397
  // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint
5339
5398
  const toolResults: ContentBlockParam[] = [];
@@ -5369,9 +5428,7 @@ export function convertAnthropicMessages(
5369
5428
 
5370
5429
  // Add a single user message with all tool results
5371
5430
  params.push(toolResultParam);
5372
- // An open tool_use turn's results are whole again; queued file
5373
- // metadata can follow without splitting the pairing.
5374
- flushCompactionFiles();
5431
+ flushLegacyCompactionFiles();
5375
5432
  }
5376
5433
  }
5377
5434
 
@@ -5468,9 +5525,18 @@ export function convertAnthropicMessages(
5468
5525
  params.splice(previous + 1, 0, { role: "user", content: "Continue." });
5469
5526
  i = previous + 1;
5470
5527
  }
5528
+ if (opts?.compactionRequest) {
5529
+ // Metadata due before `filesDueBefore` ends the summarized range; the
5530
+ // rest replays with the retained tail and is carried by the new summary
5531
+ // instead. Legacy metadata still pending here belongs after the first
5532
+ // retained turn, outside this request.
5533
+ flushCompactionFiles(opts.compactionRequest.filesDueBefore ?? Number.POSITIVE_INFINITY);
5534
+ return params;
5535
+ }
5471
5536
  // A trailing compaction summary leaves its file metadata queued; emit it
5472
5537
  // before the prefill check so the list ends the request as a user turn.
5473
- flushCompactionFiles();
5538
+ flushCompactionFiles(Number.POSITIVE_INFINITY);
5539
+ flushLegacyCompactionFiles();
5474
5540
  const last = skipControls(params.length, -1);
5475
5541
  if (params[last]?.role === "assistant") {
5476
5542
  params.splice(last + 1, 0, { role: "user", content: "Continue." });
@@ -236,7 +236,9 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
236
236
  assignment = await assignDevinModel(model, turn, chatBaseUrl, fetchImpl, options?.signal);
237
237
  output.upstreamModel = assignment.modelUid;
238
238
  }
239
- const request = buildDevinChatRequest(model, context, options, turn, assignment);
239
+ let request = buildDevinChatRequest(model, context, options, turn, assignment);
240
+ const replacementRequest = await options?.onPayload?.(request, model);
241
+ if (replacementRequest !== undefined) request = replacementRequest as typeof request;
240
242
  const reqBytes = toBinary(GetChatMessageRequestSchema, request);
241
243
  const gz = gzipSync(reqBytes);
242
244
  logger.debug("devin: sending chat request", {
@@ -366,6 +366,7 @@ type ForwardedOptions = Pick<
366
366
  | "streamIdleTimeoutMs"
367
367
  | "streamFirstEventTimeoutMs"
368
368
  | "maxTokens"
369
+ | "waitForTerminalDrain"
369
370
  > & { apiKey: string; headers: Record<string, string> };
370
371
 
371
372
  /** One resolved request attempt, shared by every wire encoder. */
@@ -580,6 +581,7 @@ export const streamFactoryDroid: StreamFunction<"factory-droid-agent"> = (
580
581
  streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
581
582
  streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
582
583
  maxTokens: options?.maxTokens ?? scope.model.maxTokens ?? undefined,
584
+ waitForTerminalDrain: options?.waitForTerminalDrain,
583
585
  headers: {
584
586
  ...buildIdentityHeaders({
585
587
  upstream: scope.upstream,
@@ -250,6 +250,7 @@ export function streamGitLabDuo(
250
250
  // like the azure-openai-responses mapping does.
251
251
  disableReasoning: options.disableReasoning || options.forceReasoningOff,
252
252
  toolChoice: options.toolChoice,
253
+ waitForTerminalDrain: options.waitForTerminalDrain,
253
254
  } satisfies OpenAICompletionsOptions,
254
255
  );
255
256
 
@@ -147,6 +147,7 @@ export function streamOpenAIAnthropicShim(
147
147
  toolChoice: options?.toolChoice,
148
148
  serviceTier: options?.serviceTier,
149
149
  disableReasoning: options?.disableReasoning,
150
+ waitForTerminalDrain: options?.waitForTerminalDrain,
150
151
  });
151
152
 
152
153
  for await (const event of innerStream) {
@@ -46,6 +46,7 @@ import type {
46
46
  Usage,
47
47
  } from "../types";
48
48
  import {
49
+ clampOpenAIResponsesImageDetailForReplay,
49
50
  createOpenAIResponsesHistoryPayload,
50
51
  dropMalformedOpenAIResponsesToolCalls,
51
52
  getOpenAIResponsesHistoryItems,
@@ -273,18 +274,13 @@ const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_FI
273
274
  const CODEX_WEBSOCKET_RETRY_BUDGET = Number($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET || CODEX_MAX_RETRIES);
274
275
  const CODEX_WEBSOCKET_RETRY_DELAY_MS = Number($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS || CODEX_RETRY_DELAY_MS);
275
276
  const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
276
- /**
277
- * The server's experimental native turn lane refuses `response.steer` with
278
- * this code, then drops the in-flight response and closes the socket.
279
- */
280
- const CODEX_NATIVE_LANE_STEER_REJECTED_CODE = "unsupported_native_inflight_message";
281
277
  // The native-lane steering rejection is replayable: the session stops steering
282
278
  // first, so the replay cannot trip it again.
283
279
  const CODEX_RETRYABLE_EVENT_CODES = new Set([
284
280
  "model_error",
285
281
  "server_error",
286
282
  "internal_error",
287
- CODEX_NATIVE_LANE_STEER_REJECTED_CODE,
283
+ AIError.CODEX_NATIVE_LANE_STEER_REJECTED_CODE,
288
284
  ]);
289
285
  const CODEX_RETRYABLE_EVENT_MESSAGE =
290
286
  /processing your request|retry your request|temporar(?:y|ily)|overloaded|service.?unavailable|internal error|server error/i;
@@ -2709,6 +2705,7 @@ class CodexStreamProcessor {
2709
2705
  const nativeOutputItems = runtime.finalizeNativeOutputItems();
2710
2706
  const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
2711
2707
  structuredCloneJSON(nativeOutputItems),
2708
+ { supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal },
2712
2709
  );
2713
2710
  if (responseId && replayableResponseItems && replayableResponseItems.length === nativeOutputItems.length) {
2714
2711
  state.lastResponseId = responseId;
@@ -2749,7 +2746,7 @@ class CodexStreamProcessor {
2749
2746
  if (
2750
2747
  !state ||
2751
2748
  !(error instanceof CodexProviderStreamError) ||
2752
- error.code !== CODEX_NATIVE_LANE_STEER_REJECTED_CODE
2749
+ error.code !== AIError.CODEX_NATIVE_LANE_STEER_REJECTED_CODE
2753
2750
  ) {
2754
2751
  return;
2755
2752
  }
@@ -4083,7 +4080,7 @@ class CodexWebSocketConnection {
4083
4080
  }
4084
4081
  // The native lane answers `response.steer` with a plain `error` that
4085
4082
  // also ends the response: refuse the submission, then fail the stream.
4086
- if (parsed.type === "error" && parsed.code === CODEX_NATIVE_LANE_STEER_REJECTED_CODE) {
4083
+ if (parsed.type === "error" && parsed.code === AIError.CODEX_NATIVE_LANE_STEER_REJECTED_CODE) {
4087
4084
  this.#refuseSteerWaiters(parsed.code, typeof parsed.message === "string" ? parsed.message : undefined);
4088
4085
  }
4089
4086
  this.#push(parsed);
@@ -4958,10 +4955,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4958
4955
  if (historyItems) {
4959
4956
  const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
4960
4957
  const sanitizedHistoryItems = dropMalformedOpenAIResponsesToolCalls(redactedHistoryItems);
4958
+ const clampedHistoryItems = clampOpenAIResponsesImageDetailForReplay(
4959
+ sanitizedHistoryItems,
4960
+ model.compat.supportsImageDetailOriginal,
4961
+ );
4961
4962
  const replayItems =
4962
4963
  model.supportsComputerUse === true
4963
- ? sanitizedHistoryItems
4964
- : unrollCodexComputerItems(sanitizedHistoryItems, model.compat.supportsImageDetailOriginal);
4964
+ ? clampedHistoryItems
4965
+ : unrollCodexComputerItems(clampedHistoryItems, model.compat.supportsImageDetailOriginal);
4965
4966
  for (const item of replayItems) {
4966
4967
  if (item.type === "custom_tool_call") {
4967
4968
  customCallIds.add(item.call_id);
@@ -4995,7 +4996,9 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4995
4996
  const historyItems = providerPayload?.items as Array<Record<string, unknown>> | undefined;
4996
4997
  let suppressHiddenEmptyFallback = false;
4997
4998
  if (historyItems) {
4998
- const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(historyItems);
4999
+ const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(historyItems, {
5000
+ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
5001
+ });
4999
5002
  if (sanitizedHistoryItems) {
5000
5003
  const rawReplayItems =
5001
5004
  model.supportsComputerUse === true
@@ -1336,6 +1336,7 @@ const streamOpenAICompletionsOnce = (
1336
1336
  // and release the socket immediately (a queued `.return()` alone
1337
1337
  // would wait on the never-arriving next chunk).
1338
1338
  onGraceEnd: () => requestAbortController.abort(),
1339
+ awaitDrainOnReturn: options?.waitForTerminalDrain,
1339
1340
  });
1340
1341
  for await (const chunk of terminalAwareStream) {
1341
1342
  if (!chunk || typeof chunk !== "object") continue;
@@ -1380,6 +1381,8 @@ const streamOpenAICompletionsOnce = (
1380
1381
  // Trailing usage-only chunk (`stream_options.include_usage`) after
1381
1382
  // `finish_reason`: the response is complete — stop pulling instead
1382
1383
  // of waiting for `[DONE]`/close from hosts that never send either.
1384
+ // `iterateWithTerminalGrace` drains the tail in the background so
1385
+ // compliant hosts still get to relay `[DONE]` before the socket closes.
1383
1386
  if (streamFinishedAt !== undefined && sawUsagePayload) break;
1384
1387
  continue;
1385
1388
  }