@oh-my-pi/pi-ai 16.1.16 → 16.1.17

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.17] - 2026-06-24
6
+
7
+ ### Added
8
+
9
+ - Added provider-level `notes?: string[]` field to `UsageReport` for disclaimers that apply to every limit (e.g. "OMP-observed spend only"). The field is declared in both the `usage.ts` schema and the auth-broker wire schema copy so it survives the `"+": "reject"` deserialization gate. ([#3268](https://github.com/can1357/oh-my-pi/issues/3268))
10
+
11
+ ### Fixed
12
+
13
+ - Moved the OpenCode Go "OMP-observed spend only" disclaimer from per-limit `notes` to provider-level `notes`, so it renders once per provider instead of duplicating across every account × window. ([#3268](https://github.com/can1357/oh-my-pi/issues/3268))
14
+ - Fixed Anthropic rate-limit header usage cache entries retaining legacy missing account metadata after refresh.
15
+ - Fixed Anthropic-compatible budget-effort models dropping the selected effort before request serialization, so `output_config.effort` is emitted alongside `thinking.budget_tokens` when model metadata declares `mode: "anthropic-budget-effort"`.
16
+ - Fixed `anthropic-messages` silently dropping caller-supplied `Authorization` / `X-Api-Key` from `model.headers` and `ANTHROPIC_CUSTOM_HEADERS`, blocking custom proxy auth schemes. Non-OAuth requests now honor the caller's value (matching `openai-responses`); the lower-level client also suppresses its `X-Api-Key` add when a custom `Authorization` is supplied for a non-official endpoint so the proxy receives a single credential. OAuth bearer + Cloudflare AI Gateway keep their pre-existing enforced auth headers. ([#3391](https://github.com/can1357/oh-my-pi/issues/3391))
17
+ - Fixed Ollama Cloud `num_predict` ignoring the provider's 65536 output-token cap so stale `models.db` rows (or custom `modelOverrides` re-enabling output caps) that carried `maxTokens: 1048576` from a pre-omitMaxOutputTokens catalog 400'd every request with `max_tokens (1048576) exceeds model's maximum output tokens (65536) for model deepseek-v4-pro`. The Ollama provider now clamps `num_predict` for any `ollama-cloud` request at the documented 65536 cap before sending, independent of the cached spec's `maxTokens` and on top of the existing `omitMaxOutputTokens` policy — so the request stays valid even when the load-time policy never normalized the spec. Self-hosted `ollama` traffic is unaffected. ([#3392](https://github.com/can1357/oh-my-pi/issues/3392))
18
+ - Fixed OpenRouter Anthropic models on the Responses path omitting `cache_control`, so prompt caching engages without forcing Chat Completions. ([#3397](https://github.com/can1357/oh-my-pi/issues/3397))
19
+ - Fixed OpenRouter Anthropic Responses follow-up requests replaying prior reasoning items with stale signatures, which caused HTTP 400 `Invalid signature in thinking block` errors after a thinking turn. ([#3399](https://github.com/can1357/oh-my-pi/issues/3399))
20
+ - Fixed OpenRouter Anthropic models on the Responses path omitting `cache_control`, so prompt caching engages without forcing Chat Completions. `cacheRetention: "long"` now upgrades the breakpoint to `ttl: "1h"`. ([#3397](https://github.com/can1357/oh-my-pi/issues/3397))
21
+
5
22
  ## [16.1.16] - 2026-06-23
6
23
 
7
24
  ### Fixed
@@ -332,6 +332,7 @@ export declare const usageResponseSchema: import("arktype/internal/variants/obje
332
332
  resetCredits?: {
333
333
  availableCount: number;
334
334
  } | undefined;
335
+ notes?: string[] | undefined;
335
336
  metadata?: {
336
337
  [x: string]: unknown;
337
338
  } | undefined;
@@ -76,6 +76,10 @@ interface OpenAIResponsesChainState {
76
76
  /** Set once chaining is judged unsupported for this session (circuit breaker). */
77
77
  disabled: boolean;
78
78
  }
79
+ type OpenRouterAnthropicCacheControl = {
80
+ type: "ephemeral";
81
+ ttl?: "1h";
82
+ };
79
83
  type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
80
84
  top_p?: number;
81
85
  top_k?: number;
@@ -92,6 +96,7 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
92
96
  } | {
93
97
  enabled: false;
94
98
  };
99
+ cache_control?: OpenRouterAnthropicCacheControl;
95
100
  };
96
101
  /**
97
102
  * Public entry: wrap the single-attempt Responses streamer with bounded
@@ -68,6 +68,13 @@ export interface UsageReport {
68
68
  limits: UsageLimit[];
69
69
  /** Saved rate-limit resets the account can redeem, when the provider reports them. */
70
70
  resetCredits?: UsageResetCredits;
71
+ /**
72
+ * Provider-wide disclaimers shown once above per-account sections.
73
+ * Use this for caveats that apply to every limit (e.g. "OMP-observed
74
+ * spend only"). Per-limit notes that differ per window (e.g. "Overage
75
+ * requests: N") stay on {@link UsageLimit.notes}.
76
+ */
77
+ notes?: string[];
71
78
  metadata?: Record<string, unknown>;
72
79
  raw?: unknown;
73
80
  }
@@ -220,6 +227,7 @@ export declare const usageReportSchema: import("arktype/internal/variants/object
220
227
  resetCredits?: {
221
228
  availableCount: number;
222
229
  } | undefined;
230
+ notes?: string[] | undefined;
223
231
  metadata?: {
224
232
  [x: string]: unknown;
225
233
  } | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.16",
4
+ "version": "16.1.17",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.16",
42
- "@oh-my-pi/pi-utils": "16.1.16",
43
- "@oh-my-pi/pi-wire": "16.1.16",
41
+ "@oh-my-pi/pi-catalog": "16.1.17",
42
+ "@oh-my-pi/pi-utils": "16.1.17",
43
+ "@oh-my-pi/pi-wire": "16.1.17",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -192,6 +192,7 @@ const arkUsageReportSchema = type({
192
192
  fetchedAt: "number",
193
193
  limits: usageLimitSchema.array(),
194
194
  "resetCredits?": usageResetCreditsSchema,
195
+ "notes?": "string[]",
195
196
  "metadata?": { "[string]": "unknown" },
196
197
  "raw?": "unknown",
197
198
  });
@@ -2326,8 +2326,13 @@ export class AuthStorage {
2326
2326
  const last = this.#usageHeaderIngestAt.get(cacheKey);
2327
2327
  if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
2328
2328
 
2329
- const report = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
2330
- if (!report) return false;
2329
+ const parsedReport = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
2330
+ if (!parsedReport) return false;
2331
+ const metadata: Record<string, unknown> = { ...(parsedReport.metadata ?? {}) };
2332
+ if (credential.accountId && metadata.accountId === undefined) metadata.accountId = credential.accountId;
2333
+ if (credential.email && metadata.email === undefined) metadata.email = credential.email;
2334
+ if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId;
2335
+ const report: UsageReport = { ...parsedReport, metadata };
2331
2336
 
2332
2337
  const prior = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value;
2333
2338
  let merged = report;
@@ -2351,6 +2356,7 @@ export class AuthStorage {
2351
2356
  fetchedAt: now,
2352
2357
  limits,
2353
2358
  metadata: {
2359
+ ...(report.metadata ?? {}),
2354
2360
  ...(prior.metadata ?? {}),
2355
2361
  headersUpdatedAt: now,
2356
2362
  },
@@ -193,10 +193,18 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
193
193
  const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
194
194
  const extraBetas = options.extraBetas ?? [];
195
195
  const stream = options.stream ?? false;
196
- // `enforcedHeaderKeys` strips User-Agent out of modelHeaders so a spread can't
197
- // produce case-duplicate keys; re-add the caller's value explicitly per branch
198
- // (OAuth replaces non-claude-cli values, the other branches forward verbatim).
196
+ // `enforcedHeaderKeys` strips User-Agent / X-Api-Key / Authorization out of
197
+ // modelHeaders so a case-insensitive spread can't produce duplicate keys; each
198
+ // branch re-adds the caller's value explicitly. User-Agent and X-Api-Key are
199
+ // always honored (with branch-specific defaults filling in when absent), while
200
+ // Authorization is honored for every non-OAuth, non-Cloudflare-gateway branch —
201
+ // OAuth requests MUST carry `Authorization: Bearer <oauth-token>` (the OAuth
202
+ // credential itself) and Cloudflare AI Gateway authenticates via
203
+ // `cf-aig-authorization`, so user-supplied auth there would just leak. Both of
204
+ // those cases drop + log the caller value (#3391).
199
205
  const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
206
+ const incomingAuthorization = getHeaderCaseInsensitive(options.modelHeaders, "Authorization");
207
+ const incomingApiKey = getHeaderCaseInsensitive(options.modelHeaders, "X-Api-Key");
200
208
  // Claude Code betas (oauth-2025-04-20, claude-code-20250219, …) are part of
201
209
  // the OAuth fingerprint; API-key requests default to extras only, matching
202
210
  // the streaming path (buildAnthropicClientOptions passes [] for non-OAuth).
@@ -205,14 +213,21 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
205
213
  extraBetas,
206
214
  );
207
215
  const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
216
+ const isCloudflare = options.isCloudflareAiGateway ?? false;
217
+ const honorAuthorization = !oauthToken && !isCloudflare;
218
+ const honorApiKey = !isCloudflare;
208
219
  const modelHeaders: Record<string, string> = {};
209
220
  const filteredEnforcedKeys: string[] = [];
210
221
  for (const [key, value] of Object.entries(options.modelHeaders ?? {})) {
211
222
  const lowerKey = key.toLowerCase();
212
223
  if (enforcedHeaderKeys.has(lowerKey)) {
213
- // User-Agent is filtered only to dedup the spread; every branch re-adds
214
- // the caller's value explicitly, so it is not "ignored".
215
- if (lowerKey !== "user-agent") filteredEnforcedKeys.push(key);
224
+ // user-agent is always re-applied explicitly. authorization / x-api-key
225
+ // are silently re-applied in honoring branches and dropped + logged
226
+ // where the branch enforces its own credential.
227
+ if (lowerKey === "user-agent") continue;
228
+ if (lowerKey === "authorization" && honorAuthorization) continue;
229
+ if (lowerKey === "x-api-key" && honorApiKey) continue;
230
+ filteredEnforcedKeys.push(key);
216
231
  continue;
217
232
  }
218
233
  modelHeaders[key] = value;
@@ -226,7 +241,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
226
241
  });
227
242
  }
228
243
 
229
- if (options.isCloudflareAiGateway) {
244
+ if (isCloudflare) {
230
245
  return {
231
246
  ...modelHeaders,
232
247
  Accept: acceptHeader,
@@ -251,15 +266,17 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
251
266
  ...(options.claudeCodeSessionId ? { "X-Claude-Code-Session-Id": options.claudeCodeSessionId } : {}),
252
267
  "x-client-request-id": nodeCrypto.randomUUID(),
253
268
  "User-Agent": userAgent,
269
+ ...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
254
270
  };
255
271
  } else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
256
272
  return {
257
273
  ...modelHeaders,
258
274
  Accept: acceptHeader,
259
- Authorization: `Bearer ${options.apiKey}`,
275
+ Authorization: incomingAuthorization ?? `Bearer ${options.apiKey}`,
260
276
  ...sharedHeaders,
261
277
  ...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
262
278
  ...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
279
+ ...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
263
280
  };
264
281
  } else {
265
282
  return {
@@ -268,7 +285,8 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
268
285
  ...sharedHeaders,
269
286
  ...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
270
287
  ...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
271
- "X-Api-Key": options.apiKey,
288
+ ...(incomingAuthorization ? { Authorization: incomingAuthorization } : {}),
289
+ "X-Api-Key": incomingApiKey ?? options.apiKey,
272
290
  };
273
291
  }
274
292
  }
@@ -2547,12 +2565,15 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2547
2565
  };
2548
2566
  }
2549
2567
 
2568
+ // Suppress the client-level `X-Api-Key` whenever an `Authorization` header
2569
+ // already sits in `defaultHeaders` for a non-official, non-OAuth endpoint —
2570
+ // either our auto-built `Bearer <apiKey>` or a caller-supplied custom auth
2571
+ // scheme via `model.headers` (#3391). Adding a bonus `X-Api-Key` would force
2572
+ // the proxy to deal with two competing credentials when the user explicitly
2573
+ // asked for one.
2550
2574
  const authorizationHeader = getHeaderCaseInsensitive(defaultHeaders, "Authorization");
2551
2575
  const shouldSuppressClientApiKey =
2552
- !oauthToken &&
2553
- !model.compat.officialEndpoint &&
2554
- typeof authorizationHeader === "string" &&
2555
- /^Bearer\s+/i.test(authorizationHeader);
2576
+ !oauthToken && !model.compat.officialEndpoint && typeof authorizationHeader === "string";
2556
2577
 
2557
2578
  return {
2558
2579
  isOAuthToken: oauthToken,
@@ -282,6 +282,26 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
282
282
  }));
283
283
  }
284
284
 
285
+ /**
286
+ * Ollama Cloud rejects `num_predict` above this value with HTTP 400
287
+ * (`max_tokens (...) exceeds model's maximum output tokens (65536)`).
288
+ * The cap currently applies uniformly to cloud-served models; the cloud-side
289
+ * limit was confirmed empirically against `deepseek-v4-pro`/`-flash` and is
290
+ * the same cap surfaced for every other Ollama Cloud model we've probed.
291
+ *
292
+ * Acts as a wire-level safety net so stale `models.db` rows (or custom
293
+ * `modelOverrides` re-enabling `num_predict`) cannot 400 the request — even
294
+ * when `model.omitMaxOutputTokens` was never applied. See #3392.
295
+ */
296
+ const OLLAMA_CLOUD_NUM_PREDICT_CAP = 65_536;
297
+
298
+ function resolveNumPredict(model: Model<"ollama-chat">, requested: number): number {
299
+ if (model.provider === "ollama-cloud") {
300
+ return Math.min(requested, OLLAMA_CLOUD_NUM_PREDICT_CAP);
301
+ }
302
+ return requested;
303
+ }
304
+
285
305
  function createChatBody(model: Model<"ollama-chat">, context: Context, options: OllamaChatOptions | undefined) {
286
306
  const think = mapReasoning(model, options?.reasoning, options?.disableReasoning);
287
307
  const toolChoice = mapToolChoice(options?.toolChoice);
@@ -294,7 +314,7 @@ function createChatBody(model: Model<"ollama-chat">, context: Context, options:
294
314
  ...(think !== undefined ? { think } : {}),
295
315
  ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
296
316
  ...(options?.maxTokens !== undefined && !model.omitMaxOutputTokens
297
- ? { options: { num_predict: options.maxTokens } }
317
+ ? { options: { num_predict: resolveNumPredict(model, options.maxTokens) } }
298
318
  : {}),
299
319
  stream: true,
300
320
  };
@@ -3,6 +3,7 @@ import { $flag, extractHttpStatusFromError, logger, structuredCloneJSON } from "
3
3
  import { getEnvApiKey } from "../stream";
4
4
  import type {
5
5
  AssistantMessage,
6
+ CacheRetention,
6
7
  Context,
7
8
  Model,
8
9
  OpenAICompat,
@@ -324,6 +325,8 @@ function markOpenAIResponsesChainZeroDataRetention(chain: OpenAIResponsesChainSt
324
325
  });
325
326
  }
326
327
 
328
+ type OpenRouterAnthropicCacheControl = { type: "ephemeral"; ttl?: "1h" };
329
+
327
330
  type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
328
331
  top_p?: number;
329
332
  top_k?: number;
@@ -334,8 +337,19 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
334
337
  stream_options?: { include_obfuscation?: boolean };
335
338
  provider?: OpenAICompat["openRouterRouting"];
336
339
  reasoning?: { effort?: string } | { enabled: false };
340
+ cache_control?: OpenRouterAnthropicCacheControl;
337
341
  };
338
342
 
343
+ function maybeAddOpenRouterAnthropicCacheControl(
344
+ params: OpenAIResponsesSamplingParams,
345
+ model: Model<"openai-responses">,
346
+ cacheRetention: CacheRetention,
347
+ ): void {
348
+ if (cacheRetention === "none" || !isOpenRouterAnthropicModel(model)) return;
349
+ if (params.cache_control != null) return;
350
+ params.cache_control = cacheRetention === "long" ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" };
351
+ }
352
+
339
353
  /**
340
354
  * Generate function for OpenAI Responses API
341
355
  */
@@ -777,7 +791,7 @@ export function buildParams(
777
791
  replay: shouldReplayNativeHistory,
778
792
  filterReasoning: policy.reasoning.filterReasoningHistory,
779
793
  },
780
- includeThinkingSignatures: shouldReplayNativeHistory,
794
+ includeThinkingSignatures: shouldReplayNativeHistory && !policy.reasoning.filterReasoningHistory,
781
795
  repairOrphanOutputs: true,
782
796
  });
