@oh-my-pi/pi-ai 16.1.16 → 16.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.18] - 2026-06-25
6
+
7
+ ### Added
8
+
9
+ - Added `listOAuthAccounts` for retrieving a read-only list of stored OAuth account identities
10
+ - Added `getOAuthAccessAt` to resolve an OAuth token exclusively for a specific account position
11
+
12
+ ### Changed
13
+
14
+ - Refactored OAuth token persistence and disable logic to use stable credential IDs instead of positional indices to prevent race conditions during concurrent updates
15
+ - Updated OAuth failure classification to treat 403 status codes, rate limits, and network errors as transient, preventing unnecessary credential invalidation
16
+
17
+ ### Fixed
18
+
19
+ - Fixed Codex Responses Lite staying enabled for image prompts, which caused GPT/Codex image turns to be rejected as `Invalid value: 'input_image'`; image-bearing Codex requests now fall back to the full Responses transport. ([#3421](https://github.com/can1357/oh-my-pi/issues/3421))
20
+ - Fixed the auth-broker background refresher disabling OAuth credentials unconditionally (`disableCredentialById`) on a definitive refresh failure, so a credential another process or a fresh login rotated mid-refresh could be torn down even though the stored row already held a valid token. The definitive-failure teardown now happens inside `AuthStorage.refreshCredentialById` via the same compare-and-set the in-stream and usage-probe paths use — it disables only when the persisted row still matches the credential the refresh actually attempted, and reloads on a CAS loss; the refresher now only logs.
21
+ - Fixed OAuth refresh persisting the rotated token by a positional index captured before the refresh `await`. A concurrent disable could reorder or shrink a provider's credential array while the refresh was in flight, landing the new token on the wrong row (or silently dropping it) and leaving accounts with a stale refresh token that failed — and was then disabled — on the next cycle. Refresh persistence, selection-index resync, and CAS-disable now address the row by id across `forceRefreshCredentialById`, candidate preflight, and in-stream selection (`#replaceCredentialById` / `#disableCredentialByIdIfMatches`).
22
+ - Fixed `isDefinitiveOAuthFailure` treating a bare HTTP 403 (and generic `unauthorized` / access-token-expired wording) as a definitive credential failure, which permanently disabled healthy OAuth accounts on WAF, egress rate-limit, permission, and account-verification responses. Bare 403, rate limits (429), gateway/5xx, and more network errors (`ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, …) are now classified transient; only explicit dead-grant errors (`invalid_grant`, `invalid_token`, `unauthorized_client`, revoked, `refresh token … expired`) or a bare 401 tear the credential down.
23
+
24
+ ## [16.1.17] - 2026-06-24
25
+
26
+ ### Added
27
+
28
+ - 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))
29
+
30
+ ### Fixed
31
+
32
+ - 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))
33
+ - Fixed Anthropic rate-limit header usage cache entries retaining legacy missing account metadata after refresh.
34
+ - 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"`.
35
+ - 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))
36
+ - 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))
37
+ - 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))
38
+ - 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))
39
+ - 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))
40
+
5
41
  ## [16.1.16] - 2026-06-23
6
42
 
7
43
  ### 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;
@@ -467,6 +467,19 @@ export type OAuthAccessResolution = ({
467
467
  } & OAuthAccess) | ({
468
468
  ok: false;
469
469
  } & OAuthAccessFailure);
470
+ /**
471
+ * Read-only identity of one stored OAuth account, in stable storage order.
472
+ * Returned by {@link AuthStorage.listOAuthAccounts}; `position` (0-based) is the
473
+ * selector accepted by {@link AuthStorage.getOAuthAccessAt}.
474
+ */
475
+ export interface OAuthAccountSummary {
476
+ position: number;
477
+ credentialId: number;
478
+ accountId?: string;
479
+ email?: string;
480
+ projectId?: string;
481
+ enterpriseUrl?: string;
482
+ }
470
483
  export interface InvalidateCredentialMatchingOptions {
471
484
  signal?: AbortSignal;
472
485
  sessionId?: string;
@@ -703,6 +716,14 @@ export declare class AuthStorage {
703
716
  sessionId?: string;
704
717
  baseUrl?: string;
705
718
  }): boolean;
