@oh-my-pi/pi-ai 18.1.16 → 18.1.18

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 (37) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/error/flags.d.ts +6 -0
  3. package/dist/types/providers/anthropic-wire.d.ts +44 -7
  4. package/dist/types/providers/anthropic.d.ts +42 -0
  5. package/dist/types/providers/github-copilot-headers.d.ts +71 -1
  6. package/dist/types/providers/openai-completions.d.ts +1 -1
  7. package/dist/types/providers/openai-shared.d.ts +8 -0
  8. package/dist/types/providers/vision-guard.d.ts +16 -2
  9. package/dist/types/registry/oauth/github-copilot.d.ts +1 -0
  10. package/dist/types/types.d.ts +47 -1
  11. package/dist/types/utils/block-symbols.d.ts +41 -0
  12. package/package.json +6 -6
  13. package/src/error/finalize.ts +9 -2
  14. package/src/error/flags.ts +46 -1
  15. package/src/error/retryable.ts +2 -0
  16. package/src/providers/amazon-bedrock.ts +35 -25
  17. package/src/providers/anthropic-wire.ts +42 -9
  18. package/src/providers/anthropic.ts +648 -82
  19. package/src/providers/cursor.ts +1 -1
  20. package/src/providers/devin.ts +1 -1
  21. package/src/providers/github-copilot-headers.ts +242 -1
  22. package/src/providers/google-gemini-cli.ts +1 -1
  23. package/src/providers/google-shared.ts +1 -1
  24. package/src/providers/ollama.ts +11 -2
  25. package/src/providers/openai-codex-responses.ts +7 -2
  26. package/src/providers/openai-completions.ts +23 -4
  27. package/src/providers/openai-responses.ts +17 -9
  28. package/src/providers/openai-shared.ts +25 -4
  29. package/src/providers/pi-native-server.ts +3 -0
  30. package/src/providers/transform-messages.ts +7 -3
  31. package/src/providers/vision-guard.ts +28 -2
  32. package/src/registry/oauth/github-copilot.ts +28 -4
  33. package/src/stream.ts +1 -0
  34. package/src/types.ts +49 -1
  35. package/src/utils/block-symbols.ts +47 -0
  36. package/src/utils/empty-completion-retry.ts +1 -0
  37. package/src/utils/http-inspector.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.18] - 2026-09-11
