@eddyskywalker/dsh-chatgpt-subscription 0.48.3 → 0.49.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/lib/client.js +2381 -179
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +875 -33
  5. package/lib/types/client/index.d.ts +2 -0
  6. package/lib/types/client/index.d.ts.map +1 -1
  7. package/lib/types/client/kimi-code/KimiCodeSection.d.ts.map +1 -1
  8. package/lib/types/client/kimi-code/locales.d.ts +14 -2
  9. package/lib/types/client/kimi-code/locales.d.ts.map +1 -1
  10. package/lib/types/client/locales-ru/kimi-code.d.ts.map +1 -1
  11. package/lib/types/client/subagent/SubagentCard.d.ts +35 -0
  12. package/lib/types/client/subagent/SubagentCard.d.ts.map +1 -0
  13. package/lib/types/client/subagent/index.d.ts +28 -0
  14. package/lib/types/client/subagent/index.d.ts.map +1 -0
  15. package/lib/types/client/subagent/locales.d.ts +172 -0
  16. package/lib/types/client/subagent/locales.d.ts.map +1 -0
  17. package/lib/types/client/subagent/styles.d.ts +20 -0
  18. package/lib/types/client/subagent/styles.d.ts.map +1 -0
  19. package/lib/types/client/subagent/subagent-card-controller.d.ts +329 -0
  20. package/lib/types/client/subagent/subagent-card-controller.d.ts.map +1 -0
  21. package/lib/types/compat.d.ts +1 -1
  22. package/lib/types/host/common/llm-compat.d.ts +3 -1
  23. package/lib/types/host/common/llm-compat.d.ts.map +1 -1
  24. package/lib/types/host/common/request-images.d.ts +66 -0
  25. package/lib/types/host/common/request-images.d.ts.map +1 -1
  26. package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
  27. package/lib/types/host/kimi-code/mapper.d.ts +53 -3
  28. package/lib/types/host/kimi-code/mapper.d.ts.map +1 -1
  29. package/lib/types/host/kimi-code/routes.d.ts.map +1 -1
  30. package/lib/types/host/kimi-code/session-cache-state.d.ts +14 -0
  31. package/lib/types/host/kimi-code/session-cache-state.d.ts.map +1 -1
  32. package/lib/types/host/kimi-code/types.d.ts +29 -4
  33. package/lib/types/host/kimi-code/types.d.ts.map +1 -1
  34. package/lib/types/host/preferences.d.ts.map +1 -1
  35. package/lib/types/host/routes.d.ts.map +1 -1
  36. package/lib/types/host/subagent-model-authorization.d.ts +130 -2
  37. package/lib/types/host/subagent-model-authorization.d.ts.map +1 -1
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/lib/types/shared/contracts.d.ts +28 -0
  40. package/lib/types/shared/contracts.d.ts.map +1 -1
  41. package/lib/types/shared/kimi-code-contracts.d.ts +10 -1
  42. package/lib/types/shared/kimi-code-contracts.d.ts.map +1 -1
  43. package/lib/types/shared/preferences.d.ts +52 -1
  44. package/lib/types/shared/preferences.d.ts.map +1 -1
  45. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -145,7 +145,7 @@ const ROUTE_PREFIX$5 = "/api/dsh-chatgpt-subscription";
145
145
  * `test/responses-client.test.ts` asserts the two agree, so a release that
146
146
  * forgets this constant fails the suite instead of shipping quietly.
147
147
  */
148
- const PLUGIN_VERSION = "0.48.3";
148
+ const PLUGIN_VERSION = "0.49.0";
149
149
  const CODEX_CHATGPT_PROVIDER_ID = "codex-chatgpt";
150
150
  const CODEX_API_BASE = "https://chatgpt.com/backend-api/codex";
151
151
  const CODEX_RESPONSES_URL = `${CODEX_API_BASE}/responses`;