783
797
 
@@ -823,6 +837,7 @@ export function buildParams(
823
837
  store: false,
824
838
  stream_options: model.compat.supportsObfuscationOptOut ? { include_obfuscation: false } : undefined,
825
839
  };
840
+ maybeAddOpenRouterAnthropicCacheControl(params, model, cacheRetention);
826
841
  const outputToken = resolveOpenAIOutputTokenParam({
827
842
  field: "max_output_tokens",
828
843
  maxTokens: options?.maxTokens,
package/src/stream.ts CHANGED
@@ -847,10 +847,15 @@ function mapOptionsForApi<TApi extends Api>(
847
847
  });
848
848
  }
849
849
 
850
+ const thinkingMode = model.thinking?.mode;
851
+ const effort =
852
+ thinkingMode === "anthropic-adaptive" || thinkingMode === "anthropic-budget-effort"
853
+ ? mapEffortToAnthropicAdaptiveEffort(model, reasoning)
854
+ : undefined;
855
+
850
856
  // For Opus 4.6+ and Sonnet 4.6+: use adaptive thinking with effort level
851
857
  // For older models: use budget-based thinking
852
- if (model.thinking?.mode === "anthropic-adaptive") {
853
- const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning);
858
+ if (thinkingMode === "anthropic-adaptive") {
854
859
  return castApi<"anthropic-messages">({
855
860
  ...base,
856
861
  requestModelId: resolveWireModelId(model, reasoning),
@@ -868,6 +873,7 @@ function mapOptionsForApi<TApi extends Api>(
868
873
  requestModelId: resolveWireModelId(model, reasoning),
869
874
  thinkingEnabled: true,
870
875
  thinkingBudgetTokens: thinkingBudget,
876
+ effort,
871
877
  toolChoice: mapAnthropicToolChoice(options?.toolChoice),
872
878
  thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
873
879
  serviceTier: options?.serviceTier,
@@ -899,6 +905,7 @@ function mapOptionsForApi<TApi extends Api>(
899
905
  requestModelId: resolveWireModelId(model, reasoning),
900
906
  thinkingEnabled: true,
901
907
  thinkingBudgetTokens: thinkingBudget,
908
+ effort,
902
909
  toolChoice: mapAnthropicToolChoice(options?.toolChoice),
903
910
  thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
904
911
  serviceTier: options?.serviceTier,
@@ -62,7 +62,6 @@ function buildWindowLimit(
62
62
  unit: "usd",
63
63
  },
64
64
  status: resolveStatus(usedFraction),
65
- notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."],
66
65
  };
67
66
  }
68
67
 
@@ -80,6 +79,7 @@ export const opencodeGoUsageProvider: UsageProvider = {
80
79
  provider: OPENCODE_GO_PROVIDER,
81
80
  fetchedAt: nowMs,
82
81
  limits: OPENCODE_GO_LIMITS.map(limit => buildWindowLimit(limit, entries, nowMs)),
82
+ notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."],
83
83
  metadata: {
84
84
  planType: "OpenCode Go",
85
85
  source: "omp-observed-request-costs",
package/src/usage.ts CHANGED
@@ -82,6 +82,13 @@ export interface UsageReport {
82
82
  limits: UsageLimit[];
83
83
  /** Saved rate-limit resets the account can redeem, when the provider reports them. */
84
84
  resetCredits?: UsageResetCredits;
85
+ /**
86
+ * Provider-wide disclaimers shown once above per-account sections.
87
+ * Use this for caveats that apply to every limit (e.g. "OMP-observed
88
+ * spend only"). Per-limit notes that differ per window (e.g. "Overage
89
+ * requests: N") stay on {@link UsageLimit.notes}.
90
+ */
91
+ notes?: string[];
85
92
  metadata?: Record<string, unknown>;
86
93
  raw?: unknown;
87
94
  }
@@ -204,6 +211,7 @@ export const usageReportSchema = type({
204
211
  fetchedAt: "number",
205
212
  limits: usageLimitSchema.array(),
206
213
  "resetCredits?": usageResetCreditsSchema,
214
+ "notes?": "string[]",
207
215
  "metadata?": { "[string]": "unknown" },
208
216
  // `raw` is provider-specific and may be anything; the broker strips it before
209
217
  // sending the report over the wire, so accept-but-ignore here.