@oh-my-pi/pi-ai 18.10.0 → 18.10.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.2] - 2026-10-12
6
+
7
+ ### Added
8
+
9
+ - Added support for `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` on the `anthropic` provider: when set, requests omit pre-release fields (`context_management`, beta tool fields such as `strict` and `defer_loading`, task budgets, structured-output format, `thinking.block_binding`, and Anthropic server compaction) along with their beta headers, so `ANTHROPIC_BASE_URL` gateways that reject them keep working ([#15458](https://github.com/can1357/oh-my-pi/pull/15458) by [@H4vC](https://github.com/H4vC))
10
+
5
11
  ## [18.9.2] - 2026-10-11
6
12
 
7
13
  ### Breaking Changes
@@ -79,9 +79,9 @@ uutils coreutils (https://github.com/uutils/coreutils), MIT
79
79
  --------------------------------------------------------------------------------
80
80
  Covers: base32, base64, basename, cat, cksum (shared checksum machinery),
81
81
  b2sum, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, comm, cp,
82
- cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nproc, paste,
83
- printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee, touch, tr,
84
- truncate, uname, uniq, wc, whoami, yes.
82
+ cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nl, nproc,
83
+ paste, printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
84
+ touch, tr, truncate, uname, uniq, wc, whoami, yes.
85
85
 
86
86
  Copyright (c) uutils developers
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.10.0",
3
+ "version": "18.10.2",
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.10.0",
159
- "@oh-my-pi/pi-catalog": "18.10.0",
160
- "@oh-my-pi/pi-natives": "18.10.0",
161
- "@oh-my-pi/pi-utils": "18.10.0",
162
- "@oh-my-pi/pi-wire": "18.10.0"
158
+ "@oh-my-pi/omptype": "18.10.2",
159
+ "@oh-my-pi/pi-catalog": "18.10.2",
160
+ "@oh-my-pi/pi-natives": "18.10.2",
161
+ "@oh-my-pi/pi-utils": "18.10.2",
162
+ "@oh-my-pi/pi-wire": "18.10.2"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -9,6 +9,7 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
9
9
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
10
10
  import {
11
11
  $env,
12
+ $flag,
12
13
  isEnoent,
13
14
  logger,
14
15
  parseJsonWithRepair,
@@ -116,10 +117,12 @@ import {
116
117
  type MessageCreateParams,
117
118
  type MessageCreateParamsStreaming,
118
119
  type MessageParam,
120
+ type OutputConfig,
119
121
  parseAnthropicInputTransformations,
120
122
  type RawMessageStreamEvent,
121
123
  THINKING_BINDING_CONTROLS_BETA,
122
124
  type TextBlockParam,
125
+ type ThinkingConfigParam,
123
126
  } from "./anthropic-wire";
124
127
  import {
125
128
  claudeCodeSdkVersion,
@@ -273,24 +276,41 @@ const promptCacheBetas: Record<string, true> = {
273
276
  };
274
277
 
275
278
  /**
276
- * Remove the prompt-cache betas from every `anthropic-beta` entry in a header
279
+ * Betas withheld under the kill switch: they gate the pre-release body fields
280
+ * it removes (Claude Code's gateway contract: context management, beta tool
281
+ * fields, structured-output format, task budgets, tool search), plus the
282
+ * thinking-binding and compaction controls omp also withholds.
283
+ */
284
+ const killSwitchBetas: Record<string, true> = {
285
+ [THINKING_BINDING_CONTROLS_BETA]: true,
286
+ [contextManagementBeta]: true,
287
+ [COMPACTION_BETA]: true,
288
+ [LEGACY_COMPACTION_BETA]: true,
289
+ [structuredOutputsBeta]: true,
290
+ [taskBudgetBeta]: true,
291
+ [midConversationToolChangesBeta]: true,
292
+ };
293
+
294
+ /**
295
+ * Remove the `dropped` betas from every `anthropic-beta` entry in a header
277
296
  * record. Header names are case-insensitive and the value is a comma-separated
278
297
  * list, so each matching key keeps its own casing and its remaining tokens;
279
298
  * an entry left with no token is deleted rather than sent empty. The input is
280
- * returned untouched when it advertises no cache beta.
299
+ * returned untouched when it advertises none of them.
281
300
  *
282
301
  * `model.headers` and `options.headers` are caller-controlled channels that
283
302
  * reach the wire through `buildAnthropicHeaders` (as `modelHeaders`, which an
284
303
  * override-enabled endpoint lets replace our own beta header outright), the
285
304
  * GitHub Copilot default headers, and the per-request beta override built for
286
- * injected clients — each of which must go through here once breakpoints drop.
305
+ * injected clients — each of which must go through here once breakpoints drop
306
+ * or the experimental-beta kill switch is on.
287
307
  */
288
- function stripPromptCacheBetas(headers: Record<string, string>): Record<string, string> {
308
+ function stripHeaderBetas(headers: Record<string, string>, dropped: Record<string, true>): Record<string, string> {
289
309
  let stripped: Record<string, string> | undefined;
290
310
  for (const key in headers) {
291
311
  if (key.toLowerCase() !== "anthropic-beta") continue;
292
312
  const betas = normalizeExtraBetas(headers[key]);
293
- const kept = betas.filter(beta => promptCacheBetas[beta] !== true);
313
+ const kept = betas.filter(beta => dropped[beta] !== true);
294
314
  if (kept.length === betas.length) continue;
295
315
  stripped ??= { ...headers };
296
316
  if (kept.length === 0) {
@@ -2406,6 +2426,9 @@ const streamAnthropicOnce = (
2406
2426
  };
2407
2427
  let dropAllThinking = droppedAllThinkingForSignature;
2408
2428
  let prefixBindingRetryAttempted = false;
2429
+ // Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`; the wrapper
2430
+ // already turned the gated compat off, this covers caller/frozen inputs.
2431
+ const killSwitchActive = experimentalBetasDisabled(model);
2409
2432
  // A live fallback-credit redemption replays its frozen body.
2410
2433
  const frozenRedemption =
2411
2434
  options?.fallbackCreditRedemption &&
@@ -2413,6 +2436,24 @@ const streamAnthropicOnce = (
2413
2436
  options.fallbackCreditRedemption.params
2414
2437
  ? options.fallbackCreditRedemption
2415
2438
  : undefined;
2439
+ // A body frozen before the kill switch was set still carries the fields
2440
+ // it withholds; the credit token is bound to that body, so forfeit it
2441
+ // and build a fresh request. Server tools that already ran would re-run
2442
+ // and re-bill on a tokenless retry, so surface that case instead.
2443
+ const frozenBody = frozenRedemption?.params as MessageCreateParamsStreaming | undefined;
2444
+ // The redemption drops `fallbacks` before sending, so they cannot force a forfeit.
2445
+ const forfeitFrozenForKillSwitch =
2446
+ killSwitchActive &&
2447
+ frozenBody !== undefined &&
2448
+ carriesKillSwitchField({ ...frozenBody, fallbacks: undefined });
2449
+ if (
2450
+ forfeitFrozenForKillSwitch &&
2451
+ frozenRedemption?.refusedContent?.some(block => block.type === "anthropicServerTool")
2452
+ ) {
2453
+ throw new AIError.ConfigurationError(
2454
+ "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS was set after a refused turn that ran server tools; its fallback credit cannot be redeemed without the experimental fields the switch withholds.",
2455
+ );
2456
+ }
2416
2457
  // Collected at most once, on first params build; a frozen redemption
2417
2458
  // body never builds params.
2418
2459
  let controlRecords: AnthropicControlRecord[] | undefined;
@@ -2431,12 +2472,16 @@ const streamAnthropicOnce = (
2431
2472
  // `anthropic-beta` value, so once breakpoints are dropped an unstripped
2432
2473
  // base would re-attach the very cache beta the replay abandoned. Both
2433
2474
  // prompt-cache betas govern `ttl`/`scope`, so the options rung has to
2434
- // strip them too. Read through a closure because both flags flip
2435
- // mid-turn.
2436
- const callerBetaBaseHeaders = (): Record<string, string> =>
2437
- dropCacheControl || dropCacheControlOptions
2438
- ? stripPromptCacheBetas(mergedCallerHeaders)
2439
- : mergedCallerHeaders;
2475
+ // strip them too. The kill switch's betas never ride, whatever the
2476
+ // caller's header says. Read through a closure because both cache
2477
+ // flags flip mid-turn.
2478
+ const callerBetaBaseHeaders = (): Record<string, string> => {
2479
+ const headers =
2480
+ dropCacheControl || dropCacheControlOptions
2481
+ ? stripHeaderBetas(mergedCallerHeaders, promptCacheBetas)
2482
+ : mergedCallerHeaders;
2483
+ return killSwitchActive ? stripHeaderBetas(headers, killSwitchBetas) : headers;
2484
+ };
2440
2485
  // Keep fallback payloads aligned with the top-level Vertex effort gate:
2441
2486
  // no nested effort field means the fallback scan cannot re-add its beta.
2442
2487
  let fallbacks = options?.fallbacks;
@@ -2565,7 +2610,9 @@ const streamAnthropicOnce = (
2565
2610
  if (!isOAuth && isOfficialAnthropicApiUrl(baseUrl) && !extraBetas.includes(fallbackCreditBeta)) {
2566
2611
  extraBetas.push(fallbackCreditBeta);
2567
2612
  }
2568
- if (options?.fallbackCreditRedemption) {
2613
+ // A forfeited frozen body is rebuilt fresh, so it keeps the betas
2614
+ // computed above instead of the saved list describing the old body.
2615
+ if (options?.fallbackCreditRedemption && !forfeitFrozenForKillSwitch) {
2569
2616
  const frozenBetas = (options.fallbackCreditRedemption.betas ?? []).filter(
2570
2617
  b => !b.startsWith("server-side-fallback-"),
2571
2618
  );
@@ -2598,6 +2645,8 @@ const streamAnthropicOnce = (
2598
2645
  }
2599
2646
  }
2600
2647
  }
2648
+ // Caller betas and a frozen redemption's beta list bypass compat.
2649
+ if (killSwitchActive) extraBetas = extraBetas.filter(beta => killSwitchBetas[beta] !== true);
2601
2650
 
2602
2651
  clientArgs = {
2603
2652
  model,
@@ -2659,6 +2708,8 @@ const streamAnthropicOnce = (
2659
2708
  if (replacementPayload !== undefined) {
2660
2709
  nextParams = replacementPayload as typeof nextParams;
2661
2710
  }
2711
+ // After `onPayload`, so a hook cannot restore a field the switch withholds.
2712
+ if (killSwitchActive) withholdKillSwitchFields(nextParams);
2662
2713
  if (nextParams.compaction) stripCompactionIncompatibleParams(nextParams);
2663
2714
  // After `onPayload`, so a hook cannot restore a field Bedrock rejects.
2664
2715
  if (model.compat.bedrockMessagesApi) fitBedrockAnthropicPayload(nextParams);
@@ -2696,7 +2747,7 @@ const streamAnthropicOnce = (
2696
2747
  let params: MessageCreateParamsStreaming;
2697
2748
  // Recheck expiry here: preparation (image resizing) is async, and an
2698
2749
  // expired token must fall back to a fresh request as before.
2699
- if (frozenRedemption && Date.now() <= frozenRedemption.expiresAt) {
2750
+ if (frozenRedemption && !forfeitFrozenForKillSwitch && Date.now() <= frozenRedemption.expiresAt) {
2700
2751
  const redemption = frozenRedemption;
2701
2752
  usingFallbackCredit = true;
2702
2753
  const frozenParams = structuredClone(redemption.params as MessageCreateParamsStreaming);
@@ -2925,7 +2976,12 @@ const streamAnthropicOnce = (
2925
2976
  const rawBetas = options.fallbackCreditRedemption.betaHeader
2926
2977
  .split(",")
2927
2978
  .map(b => b.trim())
2928
- .filter(b => b && !b.startsWith("server-side-fallback-"));
2979
+ .filter(
2980
+ b =>
2981
+ b &&
2982
+ !b.startsWith("server-side-fallback-") &&
2983
+ (!killSwitchActive || killSwitchBetas[b] !== true),
2984
+ );
2929
2985
  if (!rawBetas.includes(fallbackCreditBeta) && !rawBetas.includes("fallback-credit-2026-06-01")) {
2930
2986
  rawBetas.push(fallbackCreditBeta);
2931
2987
  }
@@ -4003,10 +4059,97 @@ const streamAnthropicOnce = (
4003
4059
  * loop. The inner attempt owns Anthropic provider-failure retries.
4004
4060
  */
4005
4061
  export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
4006
- withReplaySafeStreamRetry(model, context, options, streamAnthropicOnce, {
4062
+ withReplaySafeStreamRetry(withoutDisabledExperimentalBetas(model), context, options, streamAnthropicOnce, {
4007
4063
  retryEmptyCompletion: true,
4008
4064
  });
4009
4065
 
4066
+ /** Whether Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` applies to this model's provider. */
4067
+ function experimentalBetasDisabled(model: Model<"anthropic-messages">): boolean {
4068
+ return model.compat.honorsClaudeCodeBetaKillSwitch === true && $flag("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS");
4069
+ }
4070
+
4071
+ /**
4072
+ * Whether a request body carries a field the kill switch withholds. A frozen
4073
+ * fallback-credit body that does cannot be redeemed under the switch. Defined
4074
+ * as "the sanitizer would change it" so detection and stripping cannot drift.
4075
+ */
4076
+ function carriesKillSwitchField(params: MessageCreateParamsStreaming): boolean {
4077
+ const sanitized = structuredClone(params);
4078
+ withholdKillSwitchFields(sanitized);
4079
+ return !Bun.deepEquals(sanitized, params);
4080
+ }
4081
+
4082
+ /**
4083
+ * Remove every field the kill switch withholds from a final request body, for
4084
+ * payloads an `onPayload` hook replaced after compat already shaped them, and
4085
+ * for the switch's fields compat has no axis for (task budgets). Compaction
4086
+ * blocks keep their readable summary as text, the same form an endpoint
4087
+ * without compaction support receives.
4088
+ */
4089
+ function withholdKillSwitchFields(params: MessageCreateParamsStreaming): void {
4090
+ withholdKillSwitchControls(params);
4091
+ delete params.context_management;
4092
+ delete params.compaction;
4093
+ // Server-side fallback entries carry their own per-attempt controls.
4094
+ for (const entry of params.fallbacks ?? []) withholdKillSwitchControls(entry);
4095
+ for (const tool of params.tools ?? []) {
4096
+ if ("strict" in tool) delete tool.strict;
4097
+ if ("defer_loading" in tool) delete tool.defer_loading;
4098
+ if ("eager_input_streaming" in tool) delete tool.eager_input_streaming;
4099
+ }
4100
+ for (const message of params.messages) {
4101
+ // Per-message `output_config` (mid-conversation effort) may carry the same controls.
4102
+ if ("output_config" in message && message.output_config) withholdKillSwitchControls(message);
4103
+ if (!Array.isArray(message.content)) continue;
4104
+ for (let i = 0; i < message.content.length; i++) {
4105
+ const block = message.content[i];
4106
+ if (block.type === "compaction") message.content[i] = { type: "text", text: block.content };
4107
+ }
4108
+ }
4109
+ if (params.anthropic_beta) {
4110
+ params.anthropic_beta = params.anthropic_beta.filter(beta => killSwitchBetas[beta] !== true);
4111
+ }
4112
+ }
4113
+
4114
+ /** Withhold the kill switch's thinking/output controls on a body, fallback entry, or message. */
4115
+ function withholdKillSwitchControls(holder: { thinking?: ThinkingConfigParam; output_config?: OutputConfig }): void {
4116
+ if (holder.thinking && "block_binding" in holder.thinking) delete holder.thinking.block_binding;
4117
+ if (!holder.output_config) return;
4118
+ delete holder.output_config.task_budget;
4119
+ delete holder.output_config.format;
4120
+ if (Object.keys(holder.output_config).length === 0) delete holder.output_config;
4121
+ }
4122
+
4123
+ /**
4124
+ * Honor Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` kill switch on
4125
+ * providers whose compat opts in (`honors-claude-code-beta-kill-switch`).
4126
+ * Gateways behind `ANTHROPIC_BASE_URL` often reject pre-release body fields,
4127
+ * so the switch withholds what Claude Code's gateway contract lists — context
4128
+ * management, beta tool fields (`strict`, `defer_loading`,
4129
+ * `eager_input_streaming`), tool search (mid-conversation tool changes),
4130
+ * structured-output format, task budgets — plus `thinking.block_binding` and
4131
+ * server compaction (whose legacy replay needs a `context_management` edit),
4132
+ * together with their betas. Effort, adaptive thinking, interleaved thinking
4133
+ * and extended context stay, as in Claude Code. Applied through compat so every
4134
+ * request builder and retry rung sees one decision; {@link withholdKillSwitchFields}
4135
+ * and {@link killSwitchBetas} cover the rest where bodies and betas are finalized.
4136
+ */
4137
+ function withoutDisabledExperimentalBetas(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
4138
+ if (!experimentalBetasDisabled(model)) return model;
4139
+ return {
4140
+ ...model,
4141
+ compat: {
4142
+ ...model.compat,
4143
+ supportsContextManagement: false,
4144
+ supportsThinkingBindingControls: false,
4145
+ supportsServerCompaction: false,
4146
+ disableStrictTools: true,
4147
+ supportsEagerToolInputStreaming: false,
4148
+ supportsMidConversationToolChanges: false,
4149
+ },
4150
+ };
4151
+ }
4152
+
4010
4153
  export type AnthropicSystemBlock = {
4011
4154
  type: "text";
4012
4155
  text: string;
@@ -4163,7 +4306,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4163
4306
  // Unlike the generic builder below, this branch has no enforced-key
4164
4307
  // filter: a caller-supplied `anthropic-beta` lands in the default
4165
4308
  // headers verbatim, cache betas included.
4166
- const defaultHeaders = dropPromptCacheBetas ? stripPromptCacheBetas(mergedCopilotHeaders) : mergedCopilotHeaders;
4309
+ const defaultHeaders = dropPromptCacheBetas
4310
+ ? stripHeaderBetas(mergedCopilotHeaders, promptCacheBetas)
4311
+ : mergedCopilotHeaders;
4167
4312
  applyInferenceHeaders(defaultHeaders, {
4168
4313
  provider: model.provider,
4169
4314
  protocol: "anthropic",
@@ -4208,9 +4353,13 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4208
4353
  // value outright — but `allowAnthropicHeaderOverrides` lets it replace our
4209
4354
  // built beta header wholesale, which would put a cache beta back on a
4210
4355
  // breakpoint-free request.
4211
- const requestModelHeaders = dropPromptCacheBetas
4212
- ? stripPromptCacheBetas(mergedRequestHeaders)
4356
+ // The kill switch's betas are stripped on the same channel.
4357
+ const cacheStrippedHeaders = dropPromptCacheBetas
4358
+ ? stripHeaderBetas(mergedRequestHeaders, promptCacheBetas)
4213
4359
  : mergedRequestHeaders;
4360
+ const requestModelHeaders = experimentalBetasDisabled(model)
4361
+ ? stripHeaderBetas(cacheStrippedHeaders, killSwitchBetas)
4362
+ : cacheStrippedHeaders;
4214
4363
  const defaultHeaders = buildAnthropicHeaders({
4215
4364
  apiKey,
4216
4365
  baseUrl,