@gajae-code/ai 0.18.12 → 0.19.0

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.
@@ -1210,7 +1210,7 @@ export function formatOAuthIdentityLabel(
1210
1210
  return base ? `${base} (${organization})` : organization;
1211
1211
  }
1212
1212
 
1213
- type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
1213
+ type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential; credentialRowId: number };
1214
1214
 
1215
1215
  /**
1216
1216
  * Refreshed OAuth access plus identity metadata returned by
@@ -1525,6 +1525,15 @@ type ConfigApiKeyOwnerFork = {
1525
1525
  * Reads from storage on reload(), manages round-robin credential selection,
1526
1526
  * usage limit tracking, and OAuth token refresh.
1527
1527
  */
1528
+ /** Tier names that denote a model family rather than a plan or quota tier. */
1529
+ const MODEL_FAMILY_TIERS: ReadonlySet<string> = new Set(["opus", "sonnet", "haiku"]);
1530
+
1531
+ /** A recorded usage-limit backoff; `modelId` scopes it to one model or model family. */
1532
+ interface CredentialBackoffEntry {
1533
+ untilMs: number;
1534
+ modelId?: string;
1535
+ }
1536
+
1528
1537
  export class AuthStorage {
1529
1538
  static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
1530
1539
 
@@ -1566,7 +1575,7 @@ export class AuthStorage {
1566
1575
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1567
1576
  #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
1568
1577
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
1569
- #credentialBackoff: Map<string, Map<number, number>> = new Map();
1578
+ #credentialBackoff: Map<string, Map<number, CredentialBackoffEntry[]>> = new Map();
1570
1579
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
1571
1580
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
1572
1581
  #usageCache: UsageCache;
@@ -3050,32 +3059,60 @@ export class AuthStorage {
3050
3059
  return order;
3051
3060
  }
3052
3061
 
3053
- /** Returns block expiry timestamp for a credential, cleaning up expired entries. */
3054
- #getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
3062
+ /**
3063
+ * Returns the block expiry for a credential, cleaning up expired entries.
3064
+ * A block recorded for one model only applies to that model; a block without a
3065
+ * model applies to the whole credential. `modelId` is the requested model, so
3066
+ * omitting it asks only about credential-wide blocks.
3067
+ */
3068
+ #getCredentialBlockedUntil(providerKey: string, credentialIndex: number, modelId?: string): number | undefined {
3055
3069
  const backoffMap = this.#credentialBackoff.get(providerKey);
3056
3070
  if (!backoffMap) return undefined;
3057
- const blockedUntil = backoffMap.get(credentialIndex);
3058
- if (!blockedUntil) return undefined;
3059
- if (blockedUntil <= Date.now()) {
3060
- backoffMap.delete(credentialIndex);
3061
- if (backoffMap.size === 0) {
3062
- this.#credentialBackoff.delete(providerKey);
3063
- }
3064
- return undefined;
3071
+ const entries = backoffMap.get(credentialIndex);
3072
+ if (!entries || entries.length === 0) return undefined;
3073
+ const now = Date.now();
3074
+ const live = entries.filter(entry => entry.untilMs > now);
3075
+ if (live.length !== entries.length) {
3076
+ if (live.length > 0) backoffMap.set(credentialIndex, live);
3077
+ else backoffMap.delete(credentialIndex);
3078
+ if (backoffMap.size === 0) this.#credentialBackoff.delete(providerKey);
3079
+ }
3080
+ let blockedUntil: number | undefined;
3081
+ const wanted = modelId?.trim().toLowerCase();
3082
+ // A recorded scope may name a model or a model-family tier, so match a tier
3083
+ // against the requested model id the same way limit scoping does.
3084
+ const entryApplies = (entryModel: string): boolean => {
3085
+ if (wanted === undefined) return false;
3086
+ const scope = entryModel.toLowerCase();
3087
+ if (scope === wanted) return true;
3088
+ return MODEL_FAMILY_TIERS.has(scope) && wanted.includes(scope);
3089
+ };
3090
+ for (const entry of live) {
3091
+ if (entry.modelId !== undefined && !entryApplies(entry.modelId)) continue;
3092
+ if (blockedUntil === undefined || entry.untilMs > blockedUntil) blockedUntil = entry.untilMs;
3065
3093
  }
3066
3094
  return blockedUntil;
3067
3095
  }
3068
3096
 
3069
3097
  /** Checks if a credential is temporarily blocked due to usage limits. */
3070
- #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
3071
- return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
3098
+ #isCredentialBlocked(providerKey: string, credentialIndex: number, modelId?: string): boolean {
3099
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex, modelId) !== undefined;
3072
3100
  }