719
+ /**
720
+ * The {@link UsageProvider} registered for `provider`, or undefined when the
721
+ * provider has no usage endpoint at all. Lets callers tell "a credential we
722
+ * could have fetched usage for but didn't" apart from "a provider with no
723
+ * usage concept" (web-search keys, local/keyless servers, inference
724
+ * providers without a usage API) — the latter never warrants a usage row.
725
+ */
726
+ usageProviderFor(provider: Provider): UsageProvider | undefined;
706
727
  fetchUsageReports(options?: {
707
728
  baseUrlResolver?: (provider: Provider) => string | undefined;
708
729
  /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
@@ -782,6 +803,13 @@ export declare class AuthStorage {
782
803
  * OAuth with an explicit API key.
783
804
  */
784
805
  getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
806
+ /**
807
+ * Read-only list of stored OAuth accounts for `provider` in stable storage
808
+ * order, WITHOUT refreshing any token. The array position (0-based) is the
809
+ * selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth
810
+ * account" UI should render `position + 1`.
811
+ */
812
+ listOAuthAccounts(provider: string): OAuthAccountSummary[];
785
813
  /**
786
814
  * Resolve every stored OAuth credential for `provider` independently.
787
815
  *
@@ -791,6 +819,18 @@ export declare class AuthStorage {
791
819
  * exercise each stored account exactly once.
792
820
  */
793
821
  getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
822
+ /**
823
+ * Resolve a single stored OAuth credential by its account position (0-based,
824
+ * matching {@link AuthStorage.listOAuthAccounts}). Refreshes ONLY that
825
+ * credential ({@link #resolveStoredOAuthAccess} runs with `allowFallback:
826
+ * false`), so — unlike {@link AuthStorage.getOAuthAccesses} — a definitive
827
+ * failure of the targeted account surfaces as a failed resolution rather than
828
+ * silently rotating or rate-tripping a sibling.
829
+ *
830
+ * Returns `undefined` when `position` is out of range or runtime/config
831
+ * overrides have replaced OAuth with an explicit API key.
832
+ */
833
+ getOAuthAccessAt(provider: string, position: number, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution | undefined>;
794
834
  /**
795
835
  * List saved rate-limit resets for every stored OAuth account of `provider`
796
836
  * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
@@ -47,6 +47,8 @@ export interface RequestBody {
47
47
  service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
48
48
  [key: string]: unknown;
49
49
  }
50
+ /** Returns whether a Codex request can use the text-only Responses Lite transport. */
51
+ export declare function shouldUseCodexResponsesLite(body: RequestBody, requested: boolean | undefined): boolean;
50
52
  export declare function transformRequestBody(body: RequestBody, model: Model<Api>, options?: CodexRequestOptions, prompt?: {
51
53
  developerMessages: string[];
52
54
  }): Promise<RequestBody>;
@@ -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.18",
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.18",
42
+ "@oh-my-pi/pi-utils": "16.1.18",
43
+ "@oh-my-pi/pi-wire": "16.1.18",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -5,9 +5,10 @@
5
5
  * any whose `expires - Date.now() < refreshSkewMs`. Refresh single-flight
6
6
  * lives in {@link AuthStorage} so manual and background refreshes share the
7
7
  * same upstream attempt.
8
- * Definitively-failed credentials (invalid_grant / 401 not from network blip)
9
- * are disabled via {@link AuthStorage.disableCredentialById} so the next
10
- * snapshot pull surfaces a clean delete on the client.
8
+ * Definitively-failed credentials (invalid_grant / bare 401, not a network
9
+ * blip) are torn down inside {@link AuthStorage.refreshCredentialById} via a
10
+ * compare-and-set disable only when no peer/login rotated the row first — so
11
+ * the next snapshot pull surfaces a clean delete on the client.
11
12
  */
12
13
  import { logger } from "@oh-my-pi/pi-utils";
13
14
  import { type AuthStorage, isDefinitiveOAuthFailure } from "../auth-storage";
@@ -104,11 +105,10 @@ export class AuthBrokerRefresher {
104
105
  } catch (error) {
105
106
  const errorMsg = String(error);
106
107
  if (isDefinitiveOAuthFailure(errorMsg)) {
107
- logger.warn("auth-broker refresh failed definitively; disabling credential", {
108
- id,
109
- error: errorMsg,
110
- });
111
- this.#storage.disableCredentialById(id, `auth-broker refresh failed: ${errorMsg}`);
108
+ // AuthStorage.refreshCredentialById already CAS-disabled the row
109
+ // (unless a peer/login rotated it first, in which case the live
110
+ // credential is intentionally kept). Nothing to do here but record it.
111
+ logger.warn("auth-broker refresh failed definitively", { id, error: errorMsg });
112
112
  } else {
113
113
  logger.debug("auth-broker refresh failed (transient)", { id, error: errorMsg });
114
114
  }
@@ -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
  });
@@ -568,9 +568,17 @@ const MAX_PENDING_DISABLED_EVENTS = 32;
568
568
  * while streaming requests correctly tear the row down.
569
569
  */
570
570
  const OAUTH_DEFINITIVE_FAILURE_REGEX =
571
- /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
572
- const OAUTH_TRANSIENT_FAILURE_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
573
- const OAUTH_HTTP_AUTH_REGEX = /\b(401|403)\b/;
571
+ /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
572
+ // Transient: network blips, rate limits, gateway/5xx, and infra denials
573
+ // (WAF / egress 403, permission / account-verification) — block-and-retry,
574
+ // never tear the credential down for these.
575
+ const OAUTH_TRANSIENT_FAILURE_REGEX =
576
+ /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
577
+ // A bare 401 from an OAuth token endpoint means the stored grant/client is
578
+ // dead. 403 is deliberately excluded: it is overwhelmingly WAF / egress
579
+ // rate-limit / permission / account-verification — none of which mean the
580
+ // refresh token itself is invalid.
581
+ const OAUTH_HTTP_AUTH_REGEX = /\b401\b/;
574
582
 
575
583
  export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
576
584
  if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
@@ -673,6 +681,20 @@ export interface OAuthAccountIdentity {
673
681
  }
674
682
 
675
683
  export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure);
684
+
685
+ /**
686
+ * Read-only identity of one stored OAuth account, in stable storage order.
687
+ * Returned by {@link AuthStorage.listOAuthAccounts}; `position` (0-based) is the
688
+ * selector accepted by {@link AuthStorage.getOAuthAccessAt}.
689
+ */
690
+ export interface OAuthAccountSummary {
691
+ position: number;
692
+ credentialId: number;
693
+ accountId?: string;
694
+ email?: string;
695
+ projectId?: string;
696
+ enterpriseUrl?: string;
697
+ }
676
698
  export interface InvalidateCredentialMatchingOptions {
677
699
  signal?: AbortSignal;
678
700
  sessionId?: string;
@@ -887,6 +909,7 @@ class AuthStorageUsageCache implements UsageCache {
887
909
 
888
910
  type StoredCredential = { id: number; credential: AuthCredential };
889
911
  type OAuthSelection = { credential: OAuthCredential; index: number };
912
+ type StoredOAuthSelection = { credentialId: number; credential: OAuthCredential; index: number };
890
913
 
891
914
  type OAuthCandidate = {
892
915
  selection: OAuthSelection;
@@ -1519,6 +1542,42 @@ export class AuthStorage {
1519
1542
  return true;
1520
1543
  }
1521
1544
 
1545
+ /**
1546
+ * Persist a refreshed credential addressed by id, not a positional index.
1547
+ * A concurrent disable can reorder/shrink the provider's row array while an
1548
+ * async refresh is in flight, so a pre-await index is unsafe; resolving the
1549
+ * row by id at write time lands the rotated token on the correct row. Returns
1550
+ * the row's current index, or -1 when it was disabled/removed mid-refresh.
1551
+ */
1552
+ #replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
1553
+ const entries = this.#getStoredCredentials(provider);
1554
+ const index = entries.findIndex(entry => entry.id === id);
1555
+ if (index === -1) return -1;
1556
+ this.#store.updateAuthCredential(id, credential);
1557
+ const updated = [...entries];
1558
+ updated[index] = { id, credential };
1559
+ this.#setStoredCredentials(provider, updated);
1560
+ return index;
1561
+ }
1562
+
1563
+ /**
1564
+ * CAS-disable the row with `id`, but only if its persisted credential still
1565
+ * matches `expected` — i.e. no peer/login rotated it while we refreshed.
1566
+ * Addresses the row by id (re-resolved here, then matched on `data` in the
1567
+ * store) so a concurrent reorder can't tear down the wrong credential.
1568
+ */
1569
+ #disableCredentialByIdIfMatches(
1570
+ provider: string,
1571
+ id: number,
1572
+ expected: AuthCredential,
1573
+ disabledCause: string,
1574
+ ): boolean {
1575
+ const entries = this.#getStoredCredentials(provider);
1576
+ const index = entries.findIndex(entry => entry.id === id);
1577
+ if (index === -1) return false;
1578
+ return this.#tryDisableCredentialAtIfMatches(provider, index, expected, disabledCause);
1579
+ }
1580
+
1522
1581
  #emitCredentialDisabled(event: CredentialDisabledEvent): void {
1523
1582
  if (this.#credentialDisabledListeners.size === 0) {
1524
1583
  // No subscribers — buffer for later replay. Cap the backlog so a process that runs
@@ -2326,8 +2385,13 @@ export class AuthStorage {
2326
2385
  const last = this.#usageHeaderIngestAt.get(cacheKey);
2327
2386
  if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
2328
2387
 
2329
- const report = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
2330
- if (!report) return false;
2388
+ const parsedReport = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
2389
+ if (!parsedReport) return false;
2390
+ const metadata: Record<string, unknown> = { ...(parsedReport.metadata ?? {}) };
2391
+ if (credential.accountId && metadata.accountId === undefined) metadata.accountId = credential.accountId;
2392
+ if (credential.email && metadata.email === undefined) metadata.email = credential.email;
2393
+ if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId;
2394
+ const report: UsageReport = { ...parsedReport, metadata };
2331
2395
 
2332
2396
  const prior = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value;
2333
2397
  let merged = report;
@@ -2351,6 +2415,7 @@ export class AuthStorage {
2351
2415
  fetchedAt: now,
2352
2416
  limits,
2353
2417
  metadata: {
2418
+ ...(report.metadata ?? {}),
2354
2419
  ...(prior.metadata ?? {}),
2355
2420
  headersUpdatedAt: now,
2356
2421
  },
@@ -2595,6 +2660,17 @@ export class AuthStorage {
2595
2660
  );
2596
2661
  }
2597
2662
 
2663
+ /**
2664
+ * The {@link UsageProvider} registered for `provider`, or undefined when the
2665
+ * provider has no usage endpoint at all. Lets callers tell "a credential we
2666
+ * could have fetched usage for but didn't" apart from "a provider with no
2667
+ * usage concept" (web-search keys, local/keyless servers, inference
2668
+ * providers without a usage API) — the latter never warrants a usage row.
2669
+ */
2670
+ usageProviderFor(provider: Provider): UsageProvider | undefined {
2671
+ return this.#usageProviderResolver?.(provider);
2672
+ }
2673
+
2598
2674
  async fetchUsageReports(options?: {
2599
2675
  baseUrlResolver?: (provider: Provider) => string | undefined;
2600
2676
  /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
@@ -3272,7 +3348,12 @@ export class AuthStorage {
3272
3348
  type: "oauth",
3273
3349
  };
3274
3350
  candidate.selection.credential = updated;
3275
- this.#replaceCredentialAt(provider, candidate.selection.index, updated);
3351
+ if (credentialId !== undefined) {
3352
+ const idx = this.#replaceCredentialById(provider, credentialId, updated);
3353
+ if (idx !== -1) candidate.selection.index = idx;
3354
+ } else {
3355
+ this.#replaceCredentialAt(provider, candidate.selection.index, updated);
3356
+ }
3276
3357
  } catch (error) {
3277
3358
  // Recovery for definitive failures (incl. peer rotation) lives in
3278
3359
  // #tryOAuthCredential; log instead of swallowing silently — a bare
@@ -3454,6 +3535,8 @@ export class AuthStorage {
3454
3535
  strategy?: CredentialRankingStrategy;
3455
3536
  rankingContext?: CredentialRankingContext;
3456
3537
  blockScope?: string;
3538
+ /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
3539
+ allowFallback?: boolean;
3457
3540
  },
3458
3541
  ): Promise<OAuthResolutionResult | undefined> {
3459
3542
  const {
@@ -3465,6 +3548,7 @@ export class AuthStorage {
3465
3548
  strategy,
3466
3549
  rankingContext,
3467
3550
  blockScope,
3551
+ allowFallback = true,
3468
3552
  } = usageOptions;
3469
3553
  if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, blockScope)) {
3470
3554
  return undefined;
@@ -3473,6 +3557,11 @@ export class AuthStorage {
3473
3557
  if (!(await this.#prepareOAuthCredentialForRequest(provider, selection, options))) {
3474
3558
  return undefined;
3475
3559
  }
3560
+ // Capture the row id once, immediately after #prepareOAuthCredentialForRequest
3561
+ // resynced selection.index from the store. A concurrent disable during the
3562
+ // usage/refresh awaits below can shift positional indices, so every later
3563
+ // refresh / persist / CAS-disable addresses the row by this stable id.
3564
+ const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3476
3565
 
3477
3566
  const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
3478
3567
  const applyProFilter = enforceProRequirement ?? requiresProModel;
@@ -3515,7 +3604,7 @@ export class AuthStorage {
3515
3604
  const refreshedCredentials = await this.#refreshOAuthCredential(
3516
3605
  provider,
3517
3606
  selection.credential,
3518
- this.#getStoredCredentials(provider)[selection.index]?.id,
3607
+ credentialId,
3519
3608
  options?.signal,
3520
3609
  );
3521
3610
  const apiKey = customProvider.getApiKey
@@ -3531,7 +3620,7 @@ export class AuthStorage {
3531
3620
  const refreshedCredentials = await this.#refreshOAuthCredential(
3532
3621
  provider,
3533
3622
  selection.credential,
3534
- this.#getStoredCredentials(provider)[selection.index]?.id,
3623
+ credentialId,
3535
3624
  options?.signal,
3536
3625
  );
3537
3626
  const oauthCreds: Record<string, OAuthCredentials> = {
@@ -3551,7 +3640,12 @@ export class AuthStorage {
3551
3640
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
3552
3641
  apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
3553
3642
  };
3554
- this.#replaceCredentialAt(provider, selection.index, updated);
3643
+ if (credentialId !== undefined) {
3644
+ const idx = this.#replaceCredentialById(provider, credentialId, updated);
3645
+ if (idx !== -1) selection.index = idx;
3646
+ } else {
3647
+ this.#replaceCredentialAt(provider, selection.index, updated);
3648
+ }
3555
3649
  if ((checkUsage && !allowBlocked) || requiresProModel) {
3556
3650
  const sameAccount = selection.credential.accountId === updated.accountId;
3557
3651
  if (!usageChecked || !sameAccount) {
@@ -3601,7 +3695,6 @@ export class AuthStorage {
3601
3695
  // refresh token has changed, the peer rotation succeeded and we should pick
3602
3696
  // up the new credential instead of soft-deleting the row that the peer just
3603
3697
  // updated.
3604
- const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3605
3698
  if (credentialId !== undefined) {
3606
3699
  const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
3607
3700
  const latestCredential = latestRow?.credential;
@@ -3612,29 +3705,37 @@ export class AuthStorage {
3612
3705
  credentialId,
3613
3706
  });
3614
3707
  await this.reload();
3615
- return this.#resolveOAuthSelection(provider, sessionId, options);
3708
+ if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
3616
3709
  }
3617
3710
  }
3618
3711
  // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
3619
3712
  // Use a CAS-style disable conditioned on the row still containing the stale credential
3620
3713
  // we tried to refresh, so a peer rotation that lands between the pre-check above and
3621
3714
  // this disable doesn't soft-delete the freshly-rotated row.
3622
- const disabled = this.#tryDisableCredentialAtIfMatches(
3623
- provider,
3624
- selection.index,
3625
- selection.credential,
3626
- `oauth refresh failed: ${errorMsg}`,
3627
- );
3715
+ const disabled =
3716
+ credentialId !== undefined
3717
+ ? this.#disableCredentialByIdIfMatches(
3718
+ provider,
3719
+ credentialId,
3720
+ selection.credential,
3721
+ `oauth refresh failed: ${errorMsg}`,
3722
+ )
3723
+ : this.#tryDisableCredentialAtIfMatches(
3724
+ provider,
3725
+ selection.index,
3726
+ selection.credential,
3727
+ `oauth refresh failed: ${errorMsg}`,
3728
+ );
3628
3729
  if (!disabled) {
3629
3730
  logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
3630
3731
  provider,
3631
3732
  index: selection.index,
3632
3733
  });
3633
3734
  await this.reload();
3634
- return this.#resolveOAuthSelection(provider, sessionId, options);
3735
+ if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
3635
3736
  }
3636
3737
  if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
3637
- return this.#resolveOAuthSelection(provider, sessionId, options);
3738
+ if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
3638
3739
  }
3639
3740
  } else {
3640
3741
  // Block temporarily for transient failures (5 minutes)
@@ -3778,6 +3879,83 @@ export class AuthStorage {
3778
3879
  };
3779
3880
  }
3780
3881
 
3882
+ /** Stored OAuth credentials for `provider` in stable order, paired with their full-list index and row id. */
3883
+ #getStoredOAuthSelections(provider: string): StoredOAuthSelection[] {
3884
+ return this.#getStoredCredentials(provider)
3885
+ .map((entry, index) => ({ credentialId: entry.id, credential: entry.credential, index }))
3886
+ .filter((entry): entry is StoredOAuthSelection => entry.credential.type === "oauth");
3887
+ }
3888
+
3889
+ /** Refresh one stored OAuth selection and shape it as an {@link OAuthAccessResolution}. */
3890
+ async #resolveStoredOAuthAccess(
3891
+ provider: string,
3892
+ selection: StoredOAuthSelection,
3893
+ providerKey: string,
3894
+ options: AuthApiKeyOptions | undefined,
3895
+ ): Promise<OAuthAccessResolution> {
3896
+ try {
3897
+ const resolved = await this.#tryOAuthCredential(
3898
+ provider,
3899
+ { credential: selection.credential, index: selection.index },
3900
+ providerKey,
3901
+ undefined,
3902
+ options,
3903
+ { checkUsage: false, allowBlocked: true, allowFallback: false },
3904
+ );
3905
+ if (!resolved) {
3906
+ return {
3907
+ ok: false,
3908
+ credentialId: selection.credentialId,
3909
+ accountId: selection.credential.accountId,
3910
+ email: selection.credential.email,
3911
+ projectId: selection.credential.projectId,
3912
+ enterpriseUrl: selection.credential.enterpriseUrl,
3913
+ error: "OAuth access unavailable",
3914
+ };
3915
+ }
3916
+ const { credential } = resolved;
3917
+ return {
3918
+ ok: true,
3919
+ credentialId: selection.credentialId,
3920
+ accessToken: credential.access,
3921
+ accountId: credential.accountId,
3922
+ email: credential.email,
3923
+ projectId: credential.projectId,
3924
+ enterpriseUrl: credential.enterpriseUrl,
3925
+ };
3926
+ } catch (error) {
3927
+ return {
3928
+ ok: false,
3929
+ credentialId: selection.credentialId,
3930
+ accountId: selection.credential.accountId,
3931
+ email: selection.credential.email,
3932
+ projectId: selection.credential.projectId,
3933
+ enterpriseUrl: selection.credential.enterpriseUrl,
3934
+ error: error instanceof Error ? error.message : String(error),
3935
+ };
3936
+ }
3937
+ }
3938
+
3939
+ /**
3940
+ * Read-only list of stored OAuth accounts for `provider` in stable storage
3941
+ * order, WITHOUT refreshing any token. The array position (0-based) is the
3942
+ * selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth
3943
+ * account" UI should render `position + 1`.
3944
+ */
3945
+ listOAuthAccounts(provider: string): OAuthAccountSummary[] {
3946
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
3947
+ return [];
3948
+ }
3949
+ return this.#getStoredOAuthSelections(provider).map((selection, position) => ({
3950
+ position,
3951
+ credentialId: selection.credentialId,
3952
+ accountId: selection.credential.accountId,
3953
+ email: selection.credential.email,
3954
+ projectId: selection.credential.projectId,
3955
+ enterpriseUrl: selection.credential.enterpriseUrl,
3956
+ }));
3957
+ }
3958
+
3781
3959
  /**
3782
3960
  * Resolve every stored OAuth credential for `provider` independently.
3783
3961
  *
@@ -3791,62 +3969,38 @@ export class AuthStorage {
3791
3969
  return [];
3792
3970
  }
3793
3971
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
3794
- const selections = this.#getStoredCredentials(provider)
3795
- .map((entry, index) => ({ credentialId: entry.id, credential: entry.credential, index }))
3796
- .filter(
3797
- (entry): entry is { credentialId: number; credential: OAuthCredential; index: number } =>
3798
- entry.credential.type === "oauth",
3799
- );
3800
3972
  return Promise.all(
3801
- selections.map(async (selection): Promise<OAuthAccessResolution> => {
3802
- try {
3803
- const resolved = await this.#tryOAuthCredential(
3804
- provider,
3805
- { credential: selection.credential, index: selection.index },
3806
- providerKey,
3807
- undefined,
3808
- options,
3809
- {
3810
- checkUsage: false,
3811
- allowBlocked: true,
3812
- },
3813
- );
3814
- if (!resolved) {
3815
- return {
3816
- ok: false,
3817
- credentialId: selection.credentialId,
3818
- accountId: selection.credential.accountId,
3819
- email: selection.credential.email,
3820
- projectId: selection.credential.projectId,
3821
- enterpriseUrl: selection.credential.enterpriseUrl,
3822
- error: "OAuth access unavailable",
3823
- };
3824
- }
3825
- const { credential } = resolved;
3826
- return {
3827
- ok: true,
3828
- credentialId: selection.credentialId,
3829
- accessToken: credential.access,
3830
- accountId: credential.accountId,
3831
- email: credential.email,
3832
- projectId: credential.projectId,
3833
- enterpriseUrl: credential.enterpriseUrl,
3834
- };
3835
- } catch (error) {
3836
- return {
3837
- ok: false,
3838
- credentialId: selection.credentialId,
3839
- accountId: selection.credential.accountId,
3840
- email: selection.credential.email,
3841
- projectId: selection.credential.projectId,
3842
- enterpriseUrl: selection.credential.enterpriseUrl,
3843
- error: error instanceof Error ? error.message : String(error),
3844
- };
3845
- }
3846
- }),
3973
+ this.#getStoredOAuthSelections(provider).map(selection =>
3974
+ this.#resolveStoredOAuthAccess(provider, selection, providerKey, options),
3975
+ ),
3847
3976
  );
3848
3977
  }
3849
3978
 
3979
+ /**
3980
+ * Resolve a single stored OAuth credential by its account position (0-based,
3981
+ * matching {@link AuthStorage.listOAuthAccounts}). Refreshes ONLY that
3982
+ * credential ({@link #resolveStoredOAuthAccess} runs with `allowFallback:
3983
+ * false`), so — unlike {@link AuthStorage.getOAuthAccesses} — a definitive
3984
+ * failure of the targeted account surfaces as a failed resolution rather than
3985
+ * silently rotating or rate-tripping a sibling.
3986
+ *
3987
+ * Returns `undefined` when `position` is out of range or runtime/config
3988
+ * overrides have replaced OAuth with an explicit API key.
3989
+ */
3990
+ async getOAuthAccessAt(
3991
+ provider: string,
3992
+ position: number,
3993
+ options?: AuthApiKeyOptions,
3994
+ ): Promise<OAuthAccessResolution | undefined> {
3995
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
3996
+ return undefined;
3997
+ }
3998
+ const selection = this.#getStoredOAuthSelections(provider)[position];
3999
+ if (!selection) return undefined;
4000
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
4001
+ return this.#resolveStoredOAuthAccess(provider, selection, providerKey, options);
4002
+ }
4003
+
3850
4004
  /**
3851
4005
  * List saved rate-limit resets for every stored OAuth account of `provider`
3852
4006
  * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
@@ -4223,22 +4377,55 @@ export class AuthStorage {
4223
4377
  if (target.credential.type !== "oauth") {
4224
4378
  throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
4225
4379
  }
4380
+ // The exact credential we are about to refresh — captured before the
4381
+ // await so a definitive failure can CAS-disable the row against the
4382
+ // value we actually attempted (NOT the expires:0 clone below).
4383
+ const attempted = target.credential;
4226
4384
  // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
4227
4385
  // in #refreshOAuthCredential doesn't suppress the requested refresh.
4228
- const stale: OAuthCredential = { ...target.credential, expires: 0 };
4229
- const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
4386
+ const stale: OAuthCredential = { ...attempted, expires: 0 };
4387
+ let refreshed: OAuthCredentials;
4388
+ try {
4389
+ refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
4390
+ } catch (error) {
4391
+ // A definitively-dead grant tears the row down here, where the
4392
+ // attempted credential is known. CAS on the persisted credential so a
4393
+ // peer/login rotation in flight leaves the freshly-rotated row intact.
4394
+ if (isDefinitiveOAuthFailure(String(error))) {
4395
+ // CAS-loss (false) means a peer/login rotated the row mid-refresh, so
4396
+ // our #data copy is stale — reload so the next caller serves the
4397
+ // freshly-rotated credential rather than the dead token we attempted.
4398
+ if (
4399
+ !this.#disableCredentialByIdIfMatches(
4400
+ provider,
4401
+ id,
4402
+ attempted,
4403
+ `oauth refresh failed: ${String(error)}`,
4404
+ )
4405
+ ) {
4406
+ await this.reload();
4407
+ }
4408
+ }
4409
+ throw error;
4410
+ }
4230
4411
  const updated: OAuthCredential = {
4231
4412
  type: "oauth",
4232
4413
  access: refreshed.access,
4233
4414
  refresh: refreshed.refresh,
4234
4415
  expires: refreshed.expires,
4235
- accountId: refreshed.accountId ?? target.credential.accountId,
4236
- email: refreshed.email ?? target.credential.email,
4237
- projectId: refreshed.projectId ?? target.credential.projectId,
4238
- enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
4239
- apiEndpoint: refreshed.apiEndpoint ?? target.credential.apiEndpoint,
4416
+ accountId: refreshed.accountId ?? attempted.accountId,
4417
+ email: refreshed.email ?? attempted.email,
4418
+ projectId: refreshed.projectId ?? attempted.projectId,
4419
+ enterpriseUrl: refreshed.enterpriseUrl ?? attempted.enterpriseUrl,
4420
+ apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
4240
4421
  };
4241
- this.#replaceCredentialAt(provider, index, updated);
4422
+ // Persist by id: the array may have been reordered/shrunk while the
4423
+ // refresh was in flight, so the pre-await positional index is unsafe. A
4424
+ // -1 means the row was disabled/removed mid-refresh — surface that as a
4425
+ // miss rather than implying a live row the snapshot won't contain.
4426
+ if (this.#replaceCredentialById(provider, id, updated) === -1) {
4427
+ throw new Error(`No credential with id=${id}`);
4428
+ }
4242
4429
  return {
4243
4430
  id,
4244
4431
  provider,
@@ -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
  };
@@ -59,6 +59,26 @@ export interface RequestBody {
59
59
  [key: string]: unknown;
60
60
  }
61
61
 
62
+ function containsInputImage(value: unknown): boolean {
63
+ if (!value || typeof value !== "object") return false;
64
+ if ((value as { type?: unknown }).type === "input_image") return true;
65
+ if (Array.isArray(value)) {
66
+ for (const item of value) {
67
+ if (containsInputImage(item)) return true;
68
+ }
69
+ return false;
70
+ }
71
+ for (const item of Object.values(value)) {
72
+ if (containsInputImage(item)) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /** Returns whether a Codex request can use the text-only Responses Lite transport. */
78
+ export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolean | undefined): boolean {
79
+ return requested === true && !containsInputImage(body.input);
80
+ }
81
+
62
82
  function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
63
83
  const config: ReasoningConfig = {
64
84
  effort:
@@ -211,7 +231,8 @@ export async function transformRequestBody(
211
231
  body.input = [...developerMessages, ...body.input];
212
232
  }
213
233
 
214
- if (options.responsesLite) {
234
+ const responsesLite = shouldUseCodexResponsesLite(body, options.responsesLite);
235
+ if (responsesLite) {
215
236
  if (Array.isArray(body.input)) {
216
237
  stripImageDetails(body.input);
217
238
  }
@@ -231,7 +252,7 @@ export async function transformRequestBody(
231
252
  // Responses Lite keeps reasoning replay server-side; codex-rs requests
232
253
  // `all_turns` there and otherwise omits context so the server default
233
254
  // (currently `current_turn`) applies.
234
- const reasoningContext = options.reasoningContext ?? (options.responsesLite ? "all_turns" : undefined);
255
+ const reasoningContext = options.reasoningContext ?? (responsesLite ? "all_turns" : undefined);
235
256
  if (reasoningContext !== undefined) {
236
257
  body.reasoning.context = reasoningContext;
237
258
  }
@@ -61,6 +61,7 @@ import {
61
61
  type CodexRequestOptions,
62
62
  type InputItem,
63
63
  type RequestBody,
64
+ shouldUseCodexResponsesLite,
64
65
  transformRequestBody,
65
66
  } from "./openai-codex/request-transformer";
66
67
  import { CodexApiError } from "./openai-codex/response-handler";
@@ -697,7 +698,7 @@ async function buildCodexRequestContext(
697
698
  };
698
699
 
699
700
  const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
700
- const responsesLite = options?.responsesLite === true;
701
+ const responsesLite = shouldUseCodexResponsesLite(transformedBody, options?.responsesLite);
701
702
  const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite);
702
703
  const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined;
703
704
  if (sessionKey && publicSessionKey) {
@@ -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.