@oh-my-pi/pi-ai 18.6.1 → 18.7.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +38 -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/images/index.d.ts +2 -3
  7. package/dist/types/providers/anthropic.d.ts +15 -2
  8. package/dist/types/providers/google-auth.d.ts +4 -1
  9. package/dist/types/providers/mock.d.ts +3 -1
  10. package/dist/types/providers/openai-responses-wire.d.ts +0 -54
  11. package/dist/types/providers/openai-responses.d.ts +32 -3
  12. package/dist/types/providers/openai-shared.d.ts +4 -1
  13. package/dist/types/registry/oauth/cursor.d.ts +9 -1
  14. package/dist/types/registry/oauth/index.d.ts +2 -0
  15. package/dist/types/registry/oauth/snowflake.d.ts +3 -0
  16. package/dist/types/registry/snowflake.d.ts +4 -0
  17. package/dist/types/types.d.ts +72 -13
  18. package/dist/types/utils/idle-iterator.d.ts +11 -0
  19. package/dist/types/utils.d.ts +6 -0
  20. package/package.json +6 -6
  21. package/src/auth/cascade.ts +6 -2
  22. package/src/auth/health.ts +1 -0
  23. package/src/auth/select.ts +3 -1
  24. package/src/auth/types.ts +4 -0
  25. package/src/auth-broker/refresher.ts +8 -1
  26. package/src/error/flags.ts +22 -0
  27. package/src/images/index.ts +3 -17
  28. package/src/providers/anthropic.ts +111 -45
  29. package/src/providers/devin.ts +3 -1
  30. package/src/providers/factory-droid.ts +2 -0
  31. package/src/providers/gitlab-duo.ts +1 -0
  32. package/src/providers/google-auth.ts +10 -3
  33. package/src/providers/mock.ts +4 -0
  34. package/src/providers/openai-anthropic-shim.ts +1 -0
  35. package/src/providers/openai-codex-responses.ts +37 -38
  36. package/src/providers/openai-completions.ts +3 -0
  37. package/src/providers/openai-responses-wire.ts +0 -54
  38. package/src/providers/openai-responses.ts +349 -6
  39. package/src/providers/openai-shared.ts +20 -9
  40. package/src/providers/pi-native-server.ts +1 -0
  41. package/src/registry/hooks/custom.ts +2 -0
  42. package/src/registry/hooks/env.ts +2 -5
  43. package/src/registry/oauth/cursor.ts +57 -3
  44. package/src/registry/oauth/index.ts +5 -0
  45. package/src/registry/oauth/snowflake.ts +183 -0
  46. package/src/registry/registry.ts +2 -0
  47. package/src/registry/snowflake.ts +149 -0
  48. package/src/stream.ts +35 -33
  49. package/src/types.ts +93 -14
  50. package/src/usage/claude.ts +3 -0
  51. package/src/usage/cursor.ts +14 -25
  52. package/src/usage/minimax-code.ts +8 -1
  53. package/src/utils/idle-iterator.ts +80 -35
  54. package/src/utils/thinking-loop.ts +18 -10
  55. package/src/utils.ts +19 -4
@@ -34,6 +34,12 @@ export declare function stripOpenAIResponsesOutputOnlyStatusesForReplay<TItem ex
34
34
  type?: unknown;
35
35
  status?: unknown;
36
36
  }>(items: TItem[]): TItem[];
37
+ /** Clamp replayed images without changing item identities or other native replay fields. */
38
+ export declare function clampOpenAIResponsesImageDetailForReplay<TItem extends {
39
+ type?: unknown;
40
+ detail?: unknown;
41
+ content?: unknown;
42
+ }>(items: TItem[], supportsImageDetailOriginal: boolean): TItem[];
37
43
  export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>, options?: OpenAIResponsesReplaySanitizeOptions): ResponseInput;
38
44
  /** Strip reasoning IDs whose only linked native output is a computer call that will be demoted. */
39
45
  export declare function stripOpenAIResponsesComputerLinkedReasoningIdsForReplay(items: ResponseInput): ResponseInput;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.6.1",
3
+ "version": "18.7.0",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.6.1",
159
- "@oh-my-pi/pi-catalog": "18.6.1",
160
- "@oh-my-pi/pi-natives": "18.6.1",
161
- "@oh-my-pi/pi-utils": "18.6.1",
162
- "@oh-my-pi/pi-wire": "18.6.1"
158
+ "@oh-my-pi/omptype": "18.7.0",
159
+ "@oh-my-pi/pi-catalog": "18.7.0",
160
+ "@oh-my-pi/pi-natives": "18.7.0",
161
+ "@oh-my-pi/pi-utils": "18.7.0",
162
+ "@oh-my-pi/pi-wire": "18.7.0"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -356,7 +356,9 @@ export class KeyCascade implements KeysApi {
356
356
  credential => credential.source === "login" && !this.isKeylessFallback(provider, credential),
357
357
  );