6
+
7
+ ### Added
8
+
9
+ - Anthropic server-side compaction (`compact-2026-01-12` beta): `anthropicCompaction` on `StreamOptions` sends the `compact_20260112` context-management edit, the streamed `compaction` block is surfaced as an `anthropicCompaction` provider payload, the `compaction` stop reason is a normal stop tagged in `stopDetails` (exempt from the empty-completion retry), and usage sums `usage.iterations` whenever a compaction iteration ran. A user-role compaction summary carrying that payload replays as a leading assistant `compaction` block — folded into the retained assistant turn when one follows — with the beta and a never-firing strategy attached automatically; other providers keep reading the summary text. Everything compaction-related is gated on the model line (`compat.supportsServerCompaction`, rule-owned in the catalog) and on the endpoint the request actually reaches (`supportsAnthropicCompaction`: the official API for the first-party provider, resolved through Foundry / `ANTHROPIC_BASE_URL` reroutes, or an explicit `remoteCompaction.enabled` opt-in), so a rerouted session or an older model line falls back to the text summary instead of sending a block the API rejects. Caller-owned clients are gated on their own endpoint (the client's `baseURL`, or an explicit `remoteCompaction.enabled` opt-in when it exposes none) and receive the compaction beta per request, like the effort and control betas. A block held by its originating assistant message — a caller that appends the compacting response itself — replays at the head of that turn. The block's opaque `encrypted_content` is captured from the stream, kept on the payload as `encryptedContent`, and replayed verbatim. A compacting turn is priced per sampling iteration (like a server-side fallback turn), so a long-context tier applies only to an iteration whose own prompt crosses the threshold, never to the summed totals.
10
+ - Added historical decimation prompt-cache breakpoints every 15 user turns on Anthropic requests, so long conversations retain stable cached prefixes during branching, rewinds, and session resume ([#11665](https://github.com/can1357/oh-my-pi/pull/11665) by [@camjac251](https://github.com/camjac251)).
11
+
12
+ ### Changed
13
+
14
+ - Defaulted Anthropic OAuth requests to 1h prompt-cache retention where supported, matching Claude Code subscriber behavior and preventing cache expiry during idle intervals ([#11667](https://github.com/can1357/oh-my-pi/pull/11667) by [@camjac251](https://github.com/camjac251)).
15
+
16
+ ### Fixed
17
+
18
+ - Fixed Codex HTTP response-body transport failures forwarded through Anthropic-compatible proxies being treated as terminal errors; replay-safe turns now use the existing transient recovery without re-executing completed tools.
19
+ - GitHub Copilot Enterprise requests keep the Copilot CLI identity accepted by private Enterprise endpoints, and Business requests denied with HTTP 400 `model_not_supported` now retry once as the Copilot CLI (matching the existing 403 fallback), restoring models that 18.1.17 rejected as unsupported ([#11669](https://github.com/can1357/oh-my-pi/issues/11669)).
20
+ - Fixed provider stream truncations reported as a bare `unexpected EOF` (and other stream-parse diagnostics) classifying as terminal errors, so they now retry like every other transient transport failure ([#11745](https://github.com/can1357/oh-my-pi/issues/11745)).
21
+ - GitHub Copilot streams remember the working `Copilot-Integration-Id` per credential after a denied chat identity retries as the Copilot CLI, so later streams start at the working shape instead of replaying the denial ([#11669](https://github.com/can1357/oh-my-pi/issues/11669)).
22
+ - Fixed Anthropic OAuth requests omitting the tool-array cache breakpoint, so tool definitions are now cached across session rewrites and sibling subagents ([#11660](https://github.com/can1357/oh-my-pi/pull/11660) by [@camjac251](https://github.com/camjac251)).
23
+ - Fixed Amazon Bedrock OpenAI models rejecting image-bearing tool results by sending each image as a sibling user content block ([#11681](https://github.com/can1357/oh-my-pi/issues/11681)).
24
+
25
+ ## [18.1.17] - 2026-09-10
26
+
27
+ ### Fixed
28
+
29
+ - Fixed transient Python HTTP/2 stream resets and HTTP/1.1 chunked response interruptions being treated as terminal errors when forwarded by a proxy ([#11160](https://github.com/can1357/oh-my-pi/pull/11160) by [@cyriusweng](https://github.com/cyriusweng)).
30
+ - Ollama cache hits now populate cached-token usage: `prompt_eval_cached_count` from the `/api/chat` done chunk maps to `cacheRead`, with `input` reduced to the uncached portion, so status-line `cache_turn`/`cache_hit` segments and cache-prefix audits report real hit rates instead of false misses.
31
+ - Fixed requests that run across a price change being costed at the newer rate; peak/off-peak estimates now use the rate in effect when the request started.
32
+ - Fixed GitHub Copilot Business seats getting HTTP 403 on every model while the same token succeeds with a Chat client identity: chat and model-policy requests now identify as `copilot-chat`, denied requests retry once as the Copilot CLI (`copilot-developer-cli`), and `COPILOT_INTEGRATION_ID` pins the `Copilot-Integration-Id` header up front; model discovery keeps the CLI identity and the 403 message names the identity and the remedies ([#11372](https://github.com/can1357/oh-my-pi/issues/11372)).
33
+
5
34
  ## [18.1.16] - 2026-09-09
6
35
 
7
36
  ### Fixed
@@ -28,6 +28,12 @@ export declare const Flag: {
28
28
  };
29
29
  export type Flag = (typeof Flag)[keyof typeof Flag];
30
30
  export declare const STREAM_READ_ERROR_PATTERN: RegExp;
31
+ /** Python h2/httpx diagnostics forwarded through provider or proxy error events. */
32
+ export declare const PYTHON_HTTP2_STREAM_RESET_PATTERN: RegExp;
33
+ /** Python h11/httpx EOF while reading an HTTP/1.1 chunked response body. */
34
+ export declare const PYTHON_HTTP_INCOMPLETE_CHUNK_PATTERN: RegExp;
35
+ /** reqwest body-frame failures forwarded by the Codex HTTP proxy. */
36
+ export declare const CODEX_HTTP_BODY_READ_ERROR_PATTERN: RegExp;
31
37
  export declare const TRANSIENT_TRANSPORT_PATTERN: RegExp;
32
38
  /**
33
39
  * Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
@@ -135,7 +135,21 @@ export type FallbackBlockParam = {
135
135
  model: string;
136
136
  };
137
137
  };
138
- export type ContentBlockParam = TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam | ServerToolUseBlockParam | WebSearchToolResultBlockParam | ToolSearchToolResultBlockParam | ToolAdditionBlockParam | ToolRemovalBlockParam | ThinkingBlockParam | RedactedThinkingBlockParam | FallbackBlockParam;
138
+ /** Beta enabling server-side compaction (`compact_20260112` edit, `compaction` blocks). */
139
+ export declare const COMPACTION_BETA = "compact-2026-01-12";
140
+ /**
141
+ * Server-side compaction summary (compact-2026-01-12). Returned at the start
142
+ * of the assistant response that crossed the trigger; on replay the API drops
143
+ * every block that precedes it, so it may open the messages array. The
144
+ * `encrypted_content` is opaque provider state, round-tripped verbatim.
145
+ */
146
+ export type CompactionBlockParam = {
147
+ type: "compaction";
148
+ content: string;
149
+ encrypted_content?: string | null;
150
+ cache_control?: CacheControlEphemeral | null;
151
+ };
152
+ export type ContentBlockParam = TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam | ServerToolUseBlockParam | WebSearchToolResultBlockParam | ToolSearchToolResultBlockParam | ToolAdditionBlockParam | ToolRemovalBlockParam | ThinkingBlockParam | RedactedThinkingBlockParam | FallbackBlockParam | CompactionBlockParam;
139
153
  /**
140
154
  * A single conversation turn.
141
155
  *
@@ -229,12 +243,24 @@ export type FallbackParam = {
229
243
  output_config?: OutputConfig;
230
244
  speed?: "fast";
231
245
  };
246
+ /** Server-side compaction edit (compact-2026-01-12). */
247
+ export type CompactionEdit = {
248
+ type: "compact_20260112";
249
+ /** `input_tokens` is the only trigger; `value` must be at least 50,000. */
250
+ trigger?: {
251
+ type: "input_tokens";
252
+ value: number;
253
+ };
254
+ pause_after_compaction?: boolean;
255
+ /** Replaces the API's default summarization prompt entirely. */
256
+ instructions?: string;
257
+ };
232
258
  /** Claude Code context-management beta payload. */
233
259
  export type ContextManagement = {
234
260
  edits: Array<{
235
261
  type: "clear_thinking_20251015";
236
262
  keep: "all";
237
- }>;
263
+ } | CompactionEdit>;
238
264
  };
239
265
  export type MessageCreateParams = {
240
266
  model: string;
@@ -267,7 +293,7 @@ export type MessageCreateParams = {
267
293
  export type MessageCreateParamsStreaming = MessageCreateParams & {
268
294
  stream: true;
269
295
  };
270
- export type StopReason = "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn" | "refusal" | "sensitive" | "model_context_window_exceeded";
296
+ export type StopReason = "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn" | "refusal" | "sensitive" | "model_context_window_exceeded" | "compaction";
271
297
  export type CacheCreation = {
272
298
  ephemeral_5m_input_tokens?: number | null;
273
299
  ephemeral_1h_input_tokens?: number | null;
@@ -278,12 +304,15 @@ export type ServerToolUsage = {
278
304
  };
279
305
  /**
280
306
  * Per-attempt token accounting inside a multi-run turn
281
- * (server-side-fallback-2026-06-01). Populated whenever a fallback chain
282
- * ran, including sticky-served turns with no `fallback` content block.
283
- * A `fallback_message` entry is the definitive "served by fallback" signal.
307
+ * (server-side-fallback-2026-06-01, compact-2026-01-12). Populated whenever
308
+ * a fallback chain ran, including sticky-served turns with no `fallback`
309
+ * content block, and whenever the compaction beta is active. A
310
+ * `fallback_message` entry is the definitive "served by fallback" signal; a
311
+ * `compaction` entry is the summarization sampling the top-level usage
312
+ * excludes.
284
313
  */
285
314
  export type UsageIteration = {
286
- type?: "message" | "fallback_message" | string;
315
+ type?: "message" | "fallback_message" | "compaction" | string;
287
316
  model?: string | null;
288
317
  input_tokens?: number | null;
289
318
  output_tokens?: number | null;
@@ -343,6 +372,10 @@ export type ResponseContentBlock = {
343
372
  to: {
344
373
  model: string;
345
374
  };
375
+ } | {
376
+ type: "compaction";
377
+ content?: string | null;
378
+ encrypted_content?: string | null;
346
379
  };
347
380
  export type ContentBlockDelta = {
348
381
  type: "text_delta";
@@ -356,6 +389,10 @@ export type ContentBlockDelta = {
356
389
  } | {
357
390
  type: "signature_delta";
358
391
  signature: string;
392
+ } | {
393
+ type: "compaction_delta";
394
+ content?: string | null;
395
+ encrypted_content?: string | null;
359
396
  };
360
397
  export type StopDetails = {
361
398
  type: string;
@@ -165,6 +165,14 @@ export type AnthropicClientOptionsArgs = {
165
165
  fetch?: FetchImpl;
166
166
  maxRetryDelayMs?: number;
167
167
  sessionId?: string;
168
+ /** Working-identity cache key for this credential+host; undefined off the Copilot path. */
169
+ copilotCacheKey?: string;
170
+ /**
171
+ * Build-time cache provenance for the wrapper: the cached value the
172
+ * outgoing headers were built from, or `null` when the cache was empty at
173
+ * build. `undefined` rereads the cache at dispatch.
174
+ */
175
+ copilotCacheSnapshot?: string | null;
168
176
  };
169
177
  export type AnthropicClientOptionsResult = {
170
178
  isOAuthToken: boolean;
@@ -204,6 +212,32 @@ export type AnthropicUsageLike = {
204
212
  * zero-valued objects clear prior extras from earlier stream usage snapshots.
205
213
  */
206
214
  export declare function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void;
215
+ /**
216
+ * Whether this model's requests reach the official Anthropic API, resolved the
217
+ * way the transport resolves it — including the Foundry and
218
+ * `ANTHROPIC_BASE_URL` reroutes that leave `compat.officialEndpoint` stale.
219
+ */
220
+ export declare function resolvesToOfficialAnthropicEndpoint(model: Model<"anthropic-messages">): boolean;
221
+ /**
222
+ * Whether server-side compaction (`compact-2026-01-12`) may be spoken for
223
+ * this model to the endpoint a request actually reaches: a model line the
224
+ * beta supports (`compat.supportsServerCompaction`, rule-owned in the
225
+ * catalog), on the official API for the first-party provider or on any
226
+ * endpoint that opted in through `remoteCompaction.enabled`, and never on one
227
+ * whose deployment contract excludes context management. The same predicate
228
+ * gates emitting the edit, attaching the beta, and replaying a persisted
229
+ * block, so a route or model change can never leave a session sending a block
230
+ * its endpoint rejects.
231
+ */
232
+ export declare function supportsAnthropicCompaction(model: Model<"anthropic-messages">, effectiveBaseUrl?: string): boolean;
233
+ /**
234
+ * {@link supportsAnthropicCompaction} for a request on a caller-owned client:
235
+ * the endpoint is whatever the client targets (an `AnthropicVertex` client
236
+ * carries an Anthropic model to Vertex), never the model's own routing. SDK
237
+ * clients expose it as `baseURL`; a client that exposes no endpoint only
238
+ * compacts through an explicit `remoteCompaction.enabled` opt-in.
239
+ */
240
+ export declare function supportsAnthropicCompactionOnClient(model: Model<"anthropic-messages">, client: AnthropicMessagesClientLike): boolean;
207
241
  /** Detects the preserved-thinking error caused by rewriting a signed block's conversation prefix. */
208
242
  export declare function isThinkingPrefixBindingError(message: string): boolean;
209
243
  export declare function isInvalidThinkingSignatureError(message: string): boolean;
@@ -250,9 +284,17 @@ export type AnthropicMessageParam = MessageParam;
250
284
  * `fallback` content block from a prior turn be replayed on the wire;
251
285
  * otherwise the block is dropped to avoid a 400 on non-fallback requests
252
286
  * that don't send the beta.
287
+ *
288
+ * `opts.replayCompaction` — replay a user-role compaction summary that
289
+ * carries an {@link AnthropicCompactionPayload} from this provider as a
290
+ * native `compaction` block instead of its text. The API drops every block
291
+ * before the compaction block, so the assistant turn carrying it may open
292
+ * the conversation; the request must send the compaction beta (the stream
293
+ * entry point adds it whenever such a payload is present).
253
294
  */
254
295
  export declare function convertAnthropicMessages(messages: Message[], model: Model<"anthropic-messages">, isOAuthToken: boolean, opts?: {
255
296
  serverSideFallbackEnabled?: boolean;
297
+ replayCompaction?: boolean;
256
298
  dropAllThinking?: boolean;
257
299
  droppedThinkingBlocks?: ReadonlySet<string>;
258
300
  }): AnthropicMessageParam[];
@@ -1,4 +1,4 @@
1
- import type { Message } from "../types.js";
1
+ import type { FetchImpl, Message } from "../types.js";
2
2
  /**
3
3
  * Infer whether the current request to Copilot is user-initiated or agent-initiated.
4
4
  * Accepts `unknown[]` because providers may pass pre-converted message shapes.
@@ -11,6 +11,70 @@ export type CopilotDynamicHeaders = {
11
11
  premiumRequests: CopilotPremiumRequests;
12
12
  };
13
13
  export declare function resolveGitHubCopilotBaseUrl(baseUrl: string | undefined, apiKey: string | undefined): string | undefined;
14
+ /**
15
+ * Opt-in `Copilot-Integration-Id` override for chat and model-policy requests.
16
+ * Reads `COPILOT_INTEGRATION_ID`; unset/invalid keeps the chat-surface default
17
+ * (`COPILOT_CHAT_INTEGRATION_ID`). Model discovery keeps the CLI identity: it
18
+ * unlocks enterprise/experimental models and listing models is not
19
+ * policy-gated the way chat completions are (#11372).
20
+ */
21
+ export declare function resolveCopilotIntegrationIdOverride(env?: Record<string, string | undefined>): string | undefined;
22
+ /**
23
+ * Effective identity before the chat-surface default: explicit value, then
24
+ * request headers, then `COPILOT_INTEGRATION_ID`. Pure given its inputs, so
25
+ * tests inject literals instead of mutating process state.
26
+ */
27
+ export declare function resolveCopilotRequestIdentity(headers?: Record<string, string>, explicit?: unknown, env?: Record<string, string | undefined>): string | undefined;
28
+ /**
29
+ * Stable cache key for a raw Copilot API key envelope on one effective host.
30
+ * Hashes the bearer with `Bun.hash` (repo-approved hashing API; same
31
+ * credential-scoped pattern as the GitLab Duo and Codex account keys) so token
32
+ * bytes never sit in the map as keys; enterprise/business routing inputs and
33
+ * the normalized effective base URL participate so the same token on two hosts
34
+ * does not share an entry.
35
+ */
36
+ export declare function getCopilotIntegrationCacheKey(apiKeyRaw: string | undefined, baseUrl?: string): string | undefined;
37
+ /** Cached working identity for a cache key, if one was learned. */
38
+ export declare function getCachedCopilotIntegrationId(cacheKey: string | undefined): string | undefined;
39
+ /**
40
+ * Remember the identity that cleared the identity gate for a credential.
41
+ * Every store refreshes recency, so hot credentials survive eviction.
42
+ */
43
+ export declare function rememberCopilotWorkingIntegrationId(cacheKey: string | undefined, integrationId: unknown): void;
44
+ /** Clear one cached identity, or the whole cache when no key is given. */
45
+ export declare function clearCopilotIntegrationCache(cacheKey?: string): void;
46
+ /**
47
+ * Reissue Copilot client-identity denials once with the other surface.
48
+ *
49
+ * Chat is the default surface (`COPILOT_CHAT_INTEGRATION_ID`) because Business
50
+ * organizations that gate premium models per client surface commonly allow
51
+ * chat while blocking CLI/agentic clients (issue #11372). Other Business and
52
+ * Enterprise orgs do the opposite and reject the chat identity — as an HTTP 403
53
+ * or, on `api.business.githubcopilot.com`, an HTTP 400 `model_not_supported`
54
+ * (issue #11669). Both denials retry once as the CLI. The retry fires only for
55
+ * requests carrying the chat default and only when the caller resolved no
56
+ * explicit identity — an explicit choice is never second-guessed. The denied
57
+ * body is drained before reissuing, and the retry carries the CLI identity so
58
+ * the guard passes it through: at most two requests, never a loop.
59
+ *
60
+ * When `cacheKey` is set, a 2xx retry remembers its identity via
61
+ * `rememberCopilotWorkingIntegrationId`, so later streams for the same
62
+ * credential start at the working shape. A cached CLI start that is itself
63
+ * denied (stale after an org-policy flip) retries once as chat and relearns.
64
+ * Only a 2xx retry proves its identity — 401s deny every identity equally and
65
+ * 408/429/5xx are transport-retryable (the transport resends the *original*
66
+ * headers), so those must never be recorded as working. Any non-2xx retry
67
+ * clears the entry instead: the next stream rediscovers rather than pinning a
68
+ * shape that just failed.
69
+ */
70
+ export declare function wrapFetchForCopilotFallback(base: FetchImpl | undefined, enabled: boolean, integrationId?: unknown, cacheKey?: string,
71
+ /**
72
+ * Build-time cache provenance: the exact cached value the outgoing headers
73
+ * were built from. `undefined` rereads the cache at dispatch (direct
74
+ * callers); `null` pins "cache was empty at build" so a sibling learning
75
+ * mid-flight cannot change this request's retry decision.
76
+ */
77
+ cacheSnapshot?: string | null): FetchImpl;
14
78
  export declare function inferCopilotInitiator(messages: unknown[]): CopilotInitiator;
15
79
  /** Check whether any message in the conversation contains image content. */
16
80
  export declare function hasCopilotVisionInput(messages: Message[]): boolean;
@@ -37,4 +101,10 @@ export declare function buildCopilotDynamicHeaders(params: {
37
101
  headers?: Record<string, string>;
38
102
  initiatorOverride?: CopilotInitiator;
39
103
  planTier?: string;
104
+ /** Enterprise login domain; Enterprise keeps the CLI identity that its private endpoint accepts. */
105
+ enterpriseUrl?: string;
106
+ /** Raw explicit identity; validated here, chat default when absent/invalid. */
107
+ integrationId?: unknown;
108
+ /** Learned working identity for this credential; explicit still wins, then this, then the defaults. */
109
+ cachedIntegrationId?: unknown;
40
110
  }): CopilotDynamicHeaders;
@@ -42,5 +42,5 @@ export interface OpenAICompletionsOptions extends StreamOptions {
42
42
  * assistant output commits the attempt.
43
43
  */
44
44
  export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
45
- export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined): AssistantMessage["usage"];
45
+ export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined, timestamp?: number): AssistantMessage["usage"];
46
46
  export declare function convertMessages(model: Model<"openai-completions">, context: Context, compat: ResolvedOpenAICompat): ChatCompletionMessageParam[];
@@ -65,6 +65,14 @@ export interface OpenAIRequestSetup {
65
65
  headers: Record<string, string>;
66
66
  query: Record<string, string> | undefined;
67
67
  requestHeaders: Record<string, string>;
68
+ /** Working-identity cache key for this credential+host; undefined off the Copilot path. */
69
+ copilotCacheKey: string | undefined;
70
+ /**
71
+ * Build-time cache provenance for the wrapper: the cached value the
72
+ * outgoing headers were built from, or `null` when the cache was empty at
73
+ * build. `undefined` off the Copilot path (wrapper rereads at dispatch).
74
+ */
75
+ copilotCacheSnapshot: string | null | undefined;
68
76
  }
69
77
  export declare function resolveOpenAIRequestSetup(model: OpenAIRequestSetupModel, options: OpenAIRequestSetupOptions): OpenAIRequestSetup;
70
78
  export declare function applyOpenAIServiceTier(params: {
@@ -1,4 +1,4 @@
1
- import type { ImageContent, Model, TextContent } from "../types.js";
1
+ import type { Api, ImageContent, Model, TextContent } from "../types.js";
2
2
  export declare const NON_VISION_IMAGE_PLACEHOLDER = "[image omitted: model does not support vision]";
3
3
  export declare function partitionVisionContent(content: ReadonlyArray<TextContent | ImageContent>, supportsImages: boolean): {
4
4
  textBlocks: TextContent[];
@@ -12,4 +12,18 @@ export declare function joinTextWithImagePlaceholder(text: string, omittedImages
12
12
  * misconfigured provider descriptors or user model entries (e.g. text-only
13
13
  * DashScope Qwen SKUs, DeepSeek models) whose endpoints reject `image_url`.
14
14
  */
15
- export declare function isOpenAICompletionsVisionSupported(model: Model<"openai-completions">): boolean;
15
+ export declare function isOpenAICompletionsVisionSupported(model: Model<"openai-completions" | "openrouter">): boolean;
16
+ /**
17
+ * Whether the transport that will carry `model` sends image content on the wire.
18
+ *
19
+ * The `pi-native` transport forwards the original context (images included) to
20
+ * the gateway, which resolves its own model server-side, so the Chat
21
+ * Completions guard below never runs client-side and the declared input
22
+ * applies. Otherwise the OpenAI Chat Completions path applies the text-only
23
+ * guard, as does the OpenRouter chat fallback (`PI_OPENROUTER_RESPONSES=0`,
24
+ * which dispatches `openrouter` models through `streamOpenAICompletions`);
25
+ * every other API ships the modalities the model declares. Callers that report
26
+ * or gate on the wire (for example the `omp models` table) read this
27
+ * predicate; declared capability reads `model.input`.
28
+ */
29
+ export declare function sendsImageInputOnWire(model: Model<Api>): boolean;
@@ -8,6 +8,7 @@ type GitHubCopilotLoginOptions = {
8
8
  allowEmpty?: boolean;
9
9
  }) => Promise<string>;
10
10
  onProgress?: (message: string) => void;
11
+ copilotIntegrationId?: unknown;
11
12
  signal?: AbortSignal;
12
13
  pollIntervalFloorMs?: number;
13
14
  pollIntervalScaleMs?: number;
@@ -195,6 +195,18 @@ export interface CodexCompactionMetadata {
195
195
  export interface CodexCompactionRequestContext extends CodexCompactionMetadata {
196
196
  operationId: string;
197
197
  }
198
+ /** Anthropic `compact_20260112` context-management edit (`compact-2026-01-12` beta). */
199
+ export interface AnthropicCompactionRequest {
200
+ /**
201
+ * Prompt input-token count at which the API compacts. The API enforces a
202
+ * 50,000-token floor and defaults to 150,000 when omitted.
203
+ */
204
+ triggerInputTokens?: number;
205
+ /** Stop after the compaction block instead of continuing the response. */
206
+ pauseAfterCompaction?: boolean;
207
+ /** Custom summarization prompt; replaces the API default entirely when set. */
208
+ instructions?: string;
209
+ }
198
210
  /** OpenAI's GPT-5.6+ explicit prompt-cache controls. */
199
211
  export interface OpenAIPromptCacheOptions {
200
212
  /** `explicit` disables OpenAI's automatic latest-message breakpoint. */
@@ -243,6 +255,15 @@ export interface StreamOptions {
243
255
  anthropicPrefixMismatchBehavior?: "drop_block" | "error";
244
256
  /** @internal Marks a replay-only Anthropic request that must use non-streaming `max_tokens: 0`. */
245
257
  anthropicCacheRefreshRequest?: boolean;
258
+ /**
259
+ * Anthropic server-side compaction (`compact-2026-01-12` beta). Sends the
260
+ * `compact_20260112` context-management edit so the API summarizes the
261
+ * prompt in-band once its input reaches the trigger; the resulting summary
262
+ * arrives as an {@link AnthropicCompactionPayload} on the assistant message.
263
+ * Ignored by every other provider and by Anthropic-compatible endpoints
264
+ * without context-management support.
265
+ */
266
+ anthropicCompaction?: AnthropicCompactionRequest;
246
267
  /**
247
268
  * Additional headers to include in provider requests.
248
269
  * These are merged on top of model-defined headers.
@@ -703,7 +724,32 @@ export interface AnthropicMessagePayload {
703
724
  name: string;
704
725
  }>;
705
726
  }
706
- export type ProviderPayload = OpenAIResponsesHistoryPayload | AnthropicMessagePayload;
727
+ /**
728
+ * Anthropic server-side compaction summary (`compact-2026-01-12` beta).
729
+ *
730
+ * Produced by the Anthropic provider on the assistant message of a request
731
+ * that streamed a `compaction` content block, and attached to the user-role
732
+ * compaction summary message that replaces the compacted history so the
733
+ * provider can replay the block verbatim: the API drops every block that
734
+ * precedes it. `content` is the plain-text summary, so every other provider
735
+ * reads the message text and ignores the payload.
736
+ */
737
+ export interface AnthropicCompactionPayload {
738
+ type: "anthropicCompaction";
739
+ /** Provider that produced the summary; only that provider replays it natively. */
740
+ provider: string;
741
+ content: string;
742
+ /** Opaque provider state the API attached to the block; replayed verbatim when present. */
743
+ encryptedContent?: string;
744
+ /**
745
+ * Harness-appended file metadata (`<files>` section) kept out of the
746
+ * byte-identical block. Replayed as a user message after the native block:
747
+ * the converter replaces the summary message with the block and skips its
748
+ * text, so without this the metadata would be invisible to this provider.
749
+ */
750
+ filesText?: string;
751
+ }
752
+ export type ProviderPayload = OpenAIResponsesHistoryPayload | AnthropicMessagePayload | AnthropicCompactionPayload;
707
753
  /** Provider-reported rewrite applied to request content before inference. */
708
754
  export interface ProviderInputTransformation {
709
755
  type: string;
@@ -72,3 +72,44 @@ export type DemotedThinkingCarrier = object & {
72
72
  };
73
73
  /** True for text blocks synthesized by cross-model thinking demotion. */
74
74
  export declare function isDemotedThinking(block: DemotedThinkingCarrier | null | undefined): boolean;
75
+ /**
76
+ * Marks an Anthropic wire message that was serialized from a source
77
+ * `role: "user"` message.
78
+ *
79
+ * The wire role alone cannot answer this. `convertAnthropicMessages` emits
80
+ * `role: "user"` for several things that are not a conversational turn:
81
+ * `developer` messages on models without mid-conversation `system` support,
82
+ * `tool_result` runs, and the synthetic `Continue.` pads inserted between
83
+ * adjacent assistants. Prompt-cache decimation counts conversational turns,
84
+ * so it reads this marker instead of guessing from wire content.
85
+ *
86
+ * Symbol-keyed so the marker never persists across the JSONL round-trip and
87
+ * never reaches the wire.
88
+ */
89
+ export declare const kConversationalUser: unique symbol;
90
+ /** Carries the conversational-user marker without exposing a string-keyed property. */
91
+ export type ConversationalUserCarrier = object & {
92
+ [kConversationalUser]?: boolean;
93
+ };
94
+ /** True for wire messages serialized from a source `role: "user"` message. */
95
+ export declare function isConversationalUser(message: ConversationalUserCarrier | null | undefined): boolean;
96
+ /**
97
+ * Marks a `role: "user"` message that `transformMessages` synthesized rather
98
+ * than one the user sent.
99
+ *
100
+ * The stale-tool-result note is deliberately emitted as `user` so no provider
101
+ * elevates untrusted tool output to instruction priority, which leaves it
102
+ * indistinguishable from a real turn by role alone. Prompt-cache decimation
103
+ * must not count it, or an orphan result appearing or disappearing in a
104
+ * compacted history shifts every later checkpoint.
105
+ *
106
+ * Symbol-keyed so the marker never persists across the JSONL round-trip and
107
+ * never reaches the wire.
108
+ */
109
+ export declare const kSyntheticUser: unique symbol;
110
+ /** Carries the synthetic-user marker without exposing a string-keyed property. */
111
+ export type SyntheticUserCarrier = object & {
112
+ [kSyntheticUser]?: boolean;
113
+ };
114
+ /** True for `user` messages synthesized by message transformation. */
115
+ export declare function isSyntheticUser(message: SyntheticUserCarrier | null | undefined): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.1.16",
3
+ "version": "18.1.18",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -124,11 +124,11 @@
124
124
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
125
125
  },
126
126
  "dependencies": {
127
- "@oh-my-pi/omptype": "18.1.16",
128
- "@oh-my-pi/pi-catalog": "18.1.16",
129
- "@oh-my-pi/pi-natives": "18.1.16",
130
- "@oh-my-pi/pi-utils": "18.1.16",
131
- "@oh-my-pi/pi-wire": "18.1.16"
127
+ "@oh-my-pi/omptype": "18.1.18",
128
+ "@oh-my-pi/pi-catalog": "18.1.18",
129
+ "@oh-my-pi/pi-natives": "18.1.18",
130
+ "@oh-my-pi/pi-utils": "18.1.18",
131
+ "@oh-my-pi/pi-wire": "18.1.18"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/bun": "^1.3.14"
@@ -45,7 +45,8 @@ export interface FinalizeResult {
45
45
  */
46
46
  export async function finalize(error: unknown, opts: FinalizeOptions = {}): Promise<FinalizeResult> {
47
47
  const aborted = opts.abortTracker ? opts.abortTracker.wasCallerAbort() : opts.signal?.aborted === true;
48
- const currentStatus = status(error) ?? opts.capturedErrorResponse?.status;
48
+ const errorStatus = status(error);
49
+ const currentStatus = errorStatus ?? opts.capturedErrorResponse?.status;
49
50
 
50
51
  let message: string;
51
52
  try {
@@ -55,11 +56,17 @@ export async function finalize(error: unknown, opts: FinalizeOptions = {}): Prom
55
56
  message = error instanceof Error ? error.message : String(error);
56
57
  }
57
58
 
59
+ // A captured status is transport context for the original error. Put it at
60
+ // the root of the classification cause chain so terminal 4xx policy governs
61
+ // nested diagnostics before they can contribute transient flags.
62
+ const classificationError =
63
+ errorStatus === undefined && currentStatus !== undefined ? { status: currentStatus, cause: error } : error;
64
+
58
65
  const id = classifyMessage({
59
66
  api: opts.api,
60
67
  provider: opts.provider,
61
68
  model: opts.model,
62
- errorId: classify(error, opts.api),
69
+ errorId: classify(classificationError, opts.api),
63
70
  errorMessage: message,
64
71
  errorStatus: currentStatus,
65
72
  });
@@ -156,6 +156,13 @@ const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream
156
156
  const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
157
157
  const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|stop)/i;
158
158
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
159
+ /** Python h2/httpx diagnostics forwarded through provider or proxy error events. */
160
+ export const PYTHON_HTTP2_STREAM_RESET_PATTERN = /<StreamReset stream_id:\d+, error_code:(?:2|7), remote_reset:True>/;
161
+ /** Python h11/httpx EOF while reading an HTTP/1.1 chunked response body. */
162
+ export const PYTHON_HTTP_INCOMPLETE_CHUNK_PATTERN =
163
+ /peer closed connection without sending complete message body \(incomplete chunked read\)/;
164
+ /** reqwest body-frame failures forwarded by the Codex HTTP proxy. */
165
+ export const CODEX_HTTP_BODY_READ_ERROR_PATTERN = /\btransport error reading codex response body\b/i;
159
166
  export const TRANSIENT_TRANSPORT_PATTERN =
160
167
  /\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;
161
168
  const AUTH_FAILURE_PATTERN =
@@ -396,6 +403,8 @@ function isTransientErrorText(text: string): boolean {
396
403
  return (
397
404
  isUnexpectedSocketCloseMessage(text) ||
398
405
  isStreamReadErrorText(text) ||
406
+ PYTHON_HTTP2_STREAM_RESET_PATTERN.test(text) ||
407
+ PYTHON_HTTP_INCOMPLETE_CHUNK_PATTERN.test(text) ||
399
408
  (TRANSIENT_ENVELOPE_PATTERN.test(text) && TRANSIENT_ENVELOPE_TRUNCATION_PATTERN.test(text)) ||
400
409
  TRANSIENT_TRANSPORT_PATTERN.test(text)
401
410
  );
@@ -432,6 +441,17 @@ function matchesOverflowText(text: string): boolean {
432
441
  return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
433
442
  }
434
443
 
444
+ /**
445
+ * A 4xx the provider rejected as a deterministic client error — every 4xx
446
+ * except 408 (Request Timeout) and 429 (Too Many Requests), the retryable
447
+ * pair. Mirrors the 4xx policy in {@link isProviderRetryableError}: such a
448
+ * request replays identically, so a transient signal riding on it (e.g. a
449
+ * truncation phrase in the body) must not flip it to retryable.
450
+ */
451
+ function isTerminalClientErrorStatus(status: number | undefined): boolean {
452
+ return status !== undefined && status >= 400 && status < 500 && status !== 408 && status !== 429;
453
+ }
454
+
435
455
  function classifyText(
436
456
  errorMessage: string | undefined,
437
457
  errorStatus: number | undefined,
@@ -483,6 +503,24 @@ function classifyText(
483
503
  }
484
504
  if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout;
485
505
  else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient;
506
+ // A stream truncation or forwarded Codex HTTP body-read failure may not
507
+ // match TRANSIENT_TRANSPORT_PATTERN. Flag it explicitly so AIError.retriable and
508
+ // the turn-recovery layer treat it as retryable, matching the provider
509
+ // retry path (isProviderRetryableError). Separate `if` (not chained onto
510
+ // the else-if) so a timeout whose text also reads as a truncation keeps
511
+ // Flag.Timeout alongside Flag.Transient. The string arm applies the strict
512
+ // STREAM_PARSE_DIAGNOSTIC_PATTERN, per the rationale on isTransientStreamParseError.
513
+ // Skip a truncation phrase that rides on a terminal 4xx (e.g. a malformed
514
+ // request rejected as "400 unexpected EOF"): that is a deterministic client
515
+ // error that replays identically, so keep it terminal. classify() carries
516
+ // the outer terminal status down the cause chain so a wrapped truncation
517
+ // (ProviderHttpError 400 → cause "unexpected EOF") is caught here too.
518
+ if (
519
+ !isTerminalClientErrorStatus(statusClean) &&
520
+ (isTransientStreamParseError(errorMessage) || CODEX_HTTP_BODY_READ_ERROR_PATTERN.test(errorMessage))
521
+ ) {
522
+ kinds |= Flag.Transient;
523
+ }
486
524
  // A concurrency cap (e.g. Vertex "Online prediction concurrent requests
487
525
  // quota exceeded") is transient — shed-and-backoff. The bare wording need
488
526
  // not match TRANSIENT_TRANSPORT_PATTERN, so flag it explicitly to keep
@@ -521,6 +559,11 @@ export function classify(error: unknown, api?: Api): number {
521
559
  const seen = new Set<object>();
522
560
  const causeTokenEvidence = hasCauseTokenContextOverflowEvidence(error);
523
561
  let link: unknown = error;
562
+ // A terminal 4xx on an outer link governs its own cause diagnostics: a
563
+ // wrapped truncation is describing why the deterministic request failed,
564
+ // not an independently retryable transport fault. Carry it down so the
565
+ // stream-parse guard in classifyText sees it on the status-less cause.
566
+ let governingTerminalStatus: number | undefined;
524
567
  while (link !== undefined && link !== null) {
525
568
  if (typeof link === "object") {
526
569
  if (seen.has(link)) break;
@@ -595,8 +638,10 @@ export function classify(error: unknown, api?: Api): number {
595
638
  linkMessage = (link as { message: string }).message;
596
639
  }
597
640
 
598
- const textId = classifyText(linkMessage, status(link), causeTokenEvidence, api);
641
+ const linkStatus = status(link);
642
+ const textId = classifyText(linkMessage, linkStatus ?? governingTerminalStatus, causeTokenEvidence, api);
599
643
  kinds |= textId & KIND_MASK;
644
+ if (isTerminalClientErrorStatus(linkStatus)) governingTerminalStatus = linkStatus;
600
645
 
601
646
  link = typeof link === "object" && "cause" in link ? (link as { cause: unknown }).cause : undefined;
602
647
  }
@@ -1,5 +1,6 @@
1
1
  import { isRetryableError, isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils";
2
2
  import {
3
+ CODEX_HTTP_BODY_READ_ERROR_PATTERN,
3
4
  isRetryableStreamEnvelopeError,
4
5
  isTransientStreamParseError,
5
6
  isUsageLimit,
@@ -51,6 +52,7 @@ export function isProviderRetryableError(error: unknown): boolean {
51
52
  isUnexpectedSocketCloseMessage(msg) ||
52
53
  isTransientTransportMessage(msg) ||
53
54
  TRANSIENT_TRANSPORT_PATTERN.test(msg) ||
55
+ CODEX_HTTP_BODY_READ_ERROR_PATTERN.test(msg) ||
54
56
  PROVIDER_TRANSIENT_EXTRA_PATTERN.test(msg) ||
55
57
  isTransientStreamParseError(error) ||
56
58
  isRetryableStreamEnvelopeError(error)