3073
3101
 
3074
- /** Marks a credential as blocked until the specified time. */
3075
- #markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
3076
- const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, number>();
3077
- const existing = backoffMap.get(credentialIndex) ?? 0;
3078
- backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
3102
+ /** Marks a credential as blocked until the specified time, optionally for one model. */
3103
+ #markCredentialBlocked(
3104
+ providerKey: string,
3105
+ credentialIndex: number,
3106
+ blockedUntilMs: number,
3107
+ modelId?: string,
3108
+ ): void {
3109
+ const scoped = modelId?.trim() || undefined;
3110
+ const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, CredentialBackoffEntry[]>();
3111
+ const entries = backoffMap.get(credentialIndex) ?? [];
3112
+ const existing = entries.find(entry => entry.modelId === scoped);
3113
+ if (existing) existing.untilMs = Math.max(existing.untilMs, blockedUntilMs);
3114
+ else entries.push({ untilMs: blockedUntilMs, modelId: scoped });
3115
+ backoffMap.set(credentialIndex, entries);
3079
3116
  this.#credentialBackoff.set(providerKey, backoffMap);
3080
3117
  }
3081
3118
 
@@ -3366,12 +3403,14 @@ export class AuthStorage {
3366
3403
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a runtime API key override is active`,
3367
3404
  );
3368
3405
  }
3369
- if (this.#hasConfigOverride(provider, owner)) {
3406
+ const selected = this.#findCredentialBySelector(provider, selector);
3407
+ const configOverride = this.#configOverrideRegistration(provider, owner);
3408
+ if (configOverride && !(configOverride.envSourced && selected?.credential.type === "api_key")) {
3370
3409
  throw new Error(
3371
3410
  `Credential selector ${this.#formatCredentialSelector(selector)} cannot be used for ${provider} while a config API key override is active`,
3372
3411
  );
3373
3412
  }
3374
- if (!this.#findCredentialBySelector(provider, selector)) {
3413
+ if (!selected) {
3375
3414
  throw new Error(`No credential found for ${provider} matching ${this.#formatCredentialSelector(selector)}`);
3376
3415
  }
3377
3416
  }
@@ -5123,15 +5162,40 @@ export class AuthStorage {
5123
5162
  }
5124
5163
 
5125
5164
  /** Returns true if usage indicates rate limit has been reached. */
5126
- #isUsageLimitReached(report: UsageReport): boolean {
5127
- return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
5165
+ /**
5166
+ * True when an exhausted limit applies to `modelId`. A limit scoped to one
5167
+ * model (or one tier's model entry) does not exhaust the credential for other
5168
+ * models; a limit with no model scope and not marked shared-blocking applies to
5169
+ * every model.
5170
+ */
5171
+ #isUsageLimitReached(report: UsageReport, modelId?: string): boolean {
5172
+ return report.limits.some(
5173
+ limit => this.#limitAppliesToModel(limit, modelId) && this.#isUsageLimitExhausted(limit),
5174
+ );
5175
+ }
5176
+
5177
+ /**
5178
+ * Whether a usage limit constrains the requested model. Only model-family
5179
+ * tiers are treated as model scoping: `scope.tier` also carries plans
5180
+ * (Codex/Copilot) and quota tiers (Gemini), which apply to every model.
5181
+ */
5182
+ #limitAppliesToModel(limit: UsageLimit, modelId?: string): boolean {
5183
+ const wanted = modelId?.trim();
5184
+ // Without a requested model, every limit applies: callers that do not know
5185
+ // the model keep today's conservative "any exhausted limit blocks".
5186
+ if (wanted === undefined) return true;
5187
+ const scopedModel = limit.scope?.modelId?.trim();
5188
+ if (scopedModel) return wanted === scopedModel;
5189
+ const tier = limit.scope?.tier?.trim().toLowerCase();
5190
+ if (tier && MODEL_FAMILY_TIERS.has(tier)) return wanted.toLowerCase().includes(tier);
5191
+ return true;
5128
5192
  }
5129
5193
 
5130
5194
  /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
5131
- #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
5195
+ #getUsageResetAtMs(report: UsageReport, nowMs: number, modelId?: string): number | undefined {
5132
5196
  const candidates: number[] = [];
5133
5197
  for (const limit of report.limits) {
5134
- if (!this.#isUsageLimitExhausted(limit)) continue;
5198
+ if (!this.#limitAppliesToModel(limit, modelId) || !this.#isUsageLimitExhausted(limit)) continue;
5135
5199
  const window = limit.window;
5136
5200
  if (window?.resetsAt && window.resetsAt > nowMs) {
5137
5201
  candidates.push(window.resetsAt);
@@ -5141,6 +5205,33 @@ export class AuthStorage {
5141
5205
  return Math.min(...candidates);
5142
5206
  }
5143
5207
 
5208
+ /**
5209
+ * Backoff targets implied by an exhausted usage report: a credential-wide
5210
+ * until for limits that are not model-scoped, plus one entry per exhausted
5211
+ * model-scoped limit. A model-scoped limit must not take the whole credential
5212
+ * out of rotation for other models.
5213
+ */
5214
+ #exhaustedLimitBlockTargets(
5215
+ report: UsageReport,
5216
+ nowMs: number,
5217
+ ): { wideUntil?: number; scoped: Array<{ modelId: string; untilMs: number }> } {
5218
+ let wideUntil: number | undefined;
5219
+ const scoped: Array<{ modelId: string; untilMs: number }> = [];
5220
+ for (const limit of report.limits) {
5221
+ if (!this.#isUsageLimitExhausted(limit)) continue;
5222
+ const resetsAt = limit.window?.resetsAt;
5223
+ if (!resetsAt || resetsAt <= nowMs) continue;
5224
+ const scopeModel = limit.scope?.modelId?.trim();
5225
+ const tier = limit.scope?.tier?.trim().toLowerCase();
5226
+ if (!scopeModel && !(tier && MODEL_FAMILY_TIERS.has(tier))) {
5227
+ wideUntil = wideUntil === undefined ? resetsAt : Math.min(wideUntil, resetsAt);
5228
+ continue;
5229
+ }
5230
+ scoped.push({ modelId: scopeModel ?? tier!, untilMs: resetsAt });
5231
+ }
5232
+ return wideUntil === undefined ? { scoped } : { wideUntil, scoped };
5233
+ }
5234
+
5144
5235
  async #getUsageReport(
5145
5236
  provider: Provider,
5146
5237
  credential: OAuthCredential,
@@ -5669,7 +5760,15 @@ export class AuthStorage {
5669
5760
  async markUsageLimitReached(
5670
5761
  provider: string,
5671
5762
  sessionId: string | undefined,
5672
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal; owner?: object; rowId?: number },
5763
+ options?: {
5764
+ retryAfterMs?: number;
5765
+ baseUrl?: string;
5766
+ signal?: AbortSignal;
5767
+ owner?: object;
5768
+ rowId?: number;
5769
+ /** Scope the block to one model; other models keep using the credential. */
5770
+ modelId?: string;
5771
+ },
5673
5772
  ): Promise<UsageLimitMarkResult> {
5674
5773
  provider = resolveOAuthStorageProvider(provider);
5675
5774
  const ownerOverride = this.#configOverrideRegistration(provider, options?.owner);
@@ -5690,11 +5789,19 @@ export class AuthStorage {
5690
5789
  const now = Date.now();
5691
5790
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
5692
5791
 
5792
+ // Model-scoped exhausted limits only constrain their own model; an
5793
+ // unscoped or shared limit raises the credential-wide backoff.
5794
+ let scopedBlocks: Array<{ modelId: string; untilMs: number }> = [];
5795
+ let reportHasWideLimit = false;
5693
5796
  if (initial.credential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
5694
5797
  const report = await this.#getUsageReport(provider, initial.credential, options);
5695
- if (report && this.#isUsageLimitReached(report)) {
5696
- const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
5697
- if (resetAtMs && resetAtMs > blockedUntil) blockedUntil = resetAtMs;
5798
+ if (report) {
5799
+ const targets = this.#exhaustedLimitBlockTargets(report, Date.now());
5800
+ if (targets.wideUntil !== undefined) {
5801
+ reportHasWideLimit = true;
5802
+ if (targets.wideUntil > blockedUntil) blockedUntil = targets.wideUntil;
5803
+ }
5804
+ scopedBlocks = targets.scoped;
5698
5805
  }
5699
5806
  }
5700
5807
 
@@ -5712,7 +5819,11 @@ export class AuthStorage {
5712
5819
  candidate.entry.credential.type === credentialKind &&
5713
5820
  candidate.entry.id !== failedRowId &&
5714
5821
  (!enabledIds || enabledIds.has(candidate.entry.id)) &&
5715
- !this.#isCredentialBlocked(this.#getProviderTypeKey(provider, credentialKind), candidate.index),
5822
+ !this.#isCredentialBlocked(
5823
+ this.#getProviderTypeKey(provider, credentialKind),
5824
+ candidate.index,
5825
+ options?.modelId,
5826
+ ),
5716
5827
  )
5717
5828
  .map(candidate => candidate.entry.id);
5718
5829
  };
@@ -5725,7 +5836,15 @@ export class AuthStorage {
5725
5836
  };
5726
5837
  }
5727
5838
  const providerKey = this.#getProviderTypeKey(provider, credentialKind);
5728
- this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil);
5839
+ // A report whose only exhausted limits are model-scoped must not take the
5840
+ // whole credential out of rotation; the caller's retry-after backoff applies
5841
+ // to those models instead.
5842
+ if (!(scopedBlocks.length > 0 && !reportHasWideLimit)) {
5843
+ this.#markCredentialBlocked(providerKey, targetIndex, blockedUntil, options?.modelId);
5844
+ }
5845
+ for (const scoped of scopedBlocks) {
5846
+ this.#markCredentialBlocked(providerKey, targetIndex, scoped.untilMs, scoped.modelId);
5847
+ }
5729
5848
  return {
5730
5849
  state: "marked",
5731
5850
  failedRowId,
@@ -5734,6 +5853,30 @@ export class AuthStorage {
5734
5853
  };
5735
5854
  }
5736
5855
 
5856
+ /**
5857
+ * Opaque stored row IDs for credentials whose resolved key matches `apiKey`.
5858
+ * Unlike the session's sticky row pointer, these remain tied to the actual key
5859
+ * when concurrent requests share a credential session ID.
5860
+ * A row that cannot be resolved is skipped so an unrelated resolver failure
5861
+ * cannot prevent callers from using already-known credential identity.
5862
+ */
5863
+ async getCredentialRowIdsMatchingApiKey(provider: string, apiKey: string): Promise<number[]> {
5864
+ const storageProvider = resolveOAuthStorageProvider(provider);
5865
+ const rowIds: number[] = [];
5866
+ for (const entry of this.#getStoredCredentials(storageProvider)) {
5867
+ try {
5868
+ if (await this.#credentialMatchesApiKey(storageProvider, entry.credential, apiKey)) rowIds.push(entry.id);
5869
+ } catch (error) {
5870
+ logger.warn("Failed to resolve stored API key while matching credential rows", {
5871
+ provider: storageProvider,
5872
+ credentialId: entry.id,
5873
+ errorType: error instanceof Error ? error.name : typeof error,
5874
+ });
5875
+ }
5876
+ }
5877
+ return rowIds;
5878
+ }
5879
+
5737
5880
  /**
5738
5881
  * Mark the stored credential whose key matches `apiKey` usage-limited for
5739
5882
  * `retryAfterMs` (or the default backoff). Used when the caller knows
@@ -5866,7 +6009,11 @@ export class AuthStorage {
5866
6009
  args.order.map(async idx => {
5867
6010
  const selection = args.credentials[idx];
5868
6011
  if (!selection) return null;
5869
- const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
6012
+ const blockedUntil = this.#getCredentialBlockedUntil(
6013
+ args.providerKey,
6014
+ selection.index,
6015
+ args.options?.modelId,
6016
+ );
5870
6017
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
5871
6018
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
5872
6019
  baseUrl: args.options?.baseUrl,
@@ -5904,13 +6051,29 @@ export class AuthStorage {
5904
6051
  const { selection, usage, usageChecked } = result;
5905
6052
  let { blockedUntil } = result;
5906
6053
  let blocked = blockedUntil !== undefined;
5907
- if (!blocked && usage && this.#isUsageLimitReached(usage)) {
5908
- const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
6054
+ if (!blocked && usage && this.#isUsageLimitReached(usage, args.options?.modelId)) {
6055
+ const resetAtMs = this.#getUsageResetAtMs(usage, nowMs, args.options?.modelId);
5909
6056
  blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
5910
- this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
6057
+ const targets = this.#exhaustedLimitBlockTargets(usage, nowMs);
6058
+ if (targets.wideUntil === undefined && targets.scoped.length > 0) {
6059
+ for (const scoped of targets.scoped) {
6060
+ this.#markCredentialBlocked(args.providerKey, selection.index, scoped.untilMs, scoped.modelId);
6061
+ }
6062
+ } else {
6063
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
6064
+ }
5911
6065
  blocked = true;
5912
6066
  }
5913
- const windows = usage ? strategy.findWindowLimits(usage) : undefined;
6067
+ // Rank on the windows that apply to the requested model so a limit scoped
6068
+ // to another model does not depress this credential's drain metrics.
6069
+ const scopedUsage =
6070
+ usage && args.options?.modelId
6071
+ ? {
6072
+ ...usage,
6073
+ limits: usage.limits.filter(limit => this.#limitAppliesToModel(limit, args.options?.modelId)),
6074
+ }
6075
+ : usage;
6076
+ const windows = scopedUsage ? strategy.findWindowLimits(scopedUsage) : undefined;
5914
6077
  const primary = windows?.primary;
5915
6078
  const secondary = windows?.secondary;
5916
6079
  const secondaryTarget = secondary ?? primary;
@@ -6037,7 +6200,8 @@ export class AuthStorage {
6037
6200
  // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
6038
6201
  // with the most headroom proactively and fall back intelligently when rate-limited.
6039
6202
  const sessionPreferredIsAvailable =
6040
- sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
6203
+ sessionPreferredIndex !== undefined &&
6204
+ !this.#isCredentialBlocked(providerKey, sessionPreferredIndex, options?.modelId);
6041
6205
  const shouldRank = !selectedCredential && checkUsage && !sessionPreferredIsAvailable;
6042
6206
  const candidates = shouldRank
6043
6207
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
@@ -6077,7 +6241,7 @@ export class AuthStorage {
6077
6241
  if (!selectedCredential && sessionPreferredIndex !== undefined) {
6078
6242
  const sessionPreferredCandidate = candidates.findIndex(
6079
6243
  candidate =>
6080
- !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
6244
+ !this.#isCredentialBlocked(providerKey, candidate.selection.index, options?.modelId) &&
6081
6245
  candidate.selection.index === sessionPreferredIndex,
6082
6246
  );
6083
6247
  if (sessionPreferredCandidate > 0) {
@@ -6143,7 +6307,7 @@ export class AuthStorage {
6143
6307
  if (resolved) return resolved;
6144
6308
  }
6145
6309
 
6146
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
6310
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, options?.modelId)) {
6147
6311
  return this.#tryOAuthCredential(
6148
6312
  provider,
6149
6313
  fallback.selection,
@@ -6540,7 +6704,7 @@ export class AuthStorage {
6540
6704
  ): Promise<OAuthResolutionResult | undefined> {
6541
6705
  const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
6542
6706
  if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
6543
- if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
6707
+ if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, options?.modelId)) {
6544
6708
  return undefined;
6545
6709
  }
6546
6710
 
@@ -6562,8 +6726,8 @@ export class AuthStorage {
6562
6726
  });
6563
6727
  usageChecked = true;
6564
6728
  }
6565
- if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
6566
- const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
6729
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
6730
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
6567
6731
  this.#markCredentialBlocked(
6568
6732
  providerKey,
6569
6733
  selection.index,
@@ -6644,8 +6808,8 @@ export class AuthStorage {
6644
6808
  });
6645
6809
  usageChecked = true;
6646
6810
  }
6647
- if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
6648
- const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
6811
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage, options?.modelId)) {
6812
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now(), options?.modelId);
6649
6813
  this.#markCredentialBlocked(
6650
6814
  providerKey,
6651
6815
  selection.index,
@@ -6657,7 +6821,7 @@ export class AuthStorage {
6657
6821
  if (!this.#reconcileOAuthCredentialSelection(provider, selection)) return undefined;
6658
6822
  if (!authCredentialEquals(selection.credential, updated)) return undefined;
6659
6823
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
6660
- return { apiKey: result.apiKey, credential: updated };
6824
+ return { apiKey: result.apiKey, credential: updated, credentialRowId: selection.id };
6661
6825
  } catch (error) {
6662
6826
  if (options?.signal?.aborted) throw error;
6663
6827
  if (isSqliteError(error)) throw error;
@@ -7000,6 +7164,31 @@ export class AuthStorage {
7000
7164
  * 10. Fallback resolver (models.yml custom providers, last-resort)
7001
7165
  */
7002
7166
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
7167
+ return this.#resolveApiKey(provider, sessionId, options);
7168
+ }
7169
+
7170
+ /**
7171
+ * Resolve an API key and retain the selected stored row identity across token refreshes
7172
+ * and concurrent changes to the session's sticky credential pointer.
7173
+ */
7174
+ async getApiKeyWithCredentialRowId(
7175
+ provider: string,
7176
+ sessionId?: string,
7177
+ options?: AuthApiKeyOptions,
7178
+ ): Promise<{ apiKey: string | undefined; credentialRowId: number | undefined }> {
7179
+ let credentialRowId: number | undefined;
7180
+ const apiKey = await this.#resolveApiKey(provider, sessionId, options, rowId => {
7181
+ credentialRowId = rowId;
7182
+ });
7183
+ return { apiKey, credentialRowId };
7184
+ }
7185
+
7186
+ async #resolveApiKey(
7187
+ provider: string,
7188
+ sessionId?: string,
7189
+ options?: AuthApiKeyOptions,
7190
+ onCredentialRowResolved?: (rowId: number) => void,
7191
+ ): Promise<string | undefined> {
7003
7192
  provider = resolveOAuthStorageProvider(provider);
7004
7193
  const selectedCredential = this.#resolveSelectedStoredCredential(provider, options, sessionId);
7005
7194
 
@@ -7021,7 +7210,12 @@ export class AuthStorage {
7021
7210
  // (validated at login, rotated on 401), while the pointed-to env
7022
7211
  // value can go stale with no recovery path — prefer the stored
7023
7212
  // credential. Stored OAuth credentials still yield to the override.
7024
- const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(provider, selectedCredential, sessionId);
7213
+ const storedApiKey = await this.#resolveStoredApiKeyOverEnvConfig(
7214
+ provider,
7215
+ selectedCredential,
7216
+ sessionId,
7217
+ onCredentialRowResolved,
7218
+ );
7025
7219
  if (storedApiKey) return storedApiKey;
7026
7220
  return configKey;
7027
7221
  }
@@ -7036,14 +7230,19 @@ export class AuthStorage {
7036
7230
 
7037
7231
  if (selectedCredential?.credential.type === "api_key") {
7038
7232
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
7039
- return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
7233
+ const apiKey = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
7234
+ if (apiKey) onCredentialRowResolved?.(selectedCredential.id);
7235
+ return apiKey;
7040
7236
  }
7041
7237
 
7042
7238
  let oauthAttempted = false;
7043
7239
  if (!selectedCredential && this.#getSessionCredential(provider, sessionId)?.type === "oauth") {
7044
7240
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
7045
7241
  oauthAttempted = true;
7046
- if (oauthResolved) return oauthResolved.apiKey;
7242
+ if (oauthResolved) {
7243
+ onCredentialRowResolved?.(oauthResolved.credentialRowId);
7244
+ return oauthResolved.apiKey;
7245
+ }
7047
7246
  }
7048
7247
 
7049
7248
  const attemptedApiKeyIndices = new Set<number>();
@@ -7055,6 +7254,7 @@ export class AuthStorage {
7055
7254
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
7056
7255
  if (resolved) {
7057
7256
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7257
+ onCredentialRowResolved?.(apiKeySelection.id);
7058
7258
  return resolved;
7059
7259
  }
7060
7260
  }
@@ -7062,7 +7262,10 @@ export class AuthStorage {
7062
7262
 
7063
7263
  if (!oauthAttempted) {
7064
7264
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
7065
- if (oauthResolved) return oauthResolved.apiKey;
7265
+ if (oauthResolved) {
7266
+ onCredentialRowResolved?.(oauthResolved.credentialRowId);
7267
+ return oauthResolved.apiKey;
7268
+ }
7066
7269
  }
7067
7270
 
7068
7271
  if (!selectedCredential) {
@@ -7073,6 +7276,7 @@ export class AuthStorage {
7073
7276
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
7074
7277
  if (resolved) {
7075
7278
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7279
+ onCredentialRowResolved?.(apiKeySelection.id);
7076
7280
  return resolved;
7077
7281
  }
7078
7282
  }
@@ -7091,21 +7295,25 @@ export class AuthStorage {
7091
7295
  /**
7092
7296
  * Resolve a stored api_key credential that outranks an env-sourced config
7093
7297
  * override (`apiKeyEnv`). Mirrors the api_key branches of {@link getApiKey}:
7094
- * the selector-pinned credential first, then the round-robin/session pool.
7095
- * Returns undefined when no stored api_key credential resolves, leaving the
7096
- * env-sourced override in effect.
7298
+ * a selected credential must resolve or fail closed; unpinned resolution then
7299
+ * tries the round-robin/session pool. Returns undefined when no unpinned
7300
+ * stored api_key credential resolves, leaving the env-sourced override in
7301
+ * effect.
7097
7302
  */
7098
7303
  async #resolveStoredApiKeyOverEnvConfig(
7099
7304
  provider: string,
7100
7305
  selectedCredential: ({ index: number } & StoredCredential) | undefined,
7101
7306
  sessionId?: string,
7307
+ onCredentialRowResolved?: (rowId: number) => void,
7102
7308
  ): Promise<string | undefined> {
7103
7309
  if (selectedCredential?.credential.type === "api_key") {
7104
7310
  const resolved = await this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
7105
7311
  if (resolved) {
7106
7312
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
7313
+ onCredentialRowResolved?.(selectedCredential.id);
7107
7314
  return resolved;
7108
7315
  }
7316
+ throw new Error(`Selected credential for ${provider} (id:${selectedCredential.id}) is unavailable`);
7109
7317
  }
7110
7318
  const attemptedApiKeyIndices = new Set<number>();
7111
7319
  for (;;) {
@@ -7115,6 +7323,7 @@ export class AuthStorage {
7115
7323
  const resolved = await this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
7116
7324
  if (resolved) {
7117
7325
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
7326
+ onCredentialRowResolved?.(apiKeySelection.id);
7118
7327
  return resolved;
7119
7328
  }
7120
7329
  }
@@ -1,3 +1,11 @@
1
1
  import type { Api, Model, ModelCost } from "./types";
2
2
  export declare function getOpenAIModelCost<TApi extends Api>(model: Model<TApi>, inputTokens: number): ModelCost | undefined;
3
+ export declare function getAnthropicModelCost<TApi extends Api>(model: Model<TApi>, inputTokens: number): ModelCost | undefined;
4
+ /**
5
+ * $/million rate for 1-hour cache writes, or undefined when the model has a single
6
+ * cache-write rate. Cost calculation applies it to `usage.cttl.ephemeral1h` and bills
7
+ * the remaining cache-write tokens at `ModelCost.cacheWrite`.
8
+ */
9
+ export declare function getAnthropicCacheWriteOneHourRate<TApi extends Api>(model: Model<TApi>, inputTokens: number): number | undefined;
10
+ export declare function applyAnthropicModelPricing<TApi extends Api>(model: Model<TApi>): void;
3
11
  export declare function applyOpenAIModelPricing<TApi extends Api>(model: Model<TApi>): void;
@@ -6,6 +6,17 @@ interface TieredPricing {
6
6
  }
7
7
 
8
8
  const LONG_CONTEXT_THRESHOLD = 272_000;
9
+ // Anthropic Haiku 5.5's direct Messages API charges five times the base rates
10
+ // when prompt input exceeds 100K tokens, including cached input.
11
+ // https://platform.claude.com/docs/en/models/haiku-5-5/overview#pricing
12
+ const ANTHROPIC_HAIKU_5_5_LONG_CONTEXT_PRICING: LongContextPricing = {
13
+ threshold: 100_000,
14
+ cost: { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 },
15
+ };
16
+ // `ModelCost.cacheWrite` is the 5-minute cache-write rate. Haiku 5.5 charges 1-hour
17
+ // writes at twice the base input rate in each prompt-length tier ($0.20 up to 100K
18
+ // tokens, $1 above), and the provider defaults to `ttl: "1h"`.
19
+ const ANTHROPIC_HAIKU_5_5_CACHE_WRITE_1H = { standard: 0.2, longContext: 1 };
9
20
 
10
21
  const GPT_5_6_SOL_PRICING: TieredPricing = {
11
22
  cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
@@ -95,6 +106,82 @@ export function getOpenAIModelCost<TApi extends Api>(model: Model<TApi>, inputTo
95
106
  return inputTokens > pricing.longContextPricing.threshold ? pricing.longContextPricing.cost : pricing.cost;
96
107
  }
97
108
 
109
+ const BEDROCK_HAIKU_5_5_ID = /^(?:(?:au|eu|global|jp|us).)?anthropic.claude-haiku-5-5$/;
110
+ // Bedrock bills Haiku 5.5 prompts above 100K tokens at higher "Long Context" rates.
111
+ // AWS documents the threshold but the per-token rates only on its pricing page,
112
+ // which is not machine-readable; mirror Anthropic's published 5x multiplier on each
113
+ // inference profile's own base rates.
114
+ // https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-haiku-5-5.html#model-card-anthropic-claude-haiku-5-5-pricing
115
+ const BEDROCK_HAIKU_5_5_LONG_CONTEXT_MULTIPLIER = 5;
116
+
117
+ function getAnthropicLongContextPricing<TApi extends Api>(model: Model<TApi>): LongContextPricing | undefined {
118
+ if (model.provider === "anthropic" && model.api === "anthropic-messages" && model.id === "claude-haiku-5-5") {
119
+ return {
120
+ threshold: ANTHROPIC_HAIKU_5_5_LONG_CONTEXT_PRICING.threshold,
121
+ cost: { ...ANTHROPIC_HAIKU_5_5_LONG_CONTEXT_PRICING.cost },
122
+ };
123
+ }
124
+ if (
125
+ model.provider === "amazon-bedrock" &&
126
+ model.api === "bedrock-converse-stream" &&
127
+ BEDROCK_HAIKU_5_5_ID.test(model.id)
128
+ ) {
129
+ const m = BEDROCK_HAIKU_5_5_LONG_CONTEXT_MULTIPLIER;
130
+ const scale = (rate: number): number => Math.round(rate * m * 1e9) / 1e9;
131
+ return {
132
+ threshold: ANTHROPIC_HAIKU_5_5_LONG_CONTEXT_PRICING.threshold,
133
+ cost: {
134
+ input: scale(model.cost.input),
135
+ output: scale(model.cost.output),
136
+ cacheRead: scale(model.cost.cacheRead),
137
+ cacheWrite: scale(model.cost.cacheWrite),
138
+ },
139
+ };
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ export function getAnthropicModelCost<TApi extends Api>(
145
+ model: Model<TApi>,
146
+ inputTokens: number,
147
+ ): ModelCost | undefined {
148
+ const pricing = getAnthropicLongContextPricing(model);
149
+ if (!pricing || inputTokens <= pricing.threshold) return undefined;
150
+ return pricing.cost;
151
+ }
152
+
153
+ /**
154
+ * $/million rate for 1-hour cache writes, or undefined when the model has a single
155
+ * cache-write rate. Cost calculation applies it to `usage.cttl.ephemeral1h` and bills
156
+ * the remaining cache-write tokens at `ModelCost.cacheWrite`.
157
+ */
158
+ export function getAnthropicCacheWriteOneHourRate<TApi extends Api>(
159
+ model: Model<TApi>,
160
+ inputTokens: number,
161
+ ): number | undefined {
162
+ const aboveThreshold = inputTokens > ANTHROPIC_HAIKU_5_5_LONG_CONTEXT_PRICING.threshold;
163
+ if (model.provider === "anthropic" && model.api === "anthropic-messages" && model.id === "claude-haiku-5-5") {
164
+ return aboveThreshold
165
+ ? ANTHROPIC_HAIKU_5_5_CACHE_WRITE_1H.longContext
166
+ : ANTHROPIC_HAIKU_5_5_CACHE_WRITE_1H.standard;
167
+ }
168
+ if (
169
+ model.provider === "amazon-bedrock" &&
170
+ model.api === "bedrock-converse-stream" &&
171
+ BEDROCK_HAIKU_5_5_ID.test(model.id)
172
+ ) {
173
+ // Same structure as direct: 1-hour writes cost twice the tier's input rate.
174
+ const multiplier = aboveThreshold ? BEDROCK_HAIKU_5_5_LONG_CONTEXT_MULTIPLIER : 1;
175
+ return Math.round(2 * model.cost.input * multiplier * 1e9) / 1e9;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ export function applyAnthropicModelPricing<TApi extends Api>(model: Model<TApi>): void {
181
+ const pricing = getAnthropicLongContextPricing(model);
182
+ if (pricing) model.longContextPricing = pricing;
183
+ }
184
+
98
185
  export function applyOpenAIModelPricing<TApi extends Api>(model: Model<TApi>): void {
99
186
  if (model.provider !== "openai" && model.provider !== "openai-codex") {
100
187
  return;