358
358
  if (loginApiKeySelection) {
359
- this.#deps.affinity.record(provider, sessionId, "api_key", loginApiKeySelection.index);
359
+ if (options?.recordAffinity !== false) {
360
+ this.#deps.affinity.record(provider, sessionId, "api_key", loginApiKeySelection.index);
361
+ }
360
362
  const credentialId = onCredentialId
361
363
  ? this.#deps.pool.entries(provider)[loginApiKeySelection.index]?.id
362
364
  : undefined;
@@ -382,7 +384,9 @@ export class KeyCascade implements KeysApi {
382
384
  credential => credential.source !== "login",
383
385
  );
384
386
  if (apiKeySelection) {
385
- this.#deps.affinity.record(provider, sessionId, "api_key", apiKeySelection.index);
387
+ if (options?.recordAffinity !== false) {
388
+ this.#deps.affinity.record(provider, sessionId, "api_key", apiKeySelection.index);
389
+ }
386
390
  const credentialId = onCredentialId ? this.#deps.pool.entries(provider)[apiKeySelection.index]?.id : undefined;
387
391
  const apiKey = await this.#deps.overrides.resolve(apiKeySelection.credential.key);
388
392
  if (apiKey !== undefined && credentialId !== undefined) onCredentialId?.(credentialId);
@@ -283,6 +283,7 @@ export class CredentialHealth implements HealthApi {
283
283
  const results: CredentialHealthResult[] = [];
284
284
  for (const row of stored) {
285
285
  options?.signal?.throwIfAborted();
286
+ if (options?.excludeProviders?.has(row.provider)) continue;
286
287
  const base: CredentialHealthResult = {
287
288
  id: row.id,
288
289
  provider: row.provider,
@@ -1055,7 +1055,9 @@ export class CredentialSelector {
1055
1055
  }
1056
1056
  }
1057
1057
  this.#deps.pool.noteBearer(provider, result.apiKey, credentialId);
1058
- this.#deps.affinity.record(provider, sessionId, "oauth", selection.index);
1058
+ if (options?.recordAffinity !== false) {
1059
+ this.#deps.affinity.record(provider, sessionId, "oauth", selection.index);
1060
+ }
1059
1061
  return { apiKey: result.apiKey, credential: updated, credentialId };
1060
1062
  } catch (error) {
1061
1063
  const errorMsg = String(error);
package/src/auth/types.ts CHANGED
@@ -257,6 +257,8 @@ export interface CheckCredentialsOptions {
257
257
  completionProbe?: CompletionProbe;
258
258
  /** Per-credential completion probe timeout (ms). Defaults to `timeoutMs`. */
259
259
  completionTimeoutMs?: number;
260
+ /** Providers whose credentials are skipped entirely (not probed, not reported). */
261
+ excludeProviders?: ReadonlySet<string>;
260
262
  }
261
263
 
262
264
  // ─────────────────────────────────────────────────────────────────────────────
@@ -487,6 +489,8 @@ export type AuthApiKeyOptions = {
487
489
  forceRefresh?: boolean;
488
490
  /** Explicit provider-401 recovery; generic force refreshes leave this unset. */
489
491
  refreshReason?: OAuthRefreshReason;
492
+ /** When false, select as `sessionId` would without recording the choice as that session's sticky credential. */
493
+ recordAffinity?: boolean;
490
494
  };
491
495
 
492
496
  /** Non-secret identity bound to the OAuth credential selected for one request attempt. */
@@ -75,7 +75,12 @@ export class AuthBrokerRefresher {
75
75
  };
76
76
  }
77
77
 
78
- /** Run one sweep. Exposed for tests. */
78
+ /**
79
+ * Run one sweep. Exposed for tests.
80
+ *
81
+ * Never rejects: callers fire it unawaited from a timer, so a failed sweep
82
+ * (e.g. an unreadable credential store) is logged and retried next interval.
83
+ */
79
84
  async tick(): Promise<void> {
80
85
  if (this.#running) return;
81
86
  this.#running = true;
@@ -94,6 +99,8 @@ export class AuthBrokerRefresher {
94
99
  targets.push(entry.id);
95
100
  }
96
101
  await Promise.all(targets.map(id => this.#refreshOne(id)));
102
+ } catch (error) {
103
+ logger.warn("auth-broker refresh sweep failed", { error: String(error) });
97
104
  } finally {
98
105
  this.#running = false;
99
106
  this.#nextSweepAt = this.#now() + this.#refreshIntervalMs;
@@ -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.
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
1
+ import { type Api, IMAGE_GENERATION_APIS, type ImageGenerationApi, type Model } from "@oh-my-pi/pi-catalog/types";
2
2
  import * as AIError from "../error";
3
3
  import { generateAntigravityImage } from "./google-antigravity";
4
4
  import { generateGoogleImage } from "./google-generative-ai";
@@ -14,25 +14,11 @@ export * from "./openai-images";
14
14
  export * from "./openrouter-images";
15
15
  export * from "./types";
16
16
 
17
- /** Catalog APIs {@link generateImage} serves; the hosted Responses pair needs an explicit carrier model. */
18
- export type ImageGenerationApi =
19
- | "openai-images"
20
- | "openrouter-images"
21
- | "google-generative-ai"
22
- | "google-gemini-cli"
23
- | "openai-responses"
24
- | "openai-codex-responses";
17
+ export type { ImageGenerationApi } from "@oh-my-pi/pi-catalog/types";
25
18
 
26
19
  /** Whether a catalog API generates images through one of the pi-ai image clients. */
27
20
  export function isImageGenerationApi(api: Api): api is ImageGenerationApi {
28
- return (
29
- api === "openai-images" ||
30
- api === "openrouter-images" ||
31
- api === "google-generative-ai" ||
32
- api === "google-gemini-cli" ||
33
- api === "openai-responses" ||
34
- api === "openai-codex-responses"
35
- );
21
+ return (IMAGE_GENERATION_APIS as readonly Api[]).includes(api);
36
22
  }
37
23
 
38
24
  /** Generate (or edit, when `request.inputImages` is set) images through the transport selected by the model's `api`. */
@@ -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
 
@@ -5,7 +5,8 @@
5
5
  * Sources, in priority order:
6
6
  * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT exchange)
7
7
  * or `type: "authorized_user"` (refresh-token exchange).
8
- * 2. `~/.config/gcloud/application_default_credentials.json` (user ADC, same authorized_user flow).
8
+ * 2. gcloud user ADC: `%APPDATA%\gcloud\application_default_credentials.json` on Windows,
9
+ * `~/.config/gcloud/application_default_credentials.json` elsewhere (authorized_user flow).
9
10
  * 3. GCE / Cloud Run metadata server (`metadata.google.internal`).
10
11
  *
11
12
  * Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` before expiry
@@ -66,8 +67,14 @@ function getRefreshSkewMs(): number {
66
67
  return $envpos("GOOGLE_VERTEX_REFRESH_SKEW_MS", 60_000);
67
68
  }
68
69
 
69
- function userAdcPath(): string {
70
- return path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json");
70
+ /** gcloud's user ADC file: `%APPDATA%\gcloud\…` on Windows, `~/.config/gcloud/…` elsewhere. */
71
+ export function userAdcPath(
72
+ platform: typeof process.platform = process.platform,
73
+ appData: string | undefined = Bun.env.APPDATA,
74
+ ): string {
75
+ const gcloudDir =
76
+ platform === "win32" && appData ? path.join(appData, "gcloud") : path.join(os.homedir(), ".config", "gcloud");
77
+ return path.join(gcloudDir, "application_default_credentials.json");
71
78
  }
72
79
 
73
80
  async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
@@ -51,6 +51,7 @@ import type {
51
51
  AssistantMessage,
52
52
  Context,
53
53
  Model,
54
+ ServiceTier,
54
55
  SimpleStreamOptions,
55
56
  StopDetails,
56
57
  StopReason,
@@ -93,6 +94,8 @@ export interface MockResponse {
93
94
  errorMessage?: string;
94
95
  /** Usage stats. Missing fields default to 0; missing `cost.total` is recomputed from components. */
95
96
  usage?: Partial<Omit<Usage, "cost">> & { cost?: Partial<Usage["cost"]> };
97
+ /** Service tier the mock reports serving the turn, as a real provider's response echo would. */
98
+ serviceTier?: ServiceTier;
96
99
  /** Pre-set responseId. */
97
100
  responseId?: string;
98
101
  /** If set, the stream emits a terminal error event instead of completing. */
@@ -409,6 +412,7 @@ async function runMock(
409
412
  partial.fallbackCreditHandle = response.fallbackCreditHandle;
410
413
  partial.errorMessage = response.errorMessage;
411
414
  partial.usage = mergeUsage(response.usage);
415
+ partial.serviceTier = response.serviceTier;
412
416
  partial.duration = performance.now() - perfStart;
413
417
 
414
418
  if (reason === "aborted" || reason === "error") {
@@ -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) {