pi-cache-optimizer 2.6.12 → 2.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.ts +168 -17
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1717,6 +1717,51 @@ function keyForModelExt(model: { provider: string; id: string }): string {
1717
1717
  return `${model.provider}/${model.id}`;
1718
1718
  }
1719
1719
 
1720
+ /**
1721
+ * For direct (non-virtual-routing) providers, the upstream API may normalize or
1722
+ * rename the model id echoed in its response (e.g. a request to
1723
+ * `zai-org/GLM-5.2-FP8` returns a message whose `model` field is
1724
+ * `GLM5.2-FP8`). Writing stats under the echoed name fragments the bucket
1725
+ * away from the active-model key the footer reads (`totalsByModel[ctx.model]`),
1726
+ * so the footer shows 0% even when the backend is hitting cache.
1727
+ *
1728
+ * When the response-derived statsModel differs from the active context model
1729
+ * only in name (same provider + same cache adapter), consolidate stats back to
1730
+ * the active model identity. Virtual routing providers are excluded — their
1731
+ * message-local metadata is authoritative for router correctness (spec:
1732
+ * `message_end` MUST prefer assistant message metadata).
1733
+ *
1734
+ * Never merges across providers or across adapters, so genuinely different
1735
+ * models are never combined.
1736
+ */
1737
+ function consolidateDirectProviderStatsModel(
1738
+ statsModel: PiModel | undefined,
1739
+ ctxModel: PiModel | undefined,
1740
+ ctx?: Pick<ExtensionContext, "sessionManager">,
1741
+ ): PiModel | undefined {
1742
+ if (!statsModel || !ctxModel) return statsModel;
1743
+ // Virtual routing providers keep message-local stats identity.
1744
+ if (isVirtualRoutingModel(ctxModel, ctx)) return statsModel;
1745
+ // Only consolidate within the same provider.
1746
+ if (statsModel.provider !== ctxModel.provider) return statsModel;
1747
+ // Only consolidate when both resolve to the same cache adapter object, so
1748
+ // genuinely different models sharing a provider (or sharing the same adapter
1749
+ // family id, e.g. GPT and GLM both report family id "openai") are never
1750
+ // merged. `selectAdapterForModel` returns the precise adapter object, so
1751
+ // object identity is the correct criterion.
1752
+ const statsAdapter = selectAdapterForModel(statsModel);
1753
+ const ctxAdapter = selectAdapterForModel(ctxModel);
1754
+ if (!statsAdapter || !ctxAdapter || statsAdapter !== ctxAdapter) return statsModel;
1755
+ // No drift — nothing to consolidate.
1756
+ if (statsModel.id === ctxModel.id) return statsModel;
1757
+ // Consolidate: pin stats to the active-model identity the footer reads.
1758
+ return {
1759
+ ...statsModel,
1760
+ id: ctxModel.id,
1761
+ name: ctxModel.name || ctxModel.id,
1762
+ };
1763
+ }
1764
+
1720
1765
  function usageRecordFromAssistant(message: unknown): UnknownRecord | undefined {
1721
1766
  return asRecord(getAssistantRecord(message)?.usage);
1722
1767
  }
@@ -3876,6 +3921,22 @@ function isPromptCacheRetention400Applicable(model: PiModel): boolean {
3876
3921
  getCompat(model).supportsLongCacheRetention === true;
3877
3922
  }
3878
3923
 