@@ -859,7 +859,7 @@ async function rehydrateSnapshot(key) {
859
859
  * so a backend that flattens the envelope does not read as an empty catalog.
860
860
  */
861
861
  function parseListing(payload) {
862
- const record = asRecord$19(payload);
862
+ const record = asRecord$20(payload);
863
863
  const models = Array.isArray(payload) ? payload : record?.models;
864
864
  if (!Array.isArray(models)) return [];
865
865
  return models.flatMap((item) => parseListingEntry$1(item));
@@ -873,7 +873,7 @@ function parseListing(payload) {
873
873
  * back to a conservative answer rather than to a guess.
874
874
  */
875
875
  function parseListingEntry$1(value) {
876
- const record = asRecord$19(value);
876
+ const record = asRecord$20(value);
877
877
  if (record === void 0) return [];
878
878
  const id = asString$27(record.slug) ?? asString$27(record.id);
879
879
  if (id === void 0) return [];
@@ -909,7 +909,7 @@ function parseEfforts(value) {
909
909
  if (!Array.isArray(value)) return null;
910
910
  const out = [];
911
911
  for (const item of value) {
912
- const effort = asString$27(item) ?? asString$27(asRecord$19(item)?.effort);
912
+ const effort = asString$27(item) ?? asString$27(asRecord$20(item)?.effort);
913
913
  if (effort !== void 0 && !out.includes(effort)) out.push(effort);
914
914
  }
915
915
  return out.length > 0 ? out : null;
@@ -924,7 +924,7 @@ function parseEfforts(value) {
924
924
  function accountKeyFor$1(credentials) {
925
925
  return credentials.accountId || "default";
926
926
  }
927
- function asRecord$19(value) {
927
+ function asRecord$20(value) {
928
928
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
929
929
  }
930
930
  function asString$27(value) {
@@ -5025,12 +5025,52 @@ function readBlocks(raw) {
5025
5025
  return raw.filter((block) => isRecord$58(block) && typeof block.type === "string");
5026
5026
  }
5027
5027
  /**
5028
+ * The first value that is a non-empty string.
5029
+ *
5030
+ * Used for fields the two generations spell in different places: a value that
5031
+ * is present but empty must not shadow a real one further down the chain, which
5032
+ * is what `??` alone does.
5033
+ */
5034
+ function firstNonEmptyString(...values) {
5035
+ for (const value of values) if (typeof value === "string" && value !== "") return value;
5036
+ return "";
5037
+ }
5038
+ /**
5039
+ * The result block a `role: 'tool'` message already carries inside its body.
5040
+ *
5041
+ * Some harness generations deliver the call id inside the result block rather
5042
+ * than on the message, and one shape carries both: `role: 'tool'` with a
5043
+ * `tool-result` block holding the id. Reading only the message-level fields
5044
+ * leaves {@link toolCallIdOf} nothing to find.
5045
+ */
5046
+ function embeddedToolResult(content) {
5047
+ if (content.length !== 1) return void 0;
5048
+ const [block] = content;
5049
+ return block?.type === "tool-result" ? block : void 0;
5050
+ }
5051
+ /**
5052
+ * The call id one `role: 'tool'` message associates with its `tool-call`.
5053
+ *
5054
+ * The id is the only thing tying a result to the call it answers: a mapper that
5055
+ * sends it empty makes every result an orphan and tells the repair pass that no
5056
+ * call was answered, which some gateways reject outright (WorkBuddy answers
5057
+ * `11148`, "tool calls and tool results do not match"). It therefore has to be
5058
+ * read from every place the harness generations put it — the message, the
5059
+ * message's source, and the result block inside the body — and an empty string
5060
+ * must not shadow a later, real one.
5061
+ */
5062
+ function toolCallIdOf(raw, source, embedded) {
5063
+ return firstNonEmptyString(raw.toolCallId, raw.callId, source?.callId, embedded?.toolCallId);
5064
+ }
5065
+ /**
5028
5066
  * Normalize one harness message list into {@link Message}.
5029
5067
  *
5030
5068
  * A 0.1.7 `role: 'tool'` message is re-wrapped as the user-role message holding
5031
5069
  * one {@link ToolResultBlock} that the mappers expect, keeping the tool
5032
5070
  * provenance (`kind: 'tool'`) they use to tell a real tool result from a user
5033
- * turn. Everything a newer harness adds beside that keeps its own shape.
5071
+ * turn. A message that already carries that block keeps its own body instead of
5072
+ * nesting a second `tool-result` around it. Everything a newer harness adds
5073
+ * beside that keeps its own shape.
5034
5074
  * @param messages - Messages straight off the request options, in either generation's shape.
5035
5075
  * @returns The same conversation in the pre-0.1.7 vocabulary.
5036
5076
  */
@@ -5041,15 +5081,17 @@ function normalizeMessages(messages) {
5041
5081
  const content = readBlocks(raw.content);
5042
5082
  if (raw.role === "tool") {
5043
5083
  const source = isRecord$58(raw.source) ? raw.source : void 0;
5044
- const callId = String(raw.toolCallId ?? source?.callId ?? "");
5084
+ const embedded = embeddedToolResult(content);
5085
+ const callId = toolCallIdOf(raw, source, embedded);
5086
+ const isError = raw.isError === true || embedded?.isError === true;
5045
5087
  normalized.push({
5046
5088
  id: raw.id,
5047
5089
  role: "user",
5048
5090
  content: [{
5049
5091
  type: "tool-result",
5050
5092
  toolCallId: callId,
5051
- content,
5052
- ...raw.isError === true ? { isError: true } : {}
5093
+ content: embedded === void 0 ? content : embedded.content,
5094
+ ...isError ? { isError: true } : {}
5053
5095
  }],
5054
5096
  source: {
5055
5097
  kind: "tool",
@@ -10770,7 +10812,9 @@ const DEFAULT_PREFERENCES = {
10770
10812
  contextWindowOverrides: {},
10771
10813
  proxyMode: "auto",
10772
10814
  customProxyUrl: null,
10773
- compactionRoute: null
10815
+ compactionRoute: null,
10816
+ subagentDefaultModels: [],
10817
+ subagentModelsByMainModel: {}
10774
10818
  };
10775
10819
  const SEARCH_PROVIDER_CODEX = "codex";
10776
10820
  function isSearchProviderPreference(value) {
@@ -10785,6 +10829,105 @@ function isCodexReasoningSummary(value) {
10785
10829
  function isProxyMode(value) {
10786
10830
  return value === "auto" || value === "custom" || value === "direct";
10787
10831
  }
10832
+ function asRecord$19(value) {
10833
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
10834
+ }
10835
+ /**
10836
+ * Read one non-empty trimmed string field.
10837
+ * @param value - Candidate field value from stored JSON.
10838
+ * @returns the trimmed string, or undefined when it is not a non-empty string.
10839
+ */
10840
+ function trimmedString(value) {
10841
+ if (typeof value !== "string") return void 0;
10842
+ const trimmed = value.trim();
10843
+ return trimmed.length === 0 ? void 0 : trimmed;
10844
+ }
10845
+ /**
10846
+ * Whether one value is a complete `{ provider, model }` route.
10847
+ *
10848
+ * Both halves are trimmed and must be non-empty: a route with a blank provider
10849
+ * names no adapter, and a blank model names no model, so neither can ever be
10850
+ * authorized. Stored documents are untrusted JSON, which is why this narrows
10851
+ * rather than asserts.
10852
+ * @param value - Candidate route.
10853
+ * @returns whether the value is a usable route.
10854
+ */
10855
+ function isSubagentModelRoute(value) {
10856
+ const record = asRecord$19(value);
10857
+ if (record === void 0) return false;
10858
+ return trimmedString(record["provider"]) !== void 0 && trimmedString(record["model"]) !== void 0;
10859
+ }
10860
+ /**
10861
+ * Normalize one route list, deduplicating it without reordering.
10862
+ *
10863
+ * Order is preserved because it is the order the settings card shows and the
10864
+ * order `list_subagent_models` prints: a set would silently reshuffle the list
10865
+ * a user arranged.
10866
+ * @param value - Candidate route list from stored JSON.
10867
+ * @returns the trimmed, deduplicated routes, or null when the value is not an
10868
+ * array of well-formed routes.
10869
+ */
10870
+ function normalizeSubagentModelRoutes(value) {
10871
+ if (!Array.isArray(value)) return null;
10872
+ const routes = [];
10873
+ const seen = /* @__PURE__ */ new Set();
10874
+ for (const entry of value) {
10875
+ if (!isSubagentModelRoute(entry)) return null;
10876
+ const provider = entry.provider.trim();
10877
+ const model = entry.model.trim();
10878
+ const key = `${provider}/${model}`;
10879
+ if (seen.has(key)) continue;
10880
+ seen.add(key);
10881
+ routes.push({
10882
+ provider,
10883
+ model
10884
+ });
10885
+ }
10886
+ return routes;
10887
+ }
10888
+ /**
10889
+ * Whether one key names a main agent route as `provider/model`.
10890
+ *
10891
+ * The split is at the FIRST slash, so a model id that legally contains a slash
10892
+ * (`command-code` + `deepseek/deepseek-v4.1-flash`) still round-trips: the
10893
+ * provider half ends at the first separator and the model half keeps the rest.
10894
+ * @param key - Candidate map key.
10895
+ * @returns whether the key carries a non-empty provider and a non-empty model.
10896
+ */
10897
+ function isValidMainModelKey(key) {
10898
+ if (typeof key !== "string") return false;
10899
+ const trimmed = key.trim();
10900
+ const separator = trimmed.indexOf("/");
10901
+ if (separator <= 0) return false;
10902
+ return trimmed.slice(separator + 1).length > 0;
10903
+ }
10904
+ /**
10905
+ * Merge one `subagentModelsByMainModel` patch into the stored map.
10906
+ *
10907
+ * A merge rather than a replace, so the card can clear or set one main model
10908
+ * without resending every other entry — and `null` (or `[]`) is how one entry
10909
+ * is cleared, since a stored map has no other way to say "no override".
10910
+ * @param current - Map already stored in the preferences document.
10911
+ * @param patch - Incoming patch from a settings card or a route body.
10912
+ * @returns the merged map, or null when any key or value is malformed.
10913
+ */
10914
+ function mergeSubagentModelsByMainModel(current, patch) {
10915
+ const patchRecord = asRecord$19(patch);
10916
+ if (patchRecord === void 0) return null;
10917
+ const merged = { ...current };
10918
+ for (const [rawKey, rawRoutes] of Object.entries(patchRecord)) {
10919
+ const key = rawKey.trim();
10920
+ if (!isValidMainModelKey(key)) return null;
10921
+ if (rawRoutes === null || Array.isArray(rawRoutes) && rawRoutes.length === 0) {
10922
+ delete merged[key];
10923
+ continue;
10924
+ }
10925
+ const routes = normalizeSubagentModelRoutes(rawRoutes);
10926
+ if (routes === null) return null;
10927
+ merged[key] = routes;
10928
+ }
10929
+ return merged;
10930
+ }
10788
10931
  //#endregion
10789
10932
  //#region src/host/common/legacy-preferences.ts
10790
10933
  /**
@@ -10951,6 +11094,10 @@ function sectionOf(document, id) {
10951
11094
  */
10952
11095
  function registerPreferenceStore(settings) {
10953
11096
  const contextWindowOverrides = z.object(Object.fromEntries(CODEX_MODEL_CATALOG.map((entry) => [entry.id, z.number().step(1).min(1).max(contextWindowLimitForModel(entry.id))]))).default({});
11097
+ const subagentModelRoutes = z.array(z.object({
11098
+ provider: z.string(),
11099
+ model: z.string()
11100
+ }));
10954
11101
  const schema = z.object({
10955
11102
  enabled: z.boolean().default(DEFAULT_PREFERENCES.enabled ?? true),
10956
11103
  quickQuotaVisible: z.boolean().default(DEFAULT_PREFERENCES.quickQuotaVisible),
@@ -10980,7 +11127,9 @@ function registerPreferenceStore(settings) {
10980
11127
  compactionRoute: z.union([z.object({
10981
11128
  provider: z.string(),
10982
11129
  model: z.string()
10983
- }), z.const(null)]).default(DEFAULT_PREFERENCES.compactionRoute ?? null)
11130
+ }), z.const(null)]).default(DEFAULT_PREFERENCES.compactionRoute ?? null),
11131
+ subagentDefaultModels: subagentModelRoutes.default([]),
11132
+ subagentModelsByMainModel: z.dict(subagentModelRoutes).default({})
10984
11133
  });
10985
11134
  if (!hasRegister(settings)) {
10986
11135
  const fileStore = new FilePreferencesStore(schema, preferencesPath(), readLegacyPreferences);
@@ -11038,6 +11187,16 @@ var SettingsPreferenceStore = class {
11038
11187
  model: patch.compactionRoute.model.trim()
11039
11188
  };
11040
11189
  }
11190
+ if (patch.subagentDefaultModels !== void 0) {
11191
+ const routes = normalizeSubagentModelRoutes(patch.subagentDefaultModels);
11192
+ if (routes === null) throw new PreferenceError("Unsupported subagentDefaultModels preference.");
11193
+ normalized.subagentDefaultModels = routes;
11194
+ }
11195
+ if (patch.subagentModelsByMainModel !== void 0) {
11196
+ const merged = mergeSubagentModelsByMainModel(this.scope.get().subagentModelsByMainModel, patch.subagentModelsByMainModel);
11197
+ if (merged === null) throw new PreferenceError("Unsupported subagentModelsByMainModel preference.");
11198
+ normalized.subagentModelsByMainModel = merged;
11199
+ }
11041
11200
  if (patch.customProxyUrl !== void 0) if (patch.customProxyUrl !== null) {
11042
11201
  const trimmed = patch.customProxyUrl.trim();
11043
11202
  if (trimmed.length > 0 && !/^https?:\/\//i.test(trimmed) && !/^socks5?:\/\//i.test(trimmed)) normalized.customProxyUrl = `http://${trimmed}`;
@@ -13964,6 +14123,15 @@ function readPreferencesUpdate(value, current) {
13964
14123
  model: value.compactionRoute.model.trim()
13965
14124
  };
13966
14125
  }
14126
+ if ("subagentDefaultModels" in value) {
14127
+ const routes = normalizeSubagentModelRoutes(value.subagentDefaultModels);
14128
+ if (routes === null) throw new PreferenceError("subagentDefaultModels must be an array of { provider, model } routes.");
14129
+ patch.subagentDefaultModels = routes;
14130
+ }
14131
+ if ("subagentModelsByMainModel" in value) {
14132
+ if (mergeSubagentModelsByMainModel(current.subagentModelsByMainModel, value.subagentModelsByMainModel) === null) throw new PreferenceError("subagentModelsByMainModel must map \"provider/model\" keys to arrays of { provider, model } routes, or null to clear one.");
14133
+ patch.subagentModelsByMainModel = value.subagentModelsByMainModel;
14134
+ }
13967
14135
  if ("contextWindowOverrides" in value) {
13968
14136
  if (!isRecord$55(value.contextWindowOverrides)) throw new PreferenceError("contextWindowOverrides must be an object.");
13969
14137
  const overrides = {};
@@ -26569,6 +26737,17 @@ async function projectToolResult(input) {
26569
26737
  }
26570
26738
  //#endregion
26571
26739
  //#region src/host/common/request-images.ts
26740
+ /**
26741
+ * Image resolution, byte budgeting and data-URL handling for one request.
26742
+ *
26743
+ * Shared by the provider lines that send inline images, because the budget is a
26744
+ * property of the deployment rather than of any one endpoint, and because two
26745
+ * lines that budget differently send different request bytes for the same picture.
26746
+ *
26747
+ * The notable decision is SCALE, not drop: see {@link resolveRequestImages}.
26748
+ *
26749
+ * @module dsh-chatgpt-subscription/request-images
26750
+ */
26572
26751
  function isRecord$50(value) {
26573
26752
  return typeof value === "object" && value !== null && !Array.isArray(value);
26574
26753
  }
@@ -26666,7 +26845,7 @@ function base64Length$1(bytes) {
26666
26845
  * the only estimate available, so it stays the fallback and every call site that
26667
26846
  * has no resolved map behaves exactly as before.
26668
26847
  */
26669
- function requestImagePayloadLength(block, images) {
26848
+ function inlinePayloadOf(block) {
26670
26849
  let data = asString$21(block.data) || asString$21(block.base64);
26671
26850
  const source = isRecord$50(block.source) ? block.source : void 0;
26672
26851
  if (!data && source) data = asString$21(source.data) || asString$21(source.base64);
@@ -26674,7 +26853,11 @@ function requestImagePayloadLength(block, images) {
26674
26853
  const matched = data.match(/^data:([^;,]+);base64,(.*)$/s);
26675
26854
  if (matched) data = matched[2] ?? "";
26676
26855
  }
26677
- if (data) return data.length;
26856
+ return data === void 0 || data === "" ? void 0 : data;
26857
+ }
26858
+ function requestImagePayloadLength(block, images) {
26859
+ const data = inlinePayloadOf(block);
26860
+ if (data !== void 0) return data.length;
26678
26861
  const attachment = attachmentOf$3(block);
26679
26862
  if (!attachment) return void 0;
26680
26863
  if (images === void 0) return base64Length$1(attachment.bytes);
@@ -26859,6 +27042,186 @@ function offloadOldestRequestImages$1(options, maxBytes = MAX_REQUEST_IMAGE_BYTE
26859
27042
  return replaceOldestRequestImages(options, imagesToOffloadCount(requestImagePayloadLengths(options, images), maxBytes), images);
26860
27043
  }
26861
27044
  /**
27045
+ * A stable identity for one image occurrence.
27046
+ *
27047
+ * Two occurrences of the SAME picture have to share one identity, and that
27048
+ * identity has to survive the turn: a durable attachment keeps its id, and an
27049
+ * inline payload keeps its bytes, so hashing the payload names the same
27050
+ * picture the same way on every later request.
27051
+ *
27052
+ * `undefined` for a block that carries no sendable payload: an image that
27053
+ * resolved to `unavailable` is serialized as a short text placeholder, so it
27054
+ * is not an image on the wire and has nothing to remember.
27055
+ */
27056
+ function requestImageKey(block, images) {
27057
+ if (requestImagePayloadLength(block, images) === void 0) return void 0;
27058
+ const attachment = attachmentOf$3(block);
27059
+ if (attachment !== void 0) return `attachment:${attachment.attachmentId}`;
27060
+ const data = inlinePayloadOf(block);
27061
+ if (data === void 0) return void 0;
27062
+ return `inline:${createHash("sha256").update(data).digest("hex")}`;
27063
+ }
27064
+ /**
27065
+ * Every sendable image occurrence one request carries, in request order.
27066
+ *
27067
+ * The traversal is the one `collectRequestImagePayloads` uses, tool results
27068
+ * included: a screenshot inside a tool result reaches the wire, so an
27069
+ * occurrence the budget cannot see is an occurrence it cannot budget for.
27070
+ */
27071
+ function collectImageOccurrences(options, images) {
27072
+ const occurrences = [];
27073
+ const walk = (content) => {
27074
+ if (!Array.isArray(content)) return;
27075
+ for (const block of content) {
27076
+ if (!isRecord$50(block)) continue;
27077
+ if (block.type === "image") {
27078
+ const bytes = requestImagePayloadLength(block, images);
27079
+ const key = requestImageKey(block, images);
27080
+ if (bytes !== void 0 && key !== void 0) occurrences.push({
27081
+ key,
27082
+ bytes
27083
+ });
27084
+ continue;
27085
+ }
27086
+ if (block.type === "tool-result") walk(block.content);
27087
+ }
27088
+ };
27089
+ for (const message of options.messages) walk(message.content);
27090
+ return occurrences;
27091
+ }
27092
+ /**
27093
+ * The identities of the images one request still carries, each once.
27094
+ *
27095
+ * The caller compares two of these to learn what a later build gave back: the
27096
+ * difference names the images a body-fit pass dropped after the budget pass
27097
+ * had already run.
27098
+ */
27099
+ function collectRemainingImageKeys(options, images) {
27100
+ const keys = [];
27101
+ const seen = /* @__PURE__ */ new Set();
27102
+ for (const occurrence of collectImageOccurrences(options, images)) {
27103
+ if (seen.has(occurrence.key)) continue;
27104
+ seen.add(occurrence.key);
27105
+ keys.push(occurrence.key);
27106
+ }
27107
+ return keys;
27108
+ }
27109
+ /**
27110
+ * Replace every occurrence of a dropped image with the placeholder.
27111
+ *
27112
+ * Unlike {@link replaceOldestRequestImages} this replaces by IDENTITY rather
27113
+ * than by position: a sticky budget drops a picture, and the picture has to
27114
+ * stay dropped for the rest of the session wherever it appears.
27115
+ */
27116
+ function replaceDroppedRequestImages(options, droppedKeys, images) {
27117
+ if (droppedKeys.size === 0) return {
27118
+ options,
27119
+ count: 0
27120
+ };
27121
+ let count = 0;
27122
+ const replaceIn = (content) => {
27123
+ if (!Array.isArray(content)) return {
27124
+ content,
27125
+ replaced: false
27126
+ };
27127
+ let replaced = false;
27128
+ return {
27129
+ content: content.map((block) => {
27130
+ if (!isRecord$50(block)) return block;
27131
+ if (block.type === "image") {
27132
+ const key = requestImageKey(block, images);
27133
+ if (key === void 0 || !droppedKeys.has(key)) return block;
27134
+ replaced = true;
27135
+ count += 1;
27136
+ return {
27137
+ type: "text",
27138
+ text: OMITTED_IMAGE_TEXT$1
27139
+ };
27140
+ }
27141
+ if (block.type === "tool-result" && Array.isArray(block.content)) {
27142
+ const inner = replaceIn(block.content);
27143
+ if (!inner.replaced) return block;
27144
+ replaced = true;
27145
+ return {
27146
+ ...block,
27147
+ content: inner.content
27148
+ };
27149
+ }
27150
+ return block;
27151
+ }),
27152
+ replaced
27153
+ };
27154
+ };
27155
+ let changed = false;
27156
+ const messages = options.messages.map((message) => {
27157
+ const result = replaceIn(message.content);
27158
+ if (!result.replaced) return message;
27159
+ changed = true;
27160
+ return {
27161
+ ...message,
27162
+ content: result.content
27163
+ };
27164
+ });
27165
+ return {
27166
+ options: changed ? {
27167
+ ...options,
27168
+ messages
27169
+ } : options,
27170
+ count
27171
+ };
27172
+ }
27173
+ /**
27174
+ * Offload images against a budget that RECOVERS instead of a budget that resets.
27175
+ *
27176
+ * WHY THE TWO WATERMARKS. A single ceiling makes the retained prefix depend
27177
+ * on the request: two conversations carrying the same images drop a different
27178
+ * number of them as one more image arrives, so the prefix the service cached
27179
+ * is rewritten on the next turn and the cache is cold again. Native `kimi-cli`
27180
+ * therefore keeps a HIGH mark (20 MiB) and a LOW mark (10 MiB): the request
27181
+ * is allowed to grow to the high mark, and once it passes it, the oldest
27182
+ * images are given back until the request is back under the LOW mark. The gap
27183
+ * between the two is the slack that keeps the number of retained images — and
27184
+ * therefore the cached prefix — stable across turns.
27185
+ *
27186
+ * WHY THE DROPS ARE STICKY. The images a request gives back are replaced for
27187
+ * the REST of the session: `priorDroppedKeys` carries the identities a
27188
+ * previous turn already dropped, and they are replaced again here. A picture
27189
+ * that was omitted stays omitted, so the request the service caches stops
27190
+ * changing once the session has settled under its low mark.
27191
+ *
27192
+ * @param options - the request to budget.
27193
+ * @param maxBytes - high-water mark; images are untouched below it.
27194
+ * @param lowBytes - low-water mark; the trimming stops once the request is back under it.
27195
+ * @param images - the resolution the request will be sent with.
27196
+ * @param priorDroppedKeys - identities dropped by earlier turns.
27197
+ */
27198
+ function offloadRequestImagesWithStickyBudget(options, maxBytes = MAX_REQUEST_IMAGE_BYTES$1, lowBytes = Math.max(1, Math.floor(maxBytes / 2)), images, priorDroppedKeys) {
27199
+ const allDroppedKeys = new Set(priorDroppedKeys ?? []);
27200
+ const pending = /* @__PURE__ */ new Map();
27201
+ for (const occurrence of collectImageOccurrences(options, images)) {
27202
+ if (allDroppedKeys.has(occurrence.key)) continue;
27203
+ pending.set(occurrence.key, (pending.get(occurrence.key) ?? 0) + occurrence.bytes);
27204
+ }
27205
+ let total = 0;
27206
+ for (const bytes of pending.values()) total += bytes;
27207
+ const newlyDroppedKeys = [];
27208
+ if (total > maxBytes) for (const [key, bytes] of pending) {
27209
+ if (total <= lowBytes) break;
27210
+ if (bytes === 0) continue;
27211
+ allDroppedKeys.add(key);
27212
+ newlyDroppedKeys.push(key);
27213
+ total -= bytes;
27214
+ }
27215
+ const replaced = replaceDroppedRequestImages(options, allDroppedKeys, images);
27216
+ return {
27217
+ options: replaced.options,
27218
+ remainingBytes: total,
27219
+ droppedCount: replaced.count,
27220
+ newlyDroppedKeys,
27221
+ allDroppedKeys
27222
+ };
27223
+ }
27224
+ /**
26862
27225
  * Whether a request still carries at least one image the mapper can send.
26863
27226
  *
26864
27227
  * A request whose images have all been replaced by placeholders can no longer
@@ -27420,11 +27783,26 @@ function effectiveContextWindow(tokenWindow, env = process.env) {
27420
27783
  /**
27421
27784
  * Base64 image payload one request on this route may carry.
27422
27785
  *
27423
- * Sized to leave room for the conversation inside the body ceiling above, which
27424
- * is why it is the smaller of the two. DSH_KIMI_CODE_MAX_IMAGE_BYTES overrides it
27425
- * under the same rules: ignored, never applied as a disabled guard.
27786
+ * Kimi's own CLI budgets request media at 20 MiB (REQUEST_MEDIA_BUDGET_BYTES),
27787
+ * and this route now speaks the same number. The previous 1.5 MB figure was
27788
+ * Kimi's TEXT-and-image body ceiling applied to the images alone, which threw
27789
+ * away pictures the service was perfectly willing to accept: a conversation
27790
+ * that carried three screenshots lost two of them to a limit that never
27791
+ * existed upstream.
27792
+ *
27793
+ * DSH_KIMI_CODE_MAX_IMAGE_BYTES overrides it under the same rules: ignored,
27794
+ * never applied as a disabled guard.
27426
27795
  */
27427
- const DEFAULT_MAX_REQUEST_IMAGE_BYTES$1 = MAX_REQUEST_IMAGE_BYTES$1;
27796
+ const DEFAULT_MAX_REQUEST_IMAGE_BYTES$1 = 20 * 1024 * 1024;
27797
+ /**
27798
+ * The mark the request is brought back under once it has passed the high one.
27799
+ *
27800
+ * Half the high mark, exactly as the official client does
27801
+ * (REQUEST_MEDIA_BUDGET_LOW_BYTES). The gap between the two is the slack that
27802
+ * keeps the number of retained images stable from turn to turn, and a stable
27803
+ * image count is what keeps the cached prefix valid.
27804
+ */
27805
+ const DEFAULT_LOW_REQUEST_IMAGE_BYTES = 10 * 1024 * 1024;
27428
27806
  /** Image budget this process enforces, honouring the deployment override. */
27429
27807
  function maxRequestImageBytes$1(env = process.env) {
27430
27808
  const raw = (env.DSH_KIMI_CODE_MAX_IMAGE_BYTES ?? "").trim();
@@ -27434,6 +27812,22 @@ function maxRequestImageBytes$1(env = process.env) {
27434
27812
  return Math.floor(parsed);
27435
27813
  }
27436
27814
  /**
27815
+ * The low-water mark this process enforces, honouring the deployment override.
27816
+ *
27817
+ * Half of whatever the deployment pinned, so an override moves both marks
27818
+ * together: a deployment that lowers the ceiling to 4 MB gets a 2 MB low mark
27819
+ * rather than the built-in 10 MB, which would let every request sit above the
27820
+ * mark it is supposed to recover to. A deployment that pins nothing gets
27821
+ * Kimi's own 10 MiB.
27822
+ */
27823
+ function lowRequestImageBytes(env = process.env) {
27824
+ const raw = (env.DSH_KIMI_CODE_MAX_IMAGE_BYTES ?? "").trim();
27825
+ if (raw === "") return DEFAULT_LOW_REQUEST_IMAGE_BYTES;
27826
+ const parsed = Number(raw);
27827
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LOW_REQUEST_IMAGE_BYTES;
27828
+ return Math.max(1, Math.floor(Math.floor(parsed) / 2));
27829
+ }
27830
+ /**
27437
27831
  * Which wire dialect a model id is served over.
27438
27832
  *
27439
27833
  * Always the OpenAI-compatible chat completions dialect: this line uses that one
@@ -31302,7 +31696,8 @@ function buildAnthropicRequest(options, images = NO_RESOLVED_IMAGES$3, media = {
31302
31696
  }
31303
31697
  /** Build the body for whichever endpoint serves `wire`. */
31304
31698
  function buildRequest(options, wire, images = NO_RESOLVED_IMAGES$3, preserveThinking = preserveThinkingEnabled(), media = {}) {
31305
- recordDriftCause(trackPrefixStability(options), options.sessionId);
31699
+ const cause = trackPrefixStability(options);
31700
+ recordDriftCause(cause === "stable" && (pendingImageOffloads.get(cacheScopeKey(options.sessionId)) ?? 0) > 0 ? "image-offload" : cause, options.sessionId);
31306
31701
  return wire === "anthropic" ? buildAnthropicRequest(options, images, media) : buildOpenAIRequest(options, images, preserveThinking, media);
31307
31702
  }
31308
31703
  /**
@@ -31906,6 +32301,94 @@ function recordDriftCause(cause, sessionId) {
31906
32301
  function getLastDriftCause(sessionId) {
31907
32302
  return driftCauses.get(cacheScopeKey(sessionId));
31908
32303
  }
32304
+ /**
32305
+ * Image identities this session has given back to the image budget.
32306
+ *
32307
+ * Scoped per session for the same reason the prefix snapshots are: DSH runs
32308
+ * several conversations — and subagents — at once, so one process-wide set
32309
+ * would let one conversation's omission replace another conversation's image.
32310
+ */
32311
+ const sessionDroppedImageKeys = /* @__PURE__ */ new Map();
32312
+ /**
32313
+ * What the most recent request of one session carried, in image bytes.
32314
+ */
32315
+ const sessionImageBudgetStats = /* @__PURE__ */ new Map();
32316
+ /**
32317
+ * Images the request CURRENTLY BEING BUILT has already dropped.
32318
+ *
32319
+ * Separate from {@link sessionDroppedImageKeys} because the two answer
32320
+ * different questions: the set is what the session remembers, and this counter
32321
+ * is what the request in flight did. The counter is what lets the drift
32322
+ * attribution name the offload, whichever order the build and the recording
32323
+ * happen in.
32324
+ */
32325
+ const pendingImageOffloads = /* @__PURE__ */ new Map();
32326
+ /**
32327
+ * The image identities this session has already dropped.
32328
+ *
32329
+ * `durableDropped` seeds the memory the first time a session is asked about,
32330
+ * so a session that ran before the restart keeps the omissions it made: the
32331
+ * budget is sticky, and a picture that was given back must stay given back.
32332
+ */
32333
+ function getSessionDroppedImageKeys(sessionId, durableDropped) {
32334
+ if (sessionId === void 0 || sessionId.trim() === "") return /* @__PURE__ */ new Set();
32335
+ const scope = cacheScopeKey(sessionId);
32336
+ const existing = sessionDroppedImageKeys.get(scope);
32337
+ if (existing !== void 0) return existing;
32338
+ const seeded = /* @__PURE__ */ new Set();
32339
+ for (const key of durableDropped ?? []) if (typeof key === "string" && key !== "") seeded.add(key);
32340
+ rememberScoped(sessionDroppedImageKeys, scope, seeded);
32341
+ return seeded;
32342
+ }
32343
+ /**
32344
+ * Note that the request being built has dropped images, before its body is built.
32345
+ *
32346
+ * The drift attribution reads this back when the prefix is fingerprinted, so
32347
+ * the order of the two does not matter: a request that drops an image is
32348
+ * attributed to the offload whether the body was built before or after the
32349
+ * drop was recorded.
32350
+ */
32351
+ function markPendingImageOffload(sessionId, newlyDroppedCount) {
32352
+ const scope = cacheScopeKey(sessionId);
32353
+ if (newlyDroppedCount <= 0) {
32354
+ pendingImageOffloads.delete(scope);
32355
+ return;
32356
+ }
32357
+ rememberScoped(pendingImageOffloads, scope, newlyDroppedCount);
32358
+ }
32359
+ /**
32360
+ * Record what one request's image budget did.
32361
+ *
32362
+ * The dropped identities are kept for the whole session: the budget is sticky,
32363
+ * so an image given back stays given back, and the next turn has to know
32364
+ * which ones those are.
32365
+ */
32366
+ function recordSessionImageBudget(sessionId, input) {
32367
+ const scope = cacheScopeKey(sessionId);
32368
+ const named = sessionId !== void 0 && sessionId.trim() !== "";
32369
+ const keys = named ? sessionDroppedImageKeys.get(scope) ?? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set();
32370
+ if (named) {
32371
+ for (const key of input.allDroppedKeys) if (typeof key === "string" && key !== "") keys.add(key);
32372
+ rememberScoped(sessionDroppedImageKeys, scope, keys);
32373
+ }
32374
+ rememberScoped(sessionImageBudgetStats, scope, {
32375
+ requestImageBytes: input.requestImageBytes,
32376
+ droppedImages: input.droppedImages,
32377
+ droppedKeys: named ? [...keys] : []
32378
+ });
32379
+ markPendingImageOffload(sessionId, input.newlyDroppedCount);
32380
+ if (input.newlyDroppedCount > 0 && getLastDriftCause(sessionId) === "stable") recordDriftCause("image-offload", sessionId);
32381
+ }
32382
+ /**
32383
+ * What one session's last request carried of images, when it is known.
32384
+ *
32385
+ * `undefined` for a session that has not sent a request, rather than a zeroed
32386
+ * answer: "no request yet" and "a request that carried no images" are
32387
+ * different facts.
32388
+ */
32389
+ function getSessionImageBudgetStats(sessionId) {
32390
+ return sessionImageBudgetStats.get(cacheScopeKey(sessionId));
32391
+ }
31909
32392
  /** Last lift per session. */
31910
32393
  const thinkingCoercions = /* @__PURE__ */ new Map();
31911
32394
  /**
@@ -32284,6 +32767,9 @@ function normalizeRecord(value) {
32284
32767
  if (lastActiveAt === void 0 || updatedAt === void 0) return void 0;
32285
32768
  const drift = typeof value.lastDrift === "string" && value.lastDrift !== "" ? value.lastDrift : void 0;
32286
32769
  const warmupStreak = count$1(value.warmupConsecutiveHits);
32770
+ const budgetDropped = droppedImageKeys(value.budgetDropped);
32771
+ const lastRequestImageBytes = nonNegative(value.lastRequestImageBytes);
32772
+ const lastDroppedImages = nonNegative(value.lastDroppedImages);
32287
32773
  return {
32288
32774
  lastActiveAt,
32289
32775
  updatedAt,
@@ -32296,10 +32782,46 @@ function normalizeRecord(value) {
32296
32782
  cacheWrite5mTokens: count$1(value.cacheWrite5mTokens),
32297
32783
  cacheWrite1hTokens: count$1(value.cacheWrite1hTokens),
32298
32784
  ...drift === void 0 ? {} : { lastDrift: drift },
32299
- ...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {}
32785
+ ...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {},
32786
+ ...budgetDropped.length > 0 ? { budgetDropped } : {},
32787
+ ...lastRequestImageBytes === void 0 ? {} : { lastRequestImageBytes },
32788
+ ...lastDroppedImages === void 0 ? {} : { lastDroppedImages }
32300
32789
  };
32301
32790
  }
32302
32791
  /**
32792
+ * How many dropped image identities one record keeps.
32793
+ *
32794
+ * A session that pastes hundreds of pictures would otherwise grow this
32795
+ * document without bound, and it is a diagnostic file: the identities that
32796
+ * matter are the ones a later turn still has to replace.
32797
+ */
32798
+ const MAX_BUDGET_DROPPED_KEYS = 512;
32799
+ /**
32800
+ * The dropped image identities a stored record carries.
32801
+ *
32802
+ * Only non-empty strings, deduplicated and capped: the file is written from
32803
+ * whatever the process happened to hold, and a malformed entry must not take
32804
+ * the record down with it.
32805
+ */
32806
+ function droppedImageKeys(value) {
32807
+ if (!Array.isArray(value)) return [];
32808
+ const keys = [];
32809
+ const seen = /* @__PURE__ */ new Set();
32810
+ for (const entry of value) {
32811
+ if (typeof entry !== "string" || entry === "") continue;
32812
+ if (seen.has(entry)) continue;
32813
+ seen.add(entry);
32814
+ keys.push(entry);
32815
+ if (keys.length >= MAX_BUDGET_DROPPED_KEYS) break;
32816
+ }
32817
+ return keys;
32818
+ }
32819
+ /** A stored byte or image count, or undefined when the field is absent or absurd. */
32820
+ function nonNegative(value) {
32821
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
32822
+ return Math.floor(value);
32823
+ }
32824
+ /**
32303
32825
  * Combine process memory with the durable record into the hint's two inputs.
32304
32826
  *
32305
32827
  * The process supplies the idle baseline whenever it has served the conversation,
@@ -33434,6 +33956,13 @@ function noteSessionCacheState(sessionId, state) {
33434
33956
  const lastDrift = getLastDriftCause(sessionId);
33435
33957
  reportDriftCause(sessionId, lastDrift);
33436
33958
  const now = Date.now();
33959
+ const imageBudget = getSessionImageBudgetStats(sessionId);
33960
+ const priorRecord = sessionCacheState.read(sessionId);
33961
+ const budgetDropped = imageBudget !== void 0 && imageBudget.droppedKeys.length > 0 ? imageBudget.droppedKeys : priorRecord?.budgetDropped;
33962
+ const liveBytes = imageBudget?.requestImageBytes ?? 0;
33963
+ const liveDropped = imageBudget?.droppedImages ?? 0;
33964
+ const lastRequestImageBytes = liveBytes > 0 ? liveBytes : priorRecord?.lastRequestImageBytes;
33965
+ const lastDroppedImages = liveDropped > 0 ? liveDropped : priorRecord?.lastDroppedImages;
33437
33966
  sessionCacheState.write(sessionId, {
33438
33967
  lastActiveAt: now,
33439
33968
  totalTokens: state.cacheReadTokens + state.inputTokens,
@@ -33445,6 +33974,9 @@ function noteSessionCacheState(sessionId, state) {
33445
33974
  cacheWrite5mTokens: stats.cacheWrite5mTokens,
33446
33975
  cacheWrite1hTokens: stats.cacheWrite1hTokens,
33447
33976
  ...lastDrift === void 0 ? {} : { lastDrift },
33977
+ ...budgetDropped === void 0 ? {} : { budgetDropped },
33978
+ ...lastRequestImageBytes === void 0 ? {} : { lastRequestImageBytes },
33979
+ ...lastDroppedImages === void 0 ? {} : { lastDroppedImages },
33448
33980
  updatedAt: now
33449
33981
  });
33450
33982
  const warmupState = recordCacheWarmupUsage({
@@ -33673,7 +34205,10 @@ var KimiCodeAdapter = class extends LlmAdapter {
33673
34205
  const entry = catalog.find((model) => model.id === options.model);
33674
34206
  const normalized = normalizeGenerateOptions(options);
33675
34207
  const images = await resolveRequestImages$1(normalized, this.options.attachments, signal, KIMI_CODE_REQUEST_IMAGE_MAX_EDGE);
33676
- const imageTrimmed = offloadOldestRequestImages$1(normalized, maxRequestImageBytes$1(), images);
34208
+ const priorDroppedKeys = getSessionDroppedImageKeys(options.sessionId, options.sessionId === void 0 ? void 0 : sessionCacheState.read(options.sessionId)?.budgetDropped);
34209
+ const stickyOffload = offloadRequestImagesWithStickyBudget(normalized, maxRequestImageBytes$1(), lowRequestImageBytes(), images, priorDroppedKeys);
34210
+ const imageTrimmed = stickyOffload.options;
34211
+ markPendingImageOffload(options.sessionId, stickyOffload.newlyDroppedKeys.length);
33677
34212
  const gatedEffort = coerceThinkingEffort(options.model, mapReasoningEffort(options.reasoningEffort === void 0 || options.reasoningEffort === null ? void 0 : String(options.reasoningEffort)));
33678
34213
  if (gatedEffort.coercedFrom !== null) recordThinkingCoercion({
33679
34214
  model: options.model,
@@ -33893,6 +34428,19 @@ var KimiCodeAdapter = class extends LlmAdapter {
33893
34428
  ...after === void 0 ? {} : { providerRetryAfterMs: after }
33894
34429
  });
33895
34430
  }
34431
+ if (measured !== void 0) {
34432
+ const requestImageBytes = requestImagePayloadLengths(measured.options, images).reduce((sum, bytes) => sum + bytes, 0);
34433
+ const trimmedKeys = new Set(collectRemainingImageKeys(imageTrimmed, images));
34434
+ const measuredKeys = new Set(collectRemainingImageKeys(measured.options, images));
34435
+ const bodyFitDroppedKeys = [...trimmedKeys].filter((key) => !measuredKeys.has(key));
34436
+ const allDroppedKeys = /* @__PURE__ */ new Set([...stickyOffload.allDroppedKeys, ...bodyFitDroppedKeys]);
34437
+ recordSessionImageBudget(options.sessionId, {
34438
+ allDroppedKeys,
34439
+ newlyDroppedCount: stickyOffload.newlyDroppedKeys.length + bodyFitDroppedKeys.length,
34440
+ requestImageBytes,
34441
+ droppedImages: stickyOffload.droppedCount + bodyFitDroppedKeys.length
34442
+ });
34443
+ }
33896
34444
  if (response.body === null) throw new LlmError("Kimi Code returned an empty response body", "PROVIDER_ERROR");
33897
34445
  const reader = response.body.getReader();
33898
34446
  const decoder = new TextDecoder();
@@ -34463,6 +35011,7 @@ function cacheStatsOrNull(sessionId) {
34463
35011
  ...stats,
34464
35012
  ...lastDrift === void 0 ? {} : { lastDrift },
34465
35013
  ...warmup === void 0 ? {} : { warmup },
35014
+ imageBudget: imageBudgetOf(sessionId),
34466
35015
  cacheWriteTokens: stats.cacheWriteTokens,
34467
35016
  cacheWrite5mTokens: stats.cacheWrite5mTokens,
34468
35017
  cacheWrite1hTokens: stats.cacheWrite1hTokens
@@ -34515,7 +35064,28 @@ function durableCacheStats(sessionId) {
34515
35064
  cacheWrite5mTokens: durable.cacheWrite5mTokens,
34516
35065
  cacheWrite1hTokens: durable.cacheWrite1hTokens,
34517
35066
  ...lastDrift === void 0 ? {} : { lastDrift },
34518
- ...warmup === void 0 ? {} : { warmup }
35067
+ ...warmup === void 0 ? {} : { warmup },
35068
+ imageBudget: imageBudgetOf(sessionId)
35069
+ };
35070
+ }
35071
+ /**
35072
+ * The image budget this route enforces and what the last turn of one session
35073
+ * spent of it.
35074
+ *
35075
+ * The live numbers win over the durable ones, because the durable record
35076
+ * describes the last run of this machine and the live numbers describe the
35077
+ * request that actually went out. The ceilings are read from the environment
35078
+ * every time, so a deployment that pinned a different budget sees its own
35079
+ * number rather than the built-in one.
35080
+ */
35081
+ function imageBudgetOf(sessionId) {
35082
+ const live = getSessionImageBudgetStats(sessionId);
35083
+ const durable = sessionCacheState.read(sessionId);
35084
+ return {
35085
+ budgetBytes: maxRequestImageBytes$1(),
35086
+ lowBudgetBytes: lowRequestImageBytes(),
35087
+ requestImageBytes: live?.requestImageBytes ?? durable?.lastRequestImageBytes ?? 0,
35088
+ droppedImages: live?.droppedImages ?? durable?.lastDroppedImages ?? 0
34519
35089
  };
34520
35090
  }
34521
35091
  /**
@@ -34532,7 +35102,8 @@ function driftCauseOf(value) {
34532
35102
  case "system-prompt":
34533
35103
  case "tools":
34534
35104
  case "cache-key":
34535
- case "cold-key": return value;
35105
+ case "cold-key":
35106
+ case "image-offload": return value;
34536
35107
  default: return;
34537
35108
  }
34538
35109
  }
@@ -80134,6 +80705,124 @@ function subagentModelSelectionPreference(settings) {
80134
80705
  }
80135
80706
  }
80136
80707
  /**
80708
+ * Main-model route per root Session, locked the first time it is observed.
80709
+ *
80710
+ * Without this, a mid-session model switch would silently re-point a Session's
80711
+ * child allowlist: the guard would start answering with a different main model's
80712
+ * routes, so the routes advertised in the prompt change, the prefix cache is
80713
+ * invalidated, and a model can be handed a list its own earlier turn contradicted.
80714
+ * Locking the FIRST resolved route keeps one Session's delegation policy stable
80715
+ * for its whole life, which is the same promise the durable policy event makes.
80716
+ *
80717
+ * Keyed weakly by the root Session object, so a closed Session's entry goes with
80718
+ * it. A Session object that is not an object (a minimal fixture) is simply not
80719
+ * locked.
80720
+ */
80721
+ const lockedRootMainRoutes = /* @__PURE__ */ new WeakMap();
80722
+ /**
80723
+ * Authorized child routes per root Session, locked the first time they are used.
80724
+ *
80725
+ * Locking the main ROUTE is not enough on its own: the route is only the key
80726
+ * into `byMainModel`, and the list it resolves to is read from the live
80727
+ * preference on every step. A Settings edit made at step 5 of a running session
80728
+ * would therefore swap the list under it — the prompt-assembly hook rewrites the
80729
+ * `subagent` tool description and the `tools:sdk` section from that list, so the
80730
+ * next request would carry a different prefix, drop the provider's prefix cache,
80731
+ * and contradict the Settings card's promise that a routing change applies to
80732
+ * new sessions only. The first resolved list is frozen here for the same reason
80733
+ * the durable policy event is a snapshot: one Session is governed by one policy.
80734
+ */
80735
+ const lockedRootAllowedRoutes = /* @__PURE__ */ new WeakMap();
80736
+ /**
80737
+ * Narrow one candidate route pair into an exact route.
80738
+ * @param provider - Candidate provider id.
80739
+ * @param model - Candidate model id.
80740
+ * @returns the route, or undefined unless both halves are non-empty strings.
80741
+ */
80742
+ function routeOf(provider, model) {
80743
+ const providerId = asString$3(provider);
80744
+ const modelId = asString$3(model);
80745
+ if (providerId === void 0 || modelId === void 0) return void 0;
80746
+ if (providerId.length === 0 || modelId.length === 0) return void 0;
80747
+ return {
80748
+ provider: providerId,
80749
+ model: modelId
80750
+ };
80751
+ }
80752
+ /**
80753
+ * Read the route a Session's next request uses, when its header names one.
80754
+ * @param session - Session whose request header is read.
80755
+ * @returns the exact route, or undefined when the header is absent or silent.
80756
+ */
80757
+ function headerRouteOf(session) {
80758
+ let header;
80759
+ try {
80760
+ header = session?.requestHeader?.();
80761
+ } catch {
80762
+ return;
80763
+ }
80764
+ return routeOf(header?.config?.provider, header?.config?.model);
80765
+ }
80766
+ /**
80767
+ * Resolve the top-level Session of one agent's lineage, together with the route
80768
+ * its main model runs on.
80769
+ *
80770
+ * The main model is a property of the ROOT Session: a child subagent's own route
80771
+ * is the child's, and reading it would authorize children by what a child chose
80772
+ * rather than by what the user's main agent is. Walking up through
80773
+ * `header.parentSession` finds that root, and the root's route is read from its
80774
+ * own header (or, for an agent that IS the root, from the live model accessor and
80775
+ * then the delegation hint, which is what an unstarted Session has).
80776
+ *
80777
+ * Once resolved for a Session that has already sent a request, the route is
80778
+ * locked in {@link lockedRootMainRoutes}, so the answer never drifts mid-session.
80779
+ * @param agent - Calling agent.
80780
+ * @param sessions - Session registry used for ancestor lookup.
80781
+ * @param modelSelectionHint - Route fields the caller already resolved.
80782
+ * @param lockSnapshot - Lock even a route that has no request header yet; the
80783
+ * prompt-assembly, post-execute and denial paths pass true, so a Session's
80784
+ * first turn fixes its policy for the rest of its life.
80785
+ * @returns the root Session (when one exists) and the main route (when known).
80786
+ */
80787
+ function resolveRootSessionAndMainRoute(agent, sessions, modelSelectionHint, lockSnapshot = false) {
80788
+ let rootSession = agent?.session;
80789
+ for (let depth = 0; depth < 8 && rootSession !== void 0; depth += 1) {
80790
+ const header = rootSession.header;
80791
+ if (header?.origin !== "subagent") break;
80792
+ const parentId = asString$3(header.parentSession);
80793
+ if (parentId === void 0) break;
80794
+ const parent = sessions.get(parentId);
80795
+ if (parent === void 0) break;
80796
+ rootSession = parent;
80797
+ }
80798
+ if (rootSession === void 0) return { mainRoute: routeOf(modelSelectionHint?.provider, modelSelectionHint?.model) };
80799
+ const lockable = typeof rootSession === "object" && rootSession !== null;
80800
+ const locked = lockable ? lockedRootMainRoutes.get(rootSession) : void 0;
80801
+ if (locked !== void 0) return {
80802
+ rootSession,
80803
+ mainRoute: locked
80804
+ };
80805
+ let mainRoute;
80806
+ if (agent?.session === rootSession || agent?.session === void 0) {
80807
+ let live;
80808
+ try {
80809
+ live = agent?.model?.current?.();
80810
+ } catch {
80811
+ live = void 0;
80812
+ }
80813
+ mainRoute = headerRouteOf(rootSession) ?? routeOf(live?.provider, live?.model) ?? routeOf(modelSelectionHint?.provider, modelSelectionHint?.model);
80814
+ } else {
80815
+ const rootOptions = rootSession.options;
80816
+ mainRoute = headerRouteOf(rootSession) ?? routeOf(rootOptions?.provider, rootOptions?.model);
80817
+ }
80818
+ if (mainRoute === void 0) return { rootSession };
80819
+ if (lockable && (lockSnapshot || headerRouteOf(rootSession) !== void 0)) lockedRootMainRoutes.set(rootSession, mainRoute);
80820
+ return {
80821
+ rootSession,
80822
+ mainRoute
80823
+ };
80824
+ }
80825
+ /**
80137
80826
  * Read the route an inherit-mode child would run on: the calling agent's own
80138
80827
  * effective request route. This mirrors the delegation seam's
80139
80828
  * `parentAgentOptionsForDelegation`, where the latest request header owns
@@ -80347,9 +81036,36 @@ function delegationModeOf(policies, toolName) {
80347
81036
  * @param scope - Whether an unrecorded Session falls back to the preference.
80348
81037
  * @returns the authorized routes in force for this agent, or an empty list when unrestricted.
80349
81038
  */
80350
- function effectiveAllowedRoutes(agent, preference, sessions, scope = "session") {
81039
+ function effectiveAllowedRoutes(agent, preference, sessions, scope = "session", lockSnapshot = false) {
80351
81040
  if (preference !== void 0 && !preference.enabled) return [];
80352
- return authorizedRoutesFor(agent, sessions) ?? (scope === "preference" ? preference?.allowedModels ?? [] : []);
81041
+ const baseRoutes = authorizedRoutesFor(agent, sessions) ?? (scope === "preference" ? preference?.allowedModels ?? [] : []);
81042
+ if (baseRoutes.length === 0) return [];
81043
+ return mainModelRoutesFor(agent, sessions, preference, baseRoutes, lockSnapshot);
81044
+ }
81045
+ /**
81046
+ * Apply the per-main-model override to a Session's authorized routes.
81047
+ *
81048
+ * Precedence is exact-match, then fallback, then the Session's own list: an
81049
+ * entry for the main model the Session actually runs is the most specific
81050
+ * statement available, the fallback list covers every other main model, and the
81051
+ * Session policy is what governs when the deployment configured neither.
81052
+ * @param agent - Calling agent.
81053
+ * @param sessions - Session registry used for ancestor lookup.
81054
+ * @param preference - Current Host preference, when the settings service exists.
81055
+ * @param baseRoutes - Routes the Session itself authorized; never empty here.
81056
+ * @param lockSnapshot - Freeze the resolved main route AND the resolved route
81057
+ * list for this Session now.
81058
+ * @returns the routes that actually govern this delegation.
81059
+ */
81060
+ function mainModelRoutesFor(agent, sessions, preference, baseRoutes, lockSnapshot = false) {
81061
+ const { rootSession, mainRoute } = resolveRootSessionAndMainRoute(agent, sessions, void 0, lockSnapshot);
81062
+ const lockable = typeof rootSession === "object" && rootSession !== null;
81063
+ const locked = lockable ? lockedRootAllowedRoutes.get(rootSession) : void 0;
81064
+ if (locked !== void 0) return locked;
81065
+ const overrideRoutes = mainRoute === void 0 ? void 0 : preference?.byMainModel?.[`${mainRoute.provider}/${mainRoute.model}`];
81066
+ const resolved = overrideRoutes !== void 0 && overrideRoutes.length > 0 ? overrideRoutes : preference?.defaultAllowedModels !== void 0 && preference.defaultAllowedModels.length > 0 ? preference.defaultAllowedModels : baseRoutes;
81067
+ if (lockable && (lockSnapshot || headerRouteOf(rootSession) !== void 0)) lockedRootAllowedRoutes.set(rootSession, [...resolved]);
81068
+ return resolved;
80353
81069
  }
80354
81070
  /**
80355
81071
  * State a Session's routing posture in the `list_subagent_models` result.
@@ -80379,8 +81095,96 @@ function enrichListSubagentModelsOutput(output, allowed) {
80379
81095
  }
80380
81096
  const header = "Authorized child routes for this Session (already filtered by the Session allowlist; only these routes are accepted, and `provider` and `model` must be passed as separate fields):";
80381
81097
  if (output.includes(header)) return output;
81098
+ const filtered = filterListSubagentModelsOutput(output, allowed);
80382
81099
  const suffix = `${header}\n${allowed.map((route) => `- ${route.provider}/${route.model} -> provider: "${route.provider}", model: "${route.model}"`).join("\n")}`;
80383
- return output.trim().length === 0 ? suffix : `${output}\n\n${suffix}`;
81100
+ return filtered.trim().length === 0 ? suffix : `${filtered}\n\n${suffix}`;
81101
+ }
81102
+ /** One rendered `provider — Name` line of the upstream provider list. */
81103
+ const PROVIDER_LINE = /^([^/\s]+)\s+—\s+.+$/;
81104
+ /** One rendered `provider/model — Name` line of the upstream model list. */
81105
+ const MODEL_LINE = /^([^/\s]+)\/([^\s]+)\s+—\s+.+$/;
81106
+ /**
81107
+ * Drop the upstream lines that name a route this Session does not authorize.
81108
+ *
81109
+ * The upstream discovery tool reads the union policy recorded on the Session, so
81110
+ * its raw answer can list a provider or a `provider/model` line that belongs to
81111
+ * another main model's entry and NOT to the routes this main model is governed
81112
+ * by. Leaving those lines in place is worse than a cosmetic mismatch: the
81113
+ * documented way to read this output is to copy a line into `subagent`, so every
81114
+ * stale line is an invitation to a delegation the guard will refuse.
81115
+ *
81116
+ * Only the two known renderings are filtered, and only lines that MATCH one of
81117
+ * them are candidates: any other line (a heading, a hint, an empty answer) is
81118
+ * passed through untouched, so a generation that changes its wording degrades to
81119
+ * the old behavior rather than to an empty answer.
81120
+ * @param output - Original text returned by `list_subagent_models`.
81121
+ * @param allowed - Routes the calling Session authorizes; never empty here.
81122
+ * @returns the output with unauthorized lines removed.
81123
+ */
81124
+ function filterListSubagentModelsOutput(output, allowed) {
81125
+ if (allowed.length === 0 || output.length === 0) return output;
81126
+ if (output.includes("\nReasoning efforts:\n")) return output;
81127
+ const lines = output.split("\n");
81128
+ const modelList = lines.some((line) => MODEL_LINE.test(line));
81129
+ let matched = false;
81130
+ const kept = [];
81131
+ for (const line of lines) {
81132
+ if (modelList) {
81133
+ const match = MODEL_LINE.exec(line);
81134
+ if (match === null) {
81135
+ kept.push(line);
81136
+ continue;
81137
+ }
81138
+ matched = true;
81139
+ if (routesInclude(allowed, match[1], match[2])) kept.push(line);
81140
+ continue;
81141
+ }
81142
+ const match = PROVIDER_LINE.exec(line);
81143
+ if (match === null) {
81144
+ kept.push(line);
81145
+ continue;
81146
+ }
81147
+ matched = true;
81148
+ if (allowed.some((route) => route.provider === match[1])) kept.push(line);
81149
+ }
81150
+ if (!matched) return output;
81151
+ const keptText = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
81152
+ if (keptText.length > 0) return keptText;
81153
+ if (modelList) return `(no advertised models for ${MODEL_LINE.exec(output)?.[1] ?? ""})`;
81154
+ return "(no LLM providers)";
81155
+ }
81156
+ /**
81157
+ * Merge live per-main-model overrides into a resolved preference.
81158
+ *
81159
+ * The overrides are a separate seam from the settings namespace because they
81160
+ * live in THIS plugin's own preference document, not in the upstream
81161
+ * `subagent-model-selection` namespace. They also have to work on a harness
81162
+ * where that namespace cannot be registered at all (0.1.7 dropped
81163
+ * `settings.register`): there, an override is the only per-main-model statement
81164
+ * available, so it stands up an enabled preference by itself rather than being
81165
+ * dropped for lack of a base to attach to.
81166
+ * @param base - Preference resolved from the settings namespace, when any.
81167
+ * @param overrides - Live overrides read from this plugin's preferences.
81168
+ * @returns the merged preference, or undefined when neither source says anything.
81169
+ */
81170
+ function mergePreferenceOverrides(base, overrides) {
81171
+ const hasDefault = overrides?.defaultAllowedModels !== void 0;
81172
+ const hasByMain = overrides?.byMainModel !== void 0;
81173
+ if (base === void 0) {
81174
+ if (!hasDefault && !hasByMain) return void 0;
81175
+ return {
81176
+ enabled: true,
81177
+ allowedModels: overrides?.defaultAllowedModels ?? [],
81178
+ ...hasDefault ? { defaultAllowedModels: overrides?.defaultAllowedModels } : {},
81179
+ ...hasByMain ? { byMainModel: overrides?.byMainModel } : {}
81180
+ };
81181
+ }
81182
+ if (!hasDefault && !hasByMain) return base;
81183
+ return {
81184
+ ...base,
81185
+ ...hasDefault ? { defaultAllowedModels: overrides?.defaultAllowedModels } : {},
81186
+ ...hasByMain ? { byMainModel: overrides?.byMainModel } : {}
81187
+ };
80384
81188
  }
80385
81189
  /**
80386
81190
  * The statement appended to `list_subagent_models` when no allowlist governs the
@@ -80491,7 +81295,7 @@ function rewriteAuthorizedPromptAssembly(assembly, allowed, toolNames = DEFAULT_
80491
81295
  * @param inheritToolNames - Delegation tools that start a child on the caller's own route.
80492
81296
  * @returns a denial reason, or undefined to leave the call untouched.
80493
81297
  */
80494
- function delegationDenialReason(agent, toolName, args, preference, sessions, toolNames = DEFAULT_DELEGATION_TOOLS, scope = "session", inheritToolNames = [], declaresRouteParameters) {
81298
+ function delegationDenialReason(agent, toolName, args, preference, sessions, toolNames = DEFAULT_DELEGATION_TOOLS, scope = "session", inheritToolNames = [], declaresRouteParameters, lockSnapshot = true) {
80495
81299
  const mode = delegationModeOf([...toolNames.map((name) => ({
80496
81300
  name,
80497
81301
  mode: "explicit"
@@ -80500,7 +81304,7 @@ function delegationDenialReason(agent, toolName, args, preference, sessions, too
80500
81304
  mode: "inherit"
80501
81305
  }))], toolName);
80502
81306
  if (mode === void 0) return void 0;
80503
- const allowed = effectiveAllowedRoutes(agent, preference, sessions, scope);
81307
+ const allowed = effectiveAllowedRoutes(agent, preference, sessions, scope, lockSnapshot);
80504
81308
  if (allowed.length === 0) return void 0;
80505
81309
  const request = asRecord$2(args) ?? {};
80506
81310
  const requestedProvider = asString$3(request["provider"]);
@@ -80528,7 +81332,26 @@ function delegationDenialReason(agent, toolName, args, preference, sessions, too
80528
81332
  * @returns a guard that denies delegations outside the Session allowlist.
80529
81333
  */
80530
81334
  function createSubagentAuthorization(options) {
80531
- return (agent, toolName, args) => delegationDenialReason(agent, toolName, args, subagentModelSelectionPreference(options.settings), options.sessions, options.toolNames, options.scope ?? "session", options.inheritToolNames ?? [], options.declaresRouteParameters);
81335
+ return (agent, toolName, args) => delegationDenialReason(agent, toolName, args, mergedPreference(options), options.sessions, options.toolNames, options.scope ?? "session", options.inheritToolNames ?? [], options.declaresRouteParameters);
81336
+ }
81337
+ /**
81338
+ * Resolve the preference one guard evaluation uses.
81339
+ *
81340
+ * The namespace preference and this plugin's per-main-model overrides are
81341
+ * separate seams — the override document outlives a harness that cannot register
81342
+ * the namespace at all — so they are merged here, once, rather than at each of
81343
+ * the four call sites that need the result.
81344
+ * @param options - Runtime inputs the guard closed over.
81345
+ * @returns the merged preference, or undefined when neither seam says anything.
81346
+ */
81347
+ function mergedPreference(options) {
81348
+ let overrides;
81349
+ try {
81350
+ overrides = options.routingOverrides?.();
81351
+ } catch {
81352
+ overrides = void 0;
81353
+ }
81354
+ return mergePreferenceOverrides(subagentModelSelectionPreference(options.settings), overrides);
80532
81355
  }
80533
81356
  /** Delegation names this guard recognizes when configuration omits them. */
80534
81357
  function normalizeDelegationToolNames(value) {
@@ -80620,14 +81443,19 @@ function installSubagentModelAuthorization(ctx, sessions, config = {}) {
80620
81443
  toolNames,
80621
81444
  inheritToolNames,
80622
81445
  scope,
80623
- declaresRouteParameters: routeParameterProbe(ctx)
81446
+ declaresRouteParameters: routeParameterProbe(ctx),
81447
+ routingOverrides: config.routingOverrides
81448
+ });
81449
+ const currentPreference = () => mergedPreference({
81450
+ settings,
81451
+ routingOverrides: config.routingOverrides
80624
81452
  });
80625
81453
  const disposeGuard = ctx.tools.guard((exec) => authorize(exec.agent, exec.name, exec.arguments));
80626
81454
  const eventHost = ctx;
80627
81455
  const disposePostExecute = eventHost.on?.("tools/post-execute", async (exec, result, next) => {
80628
81456
  const decision = await next();
80629
81457
  if (exec.name !== "list_subagent_models" || result.isError === true || decision?.kind !== "accept") return decision;
80630
- const allowed = effectiveAllowedRoutes(exec.agent, subagentModelSelectionPreference(settings), sessions, scope);
81458
+ const allowed = effectiveAllowedRoutes(exec.agent, currentPreference(), sessions, scope, true);
80631
81459
  const currentValue = decision.value !== void 0 ? decision.value : result.value;
80632
81460
  if (typeof currentValue === "string") {
80633
81461
  const enriched = enrichListSubagentModelsOutput(currentValue, allowed);
@@ -80655,7 +81483,7 @@ function installSubagentModelAuthorization(ctx, sessions, config = {}) {
80655
81483
  return decision;
80656
81484
  });
80657
81485
  const disposeAssemble = eventHost.on?.("system-prompt/assemble", async (_assembly, context, next) => {
80658
- return rewriteAuthorizedPromptAssembly(await next(), effectiveAllowedRoutes(context?.agent ?? context?.scope, subagentModelSelectionPreference(settings), sessions, scope), toolNames);
81486
+ return rewriteAuthorizedPromptAssembly(await next(), effectiveAllowedRoutes(context?.agent ?? context?.scope, currentPreference(), sessions, scope, true), toolNames);
80659
81487
  });
80660
81488
  return () => {
80661
81489
  disposeGuard();
@@ -93971,7 +94799,16 @@ function apply(ctx, pluginConfig = {}) {
93971
94799
  return installSubagentModelAuthorization(scoped, sessions, {
93972
94800
  toolNames: delegationToolNames,
93973
94801
  inheritToolNames: delegationInheritNames,
93974
- scope: pluginConfig.subagentModelScope ?? "session"
94802
+ scope: pluginConfig.subagentModelScope ?? "session",
94803
+ routingOverrides: () => {
94804
+ const current = preferences.status();
94805
+ const defaultAllowedModels = current.subagentDefaultModels ?? [];
94806
+ const byMainModel = current.subagentModelsByMainModel ?? {};
94807
+ return {
94808
+ ...defaultAllowedModels.length > 0 ? { defaultAllowedModels } : {},
94809
+ ...Object.keys(byMainModel).length > 0 ? { byMainModel } : {}
94810
+ };
94811
+ }
93975
94812
  });
93976
94813
  }, "dsh-chatgpt-subscription: subagent model authorization");
93977
94814
  });
@@ -94319,7 +95156,12 @@ function apply(ctx, pluginConfig = {}) {
94319
95156
  const sessions = ctx.get("sessions");
94320
95157
  const target = sessions.get(sessionId);
94321
95158
  if (target === void 0) throw new Error(`Unknown session "${sessionId}".`);
94322
- const allowed = auditedRoutesOf(target);
95159
+ const audited = auditedRoutesOf(target);
95160
+ const current = preferences.status();
95161
+ const mainRoute = resolveRootSessionAndMainRoute({ session: target }, sessions).mainRoute;
95162
+ const override = mainRoute === void 0 ? void 0 : (current.subagentModelsByMainModel ?? {})[`${mainRoute.provider}/${mainRoute.model}`];
95163
+ const fallback = current.subagentDefaultModels ?? [];
95164
+ const allowed = audited === void 0 ? audited : override !== void 0 && override.length > 0 ? override : fallback.length > 0 ? fallback : audited;
94323
95165
  return Promise.resolve({
94324
95166
  sessionId,
94325
95167
  allowedModels: (allowed ?? []).map((route) => ({