@oh-my-pi/pi-ai 18.2.5 → 18.2.7

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 (50) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/auth-retry.d.ts +2 -0
  4. package/dist/types/auth-storage.d.ts +2 -2
  5. package/dist/types/error/classes.d.ts +4 -1
  6. package/dist/types/error/flags.d.ts +12 -4
  7. package/dist/types/index.d.ts +14 -12
  8. package/dist/types/judgment/types.d.ts +5 -2
  9. package/dist/types/judgment/typesafe.d.ts +16 -22
  10. package/dist/types/providers/anthropic-compaction.d.ts +10 -0
  11. package/dist/types/providers/anthropic-identity.d.ts +18 -0
  12. package/dist/types/providers/anthropic-state.d.ts +13 -0
  13. package/dist/types/providers/anthropic.d.ts +6 -59
  14. package/dist/types/providers/gitlab-duo.d.ts +0 -1
  15. package/dist/types/providers/kimi.d.ts +1 -5
  16. package/dist/types/providers/openai-codex-attestation.d.ts +6 -0
  17. package/dist/types/providers/openai-codex-compaction.d.ts +6 -0
  18. package/dist/types/providers/openai-codex-responses.d.ts +3 -27
  19. package/dist/types/providers/openai-codex-transport.d.ts +7 -0
  20. package/dist/types/providers/openai-shared.d.ts +0 -9
  21. package/dist/types/providers/register-builtins.d.ts +20 -22
  22. package/dist/types/providers/synthetic.d.ts +1 -5
  23. package/package.json +6 -6
  24. package/src/auth-broker/discover.ts +5 -3
  25. package/src/auth-retry.ts +3 -0
  26. package/src/auth-storage.ts +28 -22
  27. package/src/error/auth-classify.ts +10 -2
  28. package/src/error/classes.ts +23 -3
  29. package/src/error/flags.ts +51 -16
  30. package/src/index.ts +15 -12
  31. package/src/judgment/types.ts +6 -3
  32. package/src/judgment/typesafe.ts +53 -48
  33. package/src/provider-session-state.ts +1 -1
  34. package/src/providers/anthropic-compaction.ts +54 -0
  35. package/src/providers/anthropic-identity.ts +136 -0
  36. package/src/providers/anthropic-state.ts +55 -0
  37. package/src/providers/anthropic.ts +157 -326
  38. package/src/providers/bedrock-mantle.ts +1 -1
  39. package/src/providers/gitlab-duo.ts +0 -4
  40. package/src/providers/kimi.ts +1 -8
  41. package/src/providers/openai-codex-attestation.ts +19 -0
  42. package/src/providers/openai-codex-compaction.ts +18 -0
  43. package/src/providers/openai-codex-responses.ts +9 -68
  44. package/src/providers/openai-codex-transport.ts +18 -0
  45. package/src/providers/openai-shared.ts +1 -10
  46. package/src/providers/register-builtins.ts +113 -320
  47. package/src/providers/synthetic.ts +1 -8
  48. package/src/registry/cloudflare-ai-gateway.ts +1 -1
  49. package/src/stream.ts +7 -15
  50. package/src/utils/anthropic-auth.ts +3 -6
@@ -10,7 +10,6 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
10
10
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
11
11
  import {
12
12
  $env,
13
- getInstallId,
14
13
  isEnoent,
15
14
  logger,
16
15
  parseJsonWithRepair,
@@ -129,6 +128,43 @@ import { getOpenAIPromptCacheKey } from "./openai-shared";
129
128
  import { applyInferenceHeaders } from "./inference-headers";
130
129
  import { redactSensitiveCredentials, transformMessages } from "./transform-messages";
131
130
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
131
+ import {
132
+ injectedClientBaseUrl,
133
+ resolvesToOfficialAnthropicEndpoint,
134
+ supportsAnthropicCompaction,
135
+ supportsAnthropicCompactionOnClient,
136
+ } from "./anthropic-compaction";
137
+ import {
138
+ applyClaudeToolPrefix,
139
+ deriveClaudeDeviceId,
140
+ extractClaudeMetadataSessionId,
141
+ generateClaudeCloakingUserId,
142
+ isClaudeCloakingUserId,
143
+ readAnthropicMetadataAccountId,
144
+ readAnthropicMetadataString,
145
+ resolveAnthropicMetadataUserId,
146
+ stripClaudeToolPrefix,
147
+ } from "./anthropic-identity";
148
+ import {
149
+ anthropicProviderSessionStateKey,
150
+ clearAnthropicFastModeFallback,
151
+ isAnthropicFastModeFallbackDisabled,
152
+ normalizeAnthropicBaseUrl,
153
+ resolveDirectAnthropicBaseUrl,
154
+ } from "./anthropic-state";
155
+
156
+ export {
157
+ applyClaudeToolPrefix,
158
+ clearAnthropicFastModeFallback,
159
+ deriveClaudeDeviceId,
160
+ generateClaudeCloakingUserId,
161
+ isAnthropicFastModeFallbackDisabled,
162
+ isClaudeCloakingUserId,
163
+ normalizeAnthropicBaseUrl,
164
+ resolveAnthropicMetadataUserId,
165
+ stripClaudeToolPrefix,
166
+ };
167
+ export { resolvesToOfficialAnthropicEndpoint, supportsAnthropicCompaction, supportsAnthropicCompactionOnClient };
132
168
 
133
169
  export type AnthropicHeaderOptions = {
134
170
  apiKey: string;
@@ -144,15 +180,6 @@ export type AnthropicHeaderOptions = {
144
180
  allowAnthropicHeaderOverrides?: boolean;
145
181
  };
146
182
 
147
- export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
148
- const trimmed = baseUrl?.trim();
149
- if (!trimmed) {
150
- return undefined;
151
- }
152
- const withoutTrailingSlashes = trimmed.replace(/\/+$/, "");
153
- return withoutTrailingSlashes.endsWith("/v1") ? withoutTrailingSlashes.slice(0, -3) : withoutTrailingSlashes;
154
- }
155
-
156
183
  // Build deduplicated beta header string
157
184
  export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readonly string[]): string {
158
185
  const seen = new Set<string>();
@@ -416,8 +443,6 @@ type AnthropicOutputConfig = NonNullable<MessageCreateParamsStreaming["output_co
416
443
  const ANTHROPIC_STOP_SEQUENCES_MAX = 4;
417
444
  let warnedStopSequencesTrim = false;
418
445
 
419
- const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
420
-
421
446
  /**
422
447
  * A mid-conversation `role: "system"` message omp inserts at a fixed slot in
423
448
  * the wire history so top-level `tools` and `output_config.effort` can stay
@@ -504,18 +529,6 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
504
529
  return state;
505
530
  }
506
531
 
507
- /**
508
- * Key the sticky strict-tools / fast-mode learning per endpoint+model. A
509
- * grammar-too-large 400 or a fast-mode rejection is specific to the model (its
510
- * tool grammar / entitlement) and the endpoint (direct Anthropic vs a gateway /
511
- * Foundry / Bedrock proxy), so it MUST NOT bleed onto unrelated anthropic-messages
512
- * requests in the same session. NUL separates the two components so neither can
513
- * forge the boundary.
514
- */
515
- function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string {
516
- return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`;
517
- }
518
-
519
532
  function getAnthropicProviderSessionState(
520
533
  providerSessionState: Map<string, ProviderSessionState> | undefined,
521
534
  baseUrl: string,
@@ -534,41 +547,6 @@ function getAnthropicProviderSessionState(
534
547
  return created;
535
548
  }
536
549
 
537
- /**
538
- * Clears the in-session "server rejected fast mode" sticky flag. Call when the
539
- * caller is explicitly re-arming `serviceTier: "priority"` (e.g. user toggled
540
- * `/fast on` after a previous turn auto-disabled it) so the next request
541
- * actually carries `speed: "fast"` again. No-op when the map or state entry
542
- * hasn't been materialized yet.
543
- */
544
- export function clearAnthropicFastModeFallback(
545
- providerSessionState: Map<string, ProviderSessionState> | undefined,
546
- ): void {
547
- if (!providerSessionState) return;
548
- // Fast mode is re-armed session-wide (user toggled `/fast on`), so clear the
549
- // sticky flag on every per-endpoint/model Anthropic entry — plus the legacy
550
- // unscoped key — rather than a single shared object.
551
- const prefix = `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:`;
552
- for (const [key, value] of providerSessionState) {
553
- if (key !== ANTHROPIC_PROVIDER_SESSION_STATE_KEY && !key.startsWith(prefix)) continue;
554
- (value as AnthropicProviderSessionState).fastModeDisabled = false;
555
- }
556
- }
557
- /**
558
- * Whether the direct Anthropic model's endpoint-scoped fast-mode fallback is
559
- * currently active. Reading the map directly is intentional: inspection must
560
- * not materialize a state entry for a model that has never streamed.
561
- */
562
- export function isAnthropicFastModeFallbackDisabled(
563
- providerSessionState: Map<string, ProviderSessionState> | undefined,
564
- model: Model<Api>,
565
- ): boolean {
566
- if (!providerSessionState || model.provider !== "anthropic" || model.api !== "anthropic-messages") return false;
567
- const baseUrl = resolveAnthropicBaseUrl(model as Model<"anthropic-messages">) ?? "https://api.anthropic.com";
568
- const key = anthropicProviderSessionStateKey(baseUrl, model.id);
569
- return (providerSessionState.get(key) as AnthropicProviderSessionState | undefined)?.fastModeDisabled ?? false;
570
- }
571
-
572
550
  function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
573
551
  return params.tools?.some(tool => tool.strict === true) ?? false;
574
552
  }
@@ -760,145 +738,8 @@ export function wrapFetchForCch(base: FetchImpl): FetchImpl {
760
738
  };
761
739
  }
762
740
 
763
- const CLAUDE_CLOAKING_USER_ID_REGEX =
764
- /^user_[0-9a-fA-F]{64}_account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
765
-
766
- export function isClaudeCloakingUserId(userId: string): boolean {
767
- return CLAUDE_CLOAKING_USER_ID_REGEX.test(userId);
768
- }
769
-
770
- /**
771
- * Real Claude Code sends `metadata.user_id` as a JSON-stringified object of the
772
- * shape `{ device_id, account_uuid, session_id, ...extra }` (see
773
- * services/api/claude.ts → getAPIMetadata). Accept that shape so callers that
774
- * supply a stable `session_id` aren't silently overwritten with fresh entropy
775
- * on every request, which would inflate the backend session count.
776
- */
777
- function isClaudeJsonUserId(userId: string): boolean {
778
- if (userId.length === 0 || userId[0] !== "{") return false;
779
- let parsed: unknown;
780
- try {
781
- parsed = JSON.parse(userId);
782
- } catch {
783
- return false;
784
- }
785
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
786
- const obj = parsed as Record<string, unknown>;
787
- return typeof obj.session_id === "string" && obj.session_id.length > 0;
788
- }
789
-
790
- function extractClaudeMetadataSessionId(userId: unknown): string | undefined {
791
- if (typeof userId !== "string") return undefined;
792
- if (isClaudeCloakingUserId(userId)) {
793
- return userId.slice(userId.lastIndexOf("_session_") + "_session_".length);
794
- }
795
- if (userId.length === 0 || userId[0] !== "{") return undefined;
796
- let parsed: unknown;
797
- try {
798
- parsed = JSON.parse(userId);
799
- } catch {
800
- return undefined;
801
- }
802
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
803
- const sessionId = (parsed as Record<string, unknown>).session_id;
804
- return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined;
805
- }
806
-
807
- export function generateClaudeCloakingUserId(): string {
808
- const userHash = nodeCrypto.randomBytes(32).toString("hex");
809
- const accountId = nodeCrypto.randomUUID().toLowerCase();
810
- const sessionId = nodeCrypto.randomUUID().toLowerCase();
811
- return `user_${userHash}_account_${accountId}_session_${sessionId}`;
812
- }
813
-
814
- const CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN = "omp-claude-device-id-v1:";
815
- const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2";
816
-
817
- export function deriveClaudeDeviceId(installId: string, accountId?: string): string {
818
- const hash = nodeCrypto.createHash("sha256");
819
- if (accountId && accountId.length > 0) {
820
- return hash
821
- .update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN)
822
- .update("\0")
823
- .update(installId)
824
- .update("\0")
825
- .update(accountId)
826
- .digest("hex");
827
- }
828
- return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex");
829
- }
830
-
831
- function readMetadataString(metadata: Record<string, unknown> | undefined, key: string): string | undefined {
832
- const value = metadata?.[key];
833
- return typeof value === "string" && value.length > 0 ? value : undefined;
834
- }
835
-
836
- function readAnthropicMetadataAccountId(metadata: Record<string, unknown> | undefined): string | undefined {
837
- return (
838
- readMetadataString(metadata, "account_uuid") ??
839
- readMetadataString(metadata, "accountId") ??
840
- readMetadataString(metadata, "account_id")
841
- );
842
- }
843
-
844
- function deriveClaudeDeviceIdFromInstallId(accountId?: string): string {
845
- return deriveClaudeDeviceId(getInstallId(), accountId);
846
- }
847
-
848
- function generateClaudeJsonUserId(sessionId?: string, accountId?: string): string {
849
- const userId: Record<string, string> = {
850
- device_id: deriveClaudeDeviceIdFromInstallId(accountId),
851
- session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(),
852
- };
853
- if (accountId && accountId.length > 0) userId.account_uuid = accountId;
854
- return JSON.stringify(userId);
855
- }
856
-
857
- /**
858
- * Resolve the `metadata.user_id` field for an Anthropic Messages request.
859
- *
860
- * For API-key tokens, an explicit caller-supplied `userId` is forwarded
861
- * verbatim and `undefined` yields no metadata. For OAuth tokens the value
862
- * must match the Claude Code attribution shape (`isClaudeCloakingUserId` or
863
- * the `{session_id, account_uuid?, device_id?}` JSON envelope) — anything
864
- * else is dropped and a fresh Claude-Code-style JSON id is generated from
865
- * `sessionId`/`accountId` so attribution stays consistent across the main
866
- * streaming path and provider-specific request builders (e.g. web search).
867
- */
868
- export function resolveAnthropicMetadataUserId(
869
- userId: unknown,
870
- isOAuthToken: boolean,
871
- sessionId?: string,
872
- accountId?: string,
873
- ): string | undefined {
874
- if (typeof userId === "string") {
875
- if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) {
876
- return userId;
877
- }
878
- }
879
-
880
- if (!isOAuthToken) return undefined;
881
- return generateClaudeJsonUserId(sessionId, accountId);
882
- }
883
- const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]);
884
741
  const UMANS_WEBSEARCH_PROVIDER_HEADER = "X-Umans-Websearch-Provider";
885
742
  const UMANS_WEBSEARCH_TOOL_NAME = "web_search";
886
- export const applyClaudeToolPrefix = (name: string): string => {
887
- if (!claudeToolPrefix) return name;
888
- if (ANTHROPIC_BUILTIN_TOOL_NAMES.has(name.toLowerCase())) return name;
889
- // Always prepend (no "already prefixed" short-circuit): the prefix is a wire
890
- // transport detail applied once to internal tool names, and `stripClaudeToolPrefix`
891
- // removes exactly one prefix on receive. Skipping names that already start with the
892
- // prefix would make a tool literally named `_foo` lose its leading underscore on the
893
- // return trip (`_foo` → wire `_foo` → strip → `foo`), so the agent loop can't find it.
894
- return `${claudeToolPrefix}${name}`;
895
- };
896
-
897
- export const stripClaudeToolPrefix = (name: string): string => {
898
- if (!claudeToolPrefix) return name;
899
- if (!name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name;
900
- return name.slice(claudeToolPrefix.length);
901
- };
902
743
 
903
744
  function normalizeUmansWebSearchProvider(value: string | undefined): "native" | "exa" | undefined {
904
745
  const normalized = value?.trim().toLowerCase();
@@ -1327,22 +1168,7 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
1327
1168
  if (model.provider === "github-copilot") {
1328
1169
  return normalizeAnthropicBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl);
1329
1170
  }
1330
- if (model.provider === "anthropic" && isFoundryEnabled()) {
1331
- const foundryBaseUrl = normalizeAnthropicBaseUrl($env.FOUNDRY_BASE_URL);
1332
- if (foundryBaseUrl) {
1333
- return foundryBaseUrl;
1334
- }
1335
- }
1336
- if (model.provider === "anthropic") {
1337
- const configured = normalizeAnthropicBaseUrl(model.baseUrl);
1338
- // An explicitly configured non-official baseUrl (e.g. a models.yml provider
1339
- // override) is more specific than the generic env fallback and wins.
1340
- if (configured && !isOfficialAnthropicApiUrl(configured)) return configured;
1341
- // Otherwise ANTHROPIC_BASE_URL routes chat through an enterprise gateway
1342
- // (docs/environment-variables.md), ahead of the official default. The
1343
- // Foundry redirect is already handled above.
1344
- return normalizeAnthropicBaseUrl($env.ANTHROPIC_BASE_URL) ?? configured ?? "https://api.anthropic.com";
1345
- }
1171
+ if (model.provider === "anthropic") return resolveDirectAnthropicBaseUrl(model);
1346
1172
  return normalizeAnthropicBaseUrl(model.baseUrl);
1347
1173
  }
1348
1174
 
@@ -1791,89 +1617,6 @@ function parseAnthropicFallbackWireBlock(value: unknown): AnthropicFallbackConte
1791
1617
 
1792
1618
  const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50_000;
1793
1619
 
1794
- /**
1795
- * Whether this model's requests reach the official Anthropic API, resolved the
1796
- * way the transport resolves it — including the Foundry and
1797
- * `ANTHROPIC_BASE_URL` reroutes that leave `compat.officialEndpoint` stale.
1798
- */
1799
- export function resolvesToOfficialAnthropicEndpoint(model: Model<"anthropic-messages">): boolean {
1800
- return isOfficialAnthropicApiUrl(resolveAnthropicBaseUrl(model));
1801
- }
1802
-
1803
- /**
1804
- * Whether server-side compaction (`compact-2026-01-12`) may be spoken for
1805
- * this model to the endpoint a request actually reaches: a model line the
1806
- * beta supports (`compat.supportsServerCompaction`, rule-owned in the
1807
- * catalog), on the official API for the first-party provider or on any
1808
- * endpoint that opted in through `remoteCompaction.enabled`, and never on one
1809
- * whose deployment contract excludes context management. The same predicate
1810
- * gates emitting the edit, attaching the beta, and replaying a persisted
1811
- * block, so a route or model change can never leave a session sending a block
1812
- * its endpoint rejects.
1813
- */
1814
- export function supportsAnthropicCompaction(model: Model<"anthropic-messages">, effectiveBaseUrl?: string): boolean {
1815
- if (!isCompactionCapableModel(model)) return false;
1816
- if (model.remoteCompaction?.enabled === true) return true;
1817
- // First-party provider is catalog policy (`first-party-provider` on the
1818
- // provider rules), never a provider-id literal. It reads its own axis
1819
- // rather than `officialEndpoint`, which stays URL-derived.
1820
- // A `transport: "pi-native"` baseUrl names the auth gateway, not the
1821
- // upstream model server: the gateway resolves the model's own provider
1822
- // server-side, so the upstream URL check cannot apply to the model's own
1823
- // transport URL — but only for the KDL-owned first-party deployment. A
1824
- // custom provider travels the same gateway to its own upstream, whose
1825
- // server-side gate stays off (unless `remoteCompaction.enabled` opts the
1826
- // route in above). An explicitly supplied foreign endpoint (e.g. a
1827
- // caller-owned client's URL) is still judged on its own merits below.
1828
- if (
1829
- model.transport === "pi-native" &&
1830
- model.compat.firstPartyProvider === true &&
1831
- (effectiveBaseUrl === undefined || effectiveBaseUrl === normalizeAnthropicBaseUrl(model.baseUrl))
1832
- ) {
1833
- return true;
1834
- }
1835
- return (
1836
- model.compat.firstPartyProvider === true &&
1837
- (effectiveBaseUrl === undefined
1838
- ? resolvesToOfficialAnthropicEndpoint(model)
1839
- : isOfficialAnthropicApiUrl(effectiveBaseUrl))
1840
- );
1841
- }
1842
-
1843
- /**
1844
- * {@link supportsAnthropicCompaction} for a request on a caller-owned client:
1845
- * the endpoint is whatever the client targets (an `AnthropicVertex` client
1846
- * carries an Anthropic model to Vertex), never the model's own routing. SDK
1847
- * clients expose it as `baseURL`; a client that exposes no endpoint only
1848
- * compacts through an explicit `remoteCompaction.enabled` opt-in.
1849
- */
1850
- export function supportsAnthropicCompactionOnClient(
1851
- model: Model<"anthropic-messages">,
1852
- client: AnthropicMessagesClientLike,
1853
- ): boolean {
1854
- const baseURL = injectedClientBaseUrl(client);
1855
- if (baseURL !== undefined) return supportsAnthropicCompaction(model, baseURL);
1856
- return isCompactionCapableModel(model) && model.remoteCompaction?.enabled === true;
1857
- }
1858
-
1859
- /**
1860
- * Effective endpoint of a caller-owned client. SDK clients expose it as
1861
- * `baseURL`; a client that exposes none leaves routing to the model.
1862
- */
1863
- function injectedClientBaseUrl(client: AnthropicMessagesClientLike): string | undefined {
1864
- const baseURL = (client as { baseURL?: unknown }).baseURL;
1865
- return typeof baseURL === "string" && baseURL.length > 0 ? baseURL : undefined;
1866
- }
1867
-
1868
- /** The model-side half of the gate: lineage support and a deployment contract that allows it. */
1869
- function isCompactionCapableModel(model: Model<"anthropic-messages">): boolean {
1870
- return (
1871
- model.compat.supportsServerCompaction === true &&
1872
- model.compat.supportsContextManagement !== false &&
1873
- model.remoteCompaction?.enabled !== false
1874
- );
1875
- }
1876
-
1877
1620
  /**
1878
1621
  * Whether a persisted compaction summary replays as a native `compaction`
1879
1622
  * block: only the provider that produced it may replay it, and only on a
@@ -3954,8 +3697,17 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3954
3697
  const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
3955
3698
 
3956
3699
  // A breakpoint caches every preceding byte, not only the decorated message.
3957
- // Once per-call or turn-scoped content appears, no later message can anchor a
3958
- // prefix reusable by the next request.
3700
+ // A per-call or turn-scoped message is rebuilt next request, so a prefix
3701
+ // spanning it cannot match — but only at its own position. Messages after
3702
+ // the mark are ordinary persisted history with stable bytes, so a later
3703
+ // breakpoint still matches everything after the mark. The cost is bounded
3704
+ // to re-billing the marked bytes themselves, not the growing tail.
3705
+ // Hence two anchors, not a truncation: the newest candidate at or before
3706
+ // the first per-call/turn-scoped message (when one exists) pins the
3707
+ // reusable prefix behind the mark, and the rolling tail candidates pin
3708
+ // the suffix after it. Turn-scoped `clear_at` messages are absent next
3709
+ // request, so they still truncate the decimation range (ordinals would
3710
+ // shift), but per-call marks no longer freeze the tail.
3959
3711
  let stableMessageEnd = messageEnd;
3960
3712
  for (let index = 0; index <= messageEnd; index++) {
3961
3713
  const message = params.messages[index];
@@ -3980,17 +3732,21 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3980
3732
  // Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
3981
3733
  const decimationIndices = userIndices.filter((_, ordinal) => (ordinal + 1) % ANTHROPIC_DECIMATION_INTERVAL === 0);
3982
3734
 
3983
- // Collect up to 2 trailing candidates from the reusable prefix, skipping
3984
- // mid-conversation tool-control messages. They contain only tool_addition /
3985
- // tool_removal blocks, so cache_control is always rejected there; parking
3986
- // the rolling window on one spends the tail breakpoint on a decoration
3987
- // that always fails, and with decimation checkpoints present the remaining
3988
- // breakpoints land on already-cached history while the growing tail is
3989
- // re-billed as uncached input every turn.
3735
+ // Collect up to 2 trailing candidates from the message tail, skipping
3736
+ // per-call messages, turn-scoped messages, and mid-conversation
3737
+ // tool-control messages. A per-call tail candidate is rebuilt next request
3738
+ // (fresh timestamps on appended probes, fresh redaction bytes), so a
3739
+ // breakpoint on it cannot match — it would spend the tail anchor on bytes
3740
+ // that never repeat while the persisted history behind it goes uncached.
3741
+ // Turn-scoped messages are absent next request for the same reason, and
3742
+ // tool controls reject cache_control outright. The walk starts at the
3743
+ // message tail (not the truncated prefix end) so the anchor advances every
3744
+ // turn; the sub-prefix candidate below covers the reusable region behind
3745
+ // a mark.
3990
3746
  const trailingCandidates: number[] = [];
3991
- for (let index = stableMessageEnd; index >= 0 && trailingCandidates.length < 2; index--) {
3747
+ for (let index = messageEnd; index >= 0 && trailingCandidates.length < 2; index--) {
3992
3748
  const message = params.messages[index];
3993
- if (!message) continue;
3749
+ if (!message || message.clear_at === "next_user_message" || isPerCallContextMessage(message)) continue;
3994
3750
  if (
3995
3751
  message.role === "system" &&
3996
3752
  typeof message.content !== "string" &&
@@ -4002,11 +3758,13 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4002
3758
  }
4003
3759
  trailingCandidates.push(index);
4004
3760
  }
4005
-
4006
3761
  // Prioritize:
4007
3762
  // 1. Most recent trailing message
4008
3763
  // 2. Latest decimation checkpoints (newest first) to maintain stable long-context anchors
4009
- // 3. Second trailing message
3764
+ // 3. Newest message at or before the first per-call/turn-scoped mark, so a
3765
+ // volatile interior message costs only its own re-billed bytes instead
3766
+ // of invalidating the whole reusable prefix behind it
3767
+ // 4. Second trailing message
4010
3768
  const candidateIndices: number[] = [];
4011
3769
  if (trailingCandidates.length > 0) {
4012
3770
  candidateIndices.push(trailingCandidates[0]);
@@ -4016,6 +3774,9 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4016
3774
  candidateIndices.push(decimationIndices[i]);
4017
3775
  }
4018
3776
  }
3777
+ if (stableMessageEnd < messageEnd && stableMessageEnd >= 0 && !candidateIndices.includes(stableMessageEnd)) {
3778
+ candidateIndices.push(stableMessageEnd);
3779
+ }
4019
3780
  for (const index of trailingCandidates) {
4020
3781
  if (!candidateIndices.includes(index)) {
4021
3782
  candidateIndices.push(index);
@@ -4034,17 +3795,57 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4034
3795
  }
4035
3796
  }
4036
3797
 
3798
+ /**
3799
+ * Trailing system-prompt segments carrying per-turn volatile content (memory
3800
+ * recall blocks). They are rendered by the coding agent as their own
3801
+ * `systemPrompt` array elements and appended last, so on the wire they
3802
+ * normally form a volatile suffix after the stable prefix. The system cache
3803
+ * breakpoint anchors on the last stable segment instead of the array tail, so
3804
+ * a recall refresh re-bills only the suffix and the message tail for one turn
3805
+ * while the tools+stable-system prefix stays a cache hit. The fingerprint in
3806
+ * `planStableAnthropicSystem` is scoped the same way, so a recall-only change
3807
+ * no longer resets the tool/control baselines either.
3808
+ *
3809
+ * Only a genuinely trailing volatile run counts: a `before_agent_start`
3810
+ * extension override may append a stable policy block after the staged recall
3811
+ * block, and that block must stay fingerprinted stable (a change to it has to
3812
+ * re-baseline). A volatile block stranded mid-array still poisons the prefix
3813
+ * at its position — prefix caching is positional, so no classification can
3814
+ * save the bytes after it — but the stable tail is at least fingerprinted
3815
+ * instead of silently excluded.
3816
+ *
3817
+ * Detection is by our own markup, not model identity: recall blocks always
3818
+ * open with `<memories>`. Stable segments containing recalled text elsewhere
3819
+ * (e.g. quoted in conversation) are unaffected — only a leading tag counts.
3820
+ */
3821
+ const VOLATILE_SYSTEM_SEGMENT_MARKERS = ["<memories>"];
3822
+
3823
+ function stableSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]): number {
3824
+ let start = systemBlocks.length;
3825
+ while (start > 0) {
3826
+ const text = systemBlocks[start - 1]?.text ?? "";
3827
+ if (!VOLATILE_SYSTEM_SEGMENT_MARKERS.some(marker => text.startsWith(marker))) break;
3828
+ start--;
3829
+ }
3830
+ return start;
3831
+ }
3832
+
4037
3833
  /**
4038
3834
  * Anchor cache_control on the stable request head — the last (non-deferred)
4039
- * tool definition and the last system block. The canonical cache order is
4040
- * tools → system → messages, so a breakpoint on the final system block caches
4041
- * the entire tools+system prefix, and the extra tool breakpoint keeps the tool
3835
+ * tool definition and the last stable system block. The canonical cache order is
3836
+ * tools → system → messages, so a breakpoint on the final stable system block caches
3837
+ * the entire tools+stable-system prefix, and the extra tool breakpoint keeps the tool
4042
3838
  * definitions cached even when the system text changes. This guarantees the
4043
3839
  * large, unchanging head is a cache hit on every turn regardless of how the
4044
3840
  * message tail churns — the breakpoint placement first-party Anthropic clients
4045
3841
  * (Claude Code, Pi) use. Without it, the general API-key path anchors only the
4046
3842
  * moving message tail, so tail churn re-writes the whole head uncached.
4047
3843
  *
3844
+ * Volatile trailing segments (memory recall) sit after the breakpoint, so a
3845
+ * recall refresh re-bills only the suffix and the tail for one turn instead of
3846
+ * the whole head. When every system block is volatile there is no stable
3847
+ * boundary and the breakpoint stays on the array tail (previous behavior).
3848
+ *
4048
3849
  * Anthropic allows at most 4 cache breakpoints per request. At most one is
4049
3850
  * spent on tools and one on system here, leaving the remaining budget for
4050
3851
  * the message tail and historical decimation checkpoints in `applyPromptCaching`.
@@ -4080,9 +3881,28 @@ function applyHeadCaching(
4080
3881
  }
4081
3882
  }
4082
3883
 
4083
- if (systemBlocks && systemBlocks.length > 0 && !systemBlocks.some(block => block.cache_control != null)) {
4084
- const lastBlock = systemBlocks[systemBlocks.length - 1];
4085
- if (lastBlock) lastBlock.cache_control = cloneAnthropicCacheControl(cacheControl);
3884
+ if (systemBlocks && systemBlocks.length > 0) {
3885
+ // Anchor on the last stable block so a volatile recall suffix refresh
3886
+ // re-bills only the suffix, not the whole head. The skip-if-decorated
3887
+ // check applies only when there is no volatile suffix (previous
3888
+ // behavior): with a suffix present the boundary anchor is added
3889
+ // whenever the anchor block itself lacks a breakpoint, even if the
3890
+ // OAuth path pre-decorated its identity block — otherwise the only
3891
+ // system breakpoint sits before the stable prompt and a recall
3892
+ // refresh re-bills it. The message budget in `applyPromptCaching`
3893
+ // shrinks accordingly (4 minus head breakpoints). All-volatile falls
3894
+ // back to tail anchoring (previous behavior).
3895
+ const suffixStart = stableSystemSuffixStart(systemBlocks);
3896
+ if (suffixStart === systemBlocks.length) {
3897
+ if (!systemBlocks.some(block => block.cache_control != null)) {
3898
+ const lastBlock = systemBlocks[systemBlocks.length - 1];
3899
+ if (lastBlock) lastBlock.cache_control = cloneAnthropicCacheControl(cacheControl);
3900
+ }
3901
+ } else {
3902
+ const anchorIndex = suffixStart === 0 ? systemBlocks.length - 1 : suffixStart - 1;
3903
+ const anchor = systemBlocks[anchorIndex];
3904
+ if (anchor && anchor.cache_control == null) anchor.cache_control = cloneAnthropicCacheControl(cacheControl);
3905
+ }
4086
3906
  }
4087
3907
  }
4088
3908
 
@@ -4166,11 +3986,15 @@ function getAnthropicControlState(
4166
3986
  ): AnthropicControlState | undefined {
4167
3987
  if (!state) return undefined;
4168
3988
  const root = messages[0];
3989
+ // Key on the stable system prefix, not the full array: a volatile recall
3990
+ // suffix refresh must resolve the same baseline or the declared-tool,
3991
+ // effort, and control-transition state it preserves is lost with it.
3992
+ const stablePrefix = system?.slice(0, stableSystemSuffixStart(system)) ?? null;
4169
3993
  const fingerprint = String(
4170
3994
  Bun.hash(
4171
3995
  JSON.stringify([
4172
3996
  sessionId ?? "",
4173
- system?.map(block => block.text) ?? null,
3997
+ stablePrefix?.map(block => block.text) ?? null,
4174
3998
  root ? anthropicControlMessageProjection(root) : null,
4175
3999
  ]),
4176
4000
  ),
@@ -4216,14 +4040,16 @@ function syncAnthropicControlState(state: AnthropicControlState, messages: reado
4216
4040
  }
4217
4041
 
4218
4042
  /**
4219
- * Keep the top-level `system` array byte-stable across a session. The blocks
4220
- * captured on the first request are replayed verbatim (with the current
4221
- * request's cache breakpoints) while their text is unchanged. A text change
4043
+ * Keep the top-level `system` array byte-stable across a session. The stable
4044
+ * prefix captured on the first request is replayed verbatim (with the current
4045
+ * request's cache breakpoints) while its text is unchanged; the volatile
4046
+ * recall suffix always passes through current-turn. A stable-prefix change
4222
4047
  * re-baselines instead of duplicating the prompt as a mid-conversation
4223
4048
  * system message: omp's system prompt is one rendered segment that embeds
4224
4049
  * the tool roster, so replaying a second copy on every later request would
4225
4050
  * cost the full prompt again per change. The prefix rewrite is absorbed by
4226
- * `prefix_mismatch_behavior: "drop_block"` and one cache miss.
4051
+ * `prefix_mismatch_behavior: "drop_block"` and one cache miss, while a
4052
+ * recall-only change keeps the tool/control baselines intact.
4227
4053
  */
4228
4054
  function planStableAnthropicSystem(
4229
4055
  current: AnthropicSystemBlock[] | undefined,
@@ -4231,16 +4057,21 @@ function planStableAnthropicSystem(
4231
4057
  enabled: boolean,
4232
4058
  ): AnthropicSystemBlock[] | undefined {
4233
4059
  if (!state || !enabled) return current;
4234
- const fingerprint = JSON.stringify(current?.map(block => block.text) ?? null);
4060
+ const suffixStart = stableSystemSuffixStart(current ?? []);
4061
+ const fingerprint = JSON.stringify(current?.slice(0, suffixStart).map(block => block.text) ?? null);
4235
4062
  if (state.systemFingerprint !== fingerprint) {
4236
4063
  resetAnthropicControlState(state);
4237
4064
  state.systemFingerprint = fingerprint;
4238
- state.stableSystemBlocks = current?.map(block => ({ type: block.type, text: block.text }));
4065
+ state.stableSystemBlocks = current?.slice(0, suffixStart).map(block => ({ type: block.type, text: block.text }));
4239
4066
  }
4240
- return state.stableSystemBlocks?.map((block, index) => {
4241
- const cacheControl = current?.[index]?.cache_control;
4242
- return cacheControl ? { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) } : { ...block };
4243
- });
4067
+ const stableReplay =
4068
+ state.stableSystemBlocks?.map((block, index) => {
4069
+ const cacheControl = current?.[index]?.cache_control;
4070
+ return cacheControl ? { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) } : { ...block };
4071
+ }) ?? [];
4072
+ const suffix = current?.slice(suffixStart).map(block => ({ ...block })) ?? [];
4073
+ const replayed = [...stableReplay, ...suffix];
4074
+ return replayed.length > 0 ? replayed : undefined;
4244
4075
  }
4245
4076
 
4246
4077
  function anthropicToolDefinitionKey(tool: AnthropicWireTool): string {
@@ -4479,7 +4310,7 @@ function buildParams(
4479
4310
  // Pre-compute metadata.
4480
4311
  const metadataAccountId = readAnthropicMetadataAccountId(options?.metadata);
4481
4312
  const metadataUserId = resolveAnthropicMetadataUserId(
4482
- readMetadataString(options?.metadata, "user_id") ??
4313
+ readAnthropicMetadataString(options?.metadata, "user_id") ??
4483
4314
  // Deliberately share the normalized affinity identity across Kimi's two transports.
4484
4315
  (model.provider === "kimi-code" ? getOpenAIPromptCacheKey(options) : undefined),
4485
4316
  isOAuthToken,
@@ -1,10 +1,10 @@
1
+ import { NO_AUTH_SENTINEL } from "../auth-retry";
1
2
  import { type AwsBedrockProviderOptions, resolveAwsBearerToken } from "../registry/aws";
2
3
  import type { FetchImpl, Model } from "../types";
3
4
  import { resolveAwsRegion } from "../utils/aws-profile";
4
5
  import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-credentials";
5
6
  import { signRequest } from "./aws-sigv4";
6
7
  import type { OpenAIResponsesOptions } from "./openai-responses";
7
- import { NO_AUTH_SENTINEL } from "./openai-shared";
8
8
 
9
9
  export type BedrockMantleProviderOptions = AwsBedrockProviderOptions;
10
10
 
@@ -87,10 +87,6 @@ export function clearGitLabDuoDirectAccessCache(): void {
87
87
  directAccessCache.clear();
88
88
  }
89
89
 
90
- export function isGitLabDuoModel(model: Model<Api>): boolean {
91
- return model.provider === "gitlab-duo";
92
- }
93
-
94
90
  export function streamGitLabDuo(
95
91
  model: Model<Api>,
96
92
  context: Context,