3924
+ /**
3925
+ * Whether the 403 sendSessionAffinityHeaders diagnostic applies to a model.
3926
+ *
3927
+ * Pi's openai-completions / openai-responses adapters send three custom HTTP
3928
+ * headers (session_id, x-client-request-id, x-session-affinity) when
3929
+ * `sendSessionAffinityHeaders` is enabled. Some third-party proxies / CDNs /
3930
+ * WAFs block these custom headers and return HTTP 403 "Your request was
3931
+ * blocked". This guard returns true only for OpenAI-compatible APIs whose
3932
+ * merged compat currently enables session-affinity headers, so the 403
3933
+ * monitoring and doctor/fix paths can advise disabling the flag.
3934
+ */
3935
+ function isSessionAffinity403Applicable(model: PiModel): boolean {
3936
+ if (!isOpenAICompatibleApi(model.api)) return false;
3937
+ return getCompat(model).sendSessionAffinityHeaders === true;
3938
+ }
3939
+
3879
3940
  /**
3880
3941
  * Detect router / channel profiles from a PiModel and return diagnostic notes.
3881
3942
  *
@@ -4073,7 +4134,7 @@ function getCompatCheckNotApplicableLines(model: PiModel): string[] {
4073
4134
  return ["ℹ️ Compat check not applicable for this model."];
4074
4135
  }
4075
4136
 
4076
- function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean } = {}): string {
4137
+ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; sessionAffinity403?: boolean } = {}): string {
4077
4138
  const lines: string[] = [];
4078
4139
  lines.push(`Provider: ${model.provider}`);
4079
4140
  lines.push(`Model: ${model.id}`);
@@ -4132,6 +4193,25 @@ function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400
4132
4193
  }
4133
4194
  }
4134
4195
 
4196
+ // ── Session affinity 403 diagnostics ──
4197
+ // Advises the user when sendSessionAffinityHeaders is enabled, since some
4198
+ // third-party proxies / CDNs / WAFs block Pi's custom session-affinity HTTP
4199
+ // headers (session_id, x-client-request-id, x-session-affinity) and return
4200
+ // 403. If a 403 was already observed for this model, show a stronger hint.
4201
+ if (isSessionAffinity403Applicable(model)) {
4202
+ lines.push("");
4203
+ if (options.sessionAffinity403) {
4204
+ lines.push("⚠️ A 403 response was observed while sendSessionAffinityHeaders is enabled.");
4205
+ lines.push(" The proxy/CDN likely blocks Pi's custom session-affinity headers (session_id,");
4206
+ lines.push(" x-client-request-id, x-session-affinity). Run /cache-optimizer fix to");
4207
+ lines.push(` set sendSessionAffinityHeaders: false in ${getModelsJsonDisplayPath()}.`);
4208
+ } else {
4209
+ lines.push("ℹ️ Session affinity headers are enabled. Some CDNs/WAFs block custom headers");
4210
+ lines.push(" (session_id, x-client-request-id, x-session-affinity) and return 403. If you");
4211
+ lines.push(" see 403 errors, run /cache-optimizer fix to set sendSessionAffinityHeaders: false.");
4212
+ }
4213
+ }
4214
+
4135
4215
  // ── Router/channel diagnostics ──
4136
4216
  const routerNotes = describeRouterChannelDiagnostics(model);
4137
4217
  if (routerNotes.length > 0) {
@@ -4322,6 +4402,13 @@ function buildCompatDiagnosis(model: PiModel): string | undefined {
4322
4402
  if (isPromptCacheRetention400Applicable(model)) {
4323
4403
  lines.push(getPromptCacheRetentionUnsupportedHint());
4324
4404
  }
4405
+ // Advisory for 403 session-affinity header blocking (only when enabled).
4406
+ if (isSessionAffinity403Applicable(model)) {
4407
+ lines.push(
4408
+ "ℹ️ Session affinity headers are enabled. If you see 403 \"blocked\" errors,",
4409
+ " the proxy/CDN may be blocking Pi's custom headers. Set sendSessionAffinityHeaders: false.",
4410
+ );
4411
+ }
4325
4412
  appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
4326
4413
  } else {
4327
4414
  lines.push(...getCompatCheckNotApplicableLines(model));
@@ -5598,6 +5685,7 @@ export const __internals_for_tests = {
5598
5685
  isOfficialOpenAIBaseUrl,
5599
5686
  isCompatCheckApplicable,
5600
5687
  isPromptCacheRetention400Applicable,
5688
+ isSessionAffinity403Applicable,
5601
5689
  hasPromptCacheRetentionUnsupportedSignal,
5602
5690
  // Non-GPT OpenAI-compatible model detection
5603
5691
  isKimiLikeModel,
@@ -5715,6 +5803,7 @@ export const __internals_for_tests = {
5715
5803
  getAssistantMessageModelTokenValues,
5716
5804
  getCompat,
5717
5805
  modelKey,
5806
+ consolidateDirectProviderStatsModel,
5718
5807
  // Platform-friendly path helpers
5719
5808
  getModelsJsonDisplayPath,
5720
5809
  buildProviderCompatOverride,
@@ -5813,6 +5902,8 @@ export default function (pi: ExtensionAPI) {
5813
5902
  const warnedModels = new Set<string>();
5814
5903
  const promptCacheRetention400Models = new Set<string>();
5815
5904
  const warnedPromptCacheRetention400Models = new Set<string>();
5905
+ const sendSessionAffinityHeaders403Models = new Set<string>();
5906
+ const warnedSendSessionAffinityHeaders403Models = new Set<string>();
5816
5907
  let cacheStatsByModel: Record<string, CacheStats> = {};
5817
5908
  let cacheStatsTotalsByModel: Record<string, CacheStats> = {};
5818
5909
  let cacheStatsLegacyFamily: Partial<Record<CacheProviderId, CacheStats>> = emptyAllCacheStats();
@@ -6460,20 +6551,48 @@ export default function (pi: ExtensionAPI) {
6460
6551
  pi.on("after_provider_response", (event, ctx) => {
6461
6552
  const model = resolveRouteModel(ctx.model, ctx) ?? ctx.model;
6462
6553
  if (!runtimeOptimizerEnabled || !model) return;
6463
- if (event.status !== 400) return;
6464
- if (!isPromptCacheRetention400Applicable(model)) return;
6465
- if (!hasPromptCacheRetentionUnsupportedSignal(event.headers)) return;
6466
6554
 
6467
- const key = modelKey(model);
6468
- promptCacheRetention400Models.add(key);
6469
- if (warnedPromptCacheRetention400Models.has(key)) return;
6470
- warnedPromptCacheRetention400Models.add(key);
6471
- ctx.ui.notify(
6472
- `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
6473
- getPromptCacheRetentionUnsupportedHint() +
6474
- ` Run /cache-optimizer doctor for the exact edit location.`,
6475
- "warning",
6476
- );
6555
+ // ── 400: prompt_cache_retention unsupported ──
6556
+ if (event.status === 400) {
6557
+ if (!isPromptCacheRetention400Applicable(model)) return;
6558
+ if (!hasPromptCacheRetentionUnsupportedSignal(event.headers)) return;
6559
+
6560
+ const key = modelKey(model);
6561
+ promptCacheRetention400Models.add(key);
6562
+ if (warnedPromptCacheRetention400Models.has(key)) return;
6563
+ warnedPromptCacheRetention400Models.add(key);
6564
+ ctx.ui.notify(
6565
+ `⚠️ ${LOG_PREFIX}: ${key} returned HTTP 400 while supportsLongCacheRetention is enabled. ` +
6566
+ getPromptCacheRetentionUnsupportedHint() +
6567
+ ` Run /cache-optimizer doctor for the exact edit location.`,
6568
+ "warning",
6569
+ );
6570
+ return;
6571
+ }
6572
+
6573
+ // ── 403: sendSessionAffinityHeaders blocked by CDN/WAF ──
6574
+ //
6575
+ // Pi's openai-completions/responses adapters send three custom HTTP
6576
+ // headers (session_id, x-client-request-id, x-session-affinity) when
6577
+ // sendSessionAffinityHeaders is enabled. Some third-party proxies /
6578
+ // CDNs / WAFs block these custom headers and return 403. We record a
6579
+ // one-time per-session notification and surface it in doctor/fix so the
6580
+ // user can disable the flag for the blocked model.
6581
+ if (event.status === 403) {
6582
+ if (!isSessionAffinity403Applicable(model)) return;
6583
+
6584
+ const key403 = modelKey(model);
6585
+ sendSessionAffinityHeaders403Models.add(key403);
6586
+ if (warnedSendSessionAffinityHeaders403Models.has(key403)) return;
6587
+ warnedSendSessionAffinityHeaders403Models.add(key403);
6588
+ ctx.ui.notify(
6589
+ `⚠️ ${LOG_PREFIX}: ${key403} returned HTTP 403 while sendSessionAffinityHeaders is enabled. ` +
6590
+ `The proxy/CDN may be blocking Pi's custom session-affinity headers (session_id, x-client-request-id, x-session-affinity). ` +
6591
+ `Run /cache-optimizer doctor for details and /cache-optimizer fix to set sendSessionAffinityHeaders: false.`,
6592
+ "warning",
6593
+ );
6594
+ return;
6595
+ }
6477
6596
  });
6478
6597
 
6479
6598
  pi.on("message_end", async (event, ctx) => {
@@ -6498,7 +6617,17 @@ export default function (pi: ExtensionAPI) {
6498
6617
  // Completed message metadata is request-local and authoritative for virtual
6499
6618
  // routing providers. Use it whenever it supplies provider/model identity;
6500
6619
  // fall back to the active context model for direct providers.
6501
- const statsModel = modelFromAssistantMessage(event.message, ctx.model) ?? ctx.model;
6620
+ let statsModel = modelFromAssistantMessage(event.message, ctx.model) ?? ctx.model;
6621
+ // For direct (non-virtual-routing) providers, the upstream API may echo a
6622
+ // normalized/renamed model id in its response (e.g. request
6623
+ // `zai-org/GLM-5.2-FP8` but the message carries `GLM5.2-FP8`). Writing
6624
+ // stats under the echoed name fragments the bucket away from the
6625
+ // active-model key the footer reads, showing 0% even when the backend is
6626
+ // hitting cache. When the response model drifts from the active model only
6627
+ // in name (same provider, same cache adapter), consolidate stats back to
6628
+ // the active model id. Virtual routing providers keep message-local
6629
+ // identity (router correctness).
6630
+ statsModel = consolidateDirectProviderStatsModel(statsModel, ctx.model, ctx);
6502
6631
  let routedModelChanged = false;
6503
6632
  if (isVirtualRoutingModel(ctx.model, ctx) && statsModel && !isVirtualRoutingModel(statsModel, ctx)) {
6504
6633
  const nextRoutedModel: PersistedRoutedModelRef = {
@@ -6585,7 +6714,7 @@ export default function (pi: ExtensionAPI) {
6585
6714
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
6586
6715
  return;
6587
6716
  }
6588
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)) });
6717
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)) });
6589
6718
  const adapter = selectAdapterForModel(model);
6590
6719
  const sk = model ? sessionModelKey(model) : undefined;
6591
6720
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
@@ -6675,6 +6804,21 @@ export default function (pi: ExtensionAPI) {
6675
6804
  };
6676
6805
  }
6677
6806
 
6807
+ // If no regular missing compat flags but the model has a recorded
6808
+ // 403 while sendSessionAffinityHeaders was enabled (Pi sent custom
6809
+ // session-affinity HTTP headers and the proxy/CDN blocked them), offer
6810
+ // to override `sendSessionAffinityHeaders` to false in models.json.
6811
+ if (!suggestion && isSessionAffinity403Applicable(model) && sendSessionAffinityHeaders403Models.has(modelKey(model))) {
6812
+ const key = modelKey(model);
6813
+ const slashIdx = key.indexOf("/");
6814
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
6815
+ suggestion = {
6816
+ providerLabel,
6817
+ modelId: model.id,
6818
+ compatKeys: { sendSessionAffinityHeaders: false },
6819
+ };
6820
+ }
6821
+
6678
6822
  if (!suggestion) {
6679
6823
  const key = modelKey(model);
6680
6824
  cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
@@ -6699,6 +6843,13 @@ export default function (pi: ExtensionAPI) {
6699
6843
  "Create or edit the entry below to override supportsLongCacheRetention to false.",
6700
6844
  );
6701
6845
  }
6846
+ if (sendSessionAffinityHeaders403Models.has(modelKey(model))) {
6847
+ manualLines.push(
6848
+ "",
6849
+ "💡 This model returned HTTP 403 while sendSessionAffinityHeaders was enabled.",
6850
+ "Create or edit the entry below to override sendSessionAffinityHeaders to false.",
6851
+ );
6852
+ }
6702
6853
  manualLines.push(
6703
6854
  "",
6704
6855
  "If the provider/model already exists in models.json, add these compat keys under",
@@ -6984,7 +7135,7 @@ export default function (pi: ExtensionAPI) {
6984
7135
  if (!model) {
6985
7136
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
6986
7137
  } else {
6987
- const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)) });
7138
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)) });
6988
7139
  const adapter = selectAdapterForModel(model);
6989
7140
  const sk = model ? sessionModelKey(model) : undefined;
6990
7141
  const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.6.12",
3
+ "version": "2.6.14",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",