@eddyskywalker/dsh-chatgpt-subscription 0.48.4 → 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 (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/client.js +2381 -179
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +829 -29
  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/request-images.d.ts +66 -0
  23. package/lib/types/host/common/request-images.d.ts.map +1 -1
  24. package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
  25. package/lib/types/host/kimi-code/mapper.d.ts +53 -3
  26. package/lib/types/host/kimi-code/mapper.d.ts.map +1 -1
  27. package/lib/types/host/kimi-code/routes.d.ts.map +1 -1
  28. package/lib/types/host/kimi-code/session-cache-state.d.ts +14 -0
  29. package/lib/types/host/kimi-code/session-cache-state.d.ts.map +1 -1
  30. package/lib/types/host/kimi-code/types.d.ts +29 -4
  31. package/lib/types/host/kimi-code/types.d.ts.map +1 -1
  32. package/lib/types/host/preferences.d.ts.map +1 -1
  33. package/lib/types/host/routes.d.ts.map +1 -1
  34. package/lib/types/host/subagent-model-authorization.d.ts +130 -2
  35. package/lib/types/host/subagent-model-authorization.d.ts.map +1 -1
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/shared/contracts.d.ts +28 -0
  38. package/lib/types/shared/contracts.d.ts.map +1 -1
  39. package/lib/types/shared/kimi-code-contracts.d.ts +10 -1
  40. package/lib/types/shared/kimi-code-contracts.d.ts.map +1 -1
  41. package/lib/types/shared/preferences.d.ts +52 -1
  42. package/lib/types/shared/preferences.d.ts.map +1 -1
  43. 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.4";
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) {
@@ -10812,7 +10812,9 @@ const DEFAULT_PREFERENCES = {
10812
10812
  contextWindowOverrides: {},
10813
10813
  proxyMode: "auto",
10814
10814
  customProxyUrl: null,
10815
- compactionRoute: null
10815
+ compactionRoute: null,
10816
+ subagentDefaultModels: [],
10817
+ subagentModelsByMainModel: {}
10816
10818
  };
10817
10819
  const SEARCH_PROVIDER_CODEX = "codex";
10818
10820
  function isSearchProviderPreference(value) {
@@ -10827,6 +10829,105 @@ function isCodexReasoningSummary(value) {
10827
10829
  function isProxyMode(value) {
10828
10830
  return value === "auto" || value === "custom" || value === "direct";
10829
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
+ }
10830
10931
  //#endregion
10831
10932
  //#region src/host/common/legacy-preferences.ts
10832
10933
  /**
@@ -10993,6 +11094,10 @@ function sectionOf(document, id) {
10993
11094
  */
10994
11095
  function registerPreferenceStore(settings) {
10995
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
+ }));
10996
11101
  const schema = z.object({
10997
11102
  enabled: z.boolean().default(DEFAULT_PREFERENCES.enabled ?? true),
10998
11103
  quickQuotaVisible: z.boolean().default(DEFAULT_PREFERENCES.quickQuotaVisible),
@@ -11022,7 +11127,9 @@ function registerPreferenceStore(settings) {
11022
11127
  compactionRoute: z.union([z.object({
11023
11128
  provider: z.string(),
11024
11129
  model: z.string()
11025
- }), 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({})
11026
11133
  });
11027
11134
  if (!hasRegister(settings)) {
11028
11135
  const fileStore = new FilePreferencesStore(schema, preferencesPath(), readLegacyPreferences);
@@ -11080,6 +11187,16 @@ var SettingsPreferenceStore = class {
11080
11187
  model: patch.compactionRoute.model.trim()
11081
11188
  };
11082
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
+ }
11083
11200
  if (patch.customProxyUrl !== void 0) if (patch.customProxyUrl !== null) {
11084
11201
  const trimmed = patch.customProxyUrl.trim();
11085
11202
  if (trimmed.length > 0 && !/^https?:\/\//i.test(trimmed) && !/^socks5?:\/\//i.test(trimmed)) normalized.customProxyUrl = `http://${trimmed}`;
@@ -14006,6 +14123,15 @@ function readPreferencesUpdate(value, current) {
14006
14123
  model: value.compactionRoute.model.trim()
14007
14124
  };
14008
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
+ }
14009
14135
  if ("contextWindowOverrides" in value) {
14010
14136
  if (!isRecord$55(value.contextWindowOverrides)) throw new PreferenceError("contextWindowOverrides must be an object.");
14011
14137
  const overrides = {};
@@ -26611,6 +26737,17 @@ async function projectToolResult(input) {
26611
26737
  }
26612
26738
  //#endregion
26613
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
+ */
26614
26751
  function isRecord$50(value) {
26615
26752
  return typeof value === "object" && value !== null && !Array.isArray(value);
26616
26753
  }
@@ -26708,7 +26845,7 @@ function base64Length$1(bytes) {
26708
26845
  * the only estimate available, so it stays the fallback and every call site that
26709
26846
  * has no resolved map behaves exactly as before.
26710
26847
  */
26711
- function requestImagePayloadLength(block, images) {
26848
+ function inlinePayloadOf(block) {
26712
26849
  let data = asString$21(block.data) || asString$21(block.base64);
26713
26850
  const source = isRecord$50(block.source) ? block.source : void 0;
26714
26851
  if (!data && source) data = asString$21(source.data) || asString$21(source.base64);
@@ -26716,7 +26853,11 @@ function requestImagePayloadLength(block, images) {
26716
26853
  const matched = data.match(/^data:([^;,]+);base64,(.*)$/s);
26717
26854
  if (matched) data = matched[2] ?? "";
26718
26855
  }
26719
- 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;
26720
26861
  const attachment = attachmentOf$3(block);
26721
26862
  if (!attachment) return void 0;
26722
26863
  if (images === void 0) return base64Length$1(attachment.bytes);
@@ -26901,6 +27042,186 @@ function offloadOldestRequestImages$1(options, maxBytes = MAX_REQUEST_IMAGE_BYTE
26901
27042
  return replaceOldestRequestImages(options, imagesToOffloadCount(requestImagePayloadLengths(options, images), maxBytes), images);
26902
27043
  }
26903
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
+ /**
26904
27225
  * Whether a request still carries at least one image the mapper can send.
26905
27226
  *
26906
27227
  * A request whose images have all been replaced by placeholders can no longer
@@ -27462,11 +27783,26 @@ function effectiveContextWindow(tokenWindow, env = process.env) {
27462
27783
  /**
27463
27784
  * Base64 image payload one request on this route may carry.
27464
27785
  *
27465
- * Sized to leave room for the conversation inside the body ceiling above, which
27466
- * is why it is the smaller of the two. DSH_KIMI_CODE_MAX_IMAGE_BYTES overrides it
27467
- * 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.
27795
+ */
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.
27468
27804
  */
27469
- const DEFAULT_MAX_REQUEST_IMAGE_BYTES$1 = MAX_REQUEST_IMAGE_BYTES$1;
27805
+ const DEFAULT_LOW_REQUEST_IMAGE_BYTES = 10 * 1024 * 1024;
27470
27806
  /** Image budget this process enforces, honouring the deployment override. */
27471
27807
  function maxRequestImageBytes$1(env = process.env) {
27472
27808
  const raw = (env.DSH_KIMI_CODE_MAX_IMAGE_BYTES ?? "").trim();
@@ -27476,6 +27812,22 @@ function maxRequestImageBytes$1(env = process.env) {
27476
27812
  return Math.floor(parsed);
27477
27813
  }
27478
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
+ /**
27479
27831
  * Which wire dialect a model id is served over.
27480
27832
  *
27481
27833
  * Always the OpenAI-compatible chat completions dialect: this line uses that one
@@ -31344,7 +31696,8 @@ function buildAnthropicRequest(options, images = NO_RESOLVED_IMAGES$3, media = {
31344
31696
  }
31345
31697
  /** Build the body for whichever endpoint serves `wire`. */
31346
31698
  function buildRequest(options, wire, images = NO_RESOLVED_IMAGES$3, preserveThinking = preserveThinkingEnabled(), media = {}) {
31347
- 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);
31348
31701
  return wire === "anthropic" ? buildAnthropicRequest(options, images, media) : buildOpenAIRequest(options, images, preserveThinking, media);
31349
31702
  }
31350
31703
  /**
@@ -31948,6 +32301,94 @@ function recordDriftCause(cause, sessionId) {
31948
32301
  function getLastDriftCause(sessionId) {
31949
32302
  return driftCauses.get(cacheScopeKey(sessionId));
31950
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
+ }
31951
32392
  /** Last lift per session. */
31952
32393
  const thinkingCoercions = /* @__PURE__ */ new Map();
31953
32394
  /**
@@ -32326,6 +32767,9 @@ function normalizeRecord(value) {
32326
32767
  if (lastActiveAt === void 0 || updatedAt === void 0) return void 0;
32327
32768
  const drift = typeof value.lastDrift === "string" && value.lastDrift !== "" ? value.lastDrift : void 0;
32328
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);
32329
32773
  return {
32330
32774
  lastActiveAt,
32331
32775
  updatedAt,
@@ -32338,10 +32782,46 @@ function normalizeRecord(value) {
32338
32782
  cacheWrite5mTokens: count$1(value.cacheWrite5mTokens),
32339
32783
  cacheWrite1hTokens: count$1(value.cacheWrite1hTokens),
32340
32784
  ...drift === void 0 ? {} : { lastDrift: drift },
32341
- ...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {}
32785
+ ...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {},
32786
+ ...budgetDropped.length > 0 ? { budgetDropped } : {},
32787
+ ...lastRequestImageBytes === void 0 ? {} : { lastRequestImageBytes },
32788
+ ...lastDroppedImages === void 0 ? {} : { lastDroppedImages }
32342
32789
  };
32343
32790
  }
32344
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
+ /**
32345
32825
  * Combine process memory with the durable record into the hint's two inputs.
32346
32826
  *
32347
32827
  * The process supplies the idle baseline whenever it has served the conversation,
@@ -33476,6 +33956,13 @@ function noteSessionCacheState(sessionId, state) {
33476
33956
  const lastDrift = getLastDriftCause(sessionId);
33477
33957
  reportDriftCause(sessionId, lastDrift);
33478
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;
33479
33966
  sessionCacheState.write(sessionId, {
33480
33967
  lastActiveAt: now,
33481
33968
  totalTokens: state.cacheReadTokens + state.inputTokens,
@@ -33487,6 +33974,9 @@ function noteSessionCacheState(sessionId, state) {
33487
33974
  cacheWrite5mTokens: stats.cacheWrite5mTokens,
33488
33975
  cacheWrite1hTokens: stats.cacheWrite1hTokens,
33489
33976
  ...lastDrift === void 0 ? {} : { lastDrift },
33977
+ ...budgetDropped === void 0 ? {} : { budgetDropped },
33978
+ ...lastRequestImageBytes === void 0 ? {} : { lastRequestImageBytes },
33979
+ ...lastDroppedImages === void 0 ? {} : { lastDroppedImages },
33490
33980
  updatedAt: now
33491
33981
  });
33492
33982
  const warmupState = recordCacheWarmupUsage({
@@ -33715,7 +34205,10 @@ var KimiCodeAdapter = class extends LlmAdapter {
33715
34205
  const entry = catalog.find((model) => model.id === options.model);
33716
34206
  const normalized = normalizeGenerateOptions(options);
33717
34207
  const images = await resolveRequestImages$1(normalized, this.options.attachments, signal, KIMI_CODE_REQUEST_IMAGE_MAX_EDGE);
33718
- 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);
33719
34212
  const gatedEffort = coerceThinkingEffort(options.model, mapReasoningEffort(options.reasoningEffort === void 0 || options.reasoningEffort === null ? void 0 : String(options.reasoningEffort)));
33720
34213
  if (gatedEffort.coercedFrom !== null) recordThinkingCoercion({
33721
34214
  model: options.model,
@@ -33935,6 +34428,19 @@ var KimiCodeAdapter = class extends LlmAdapter {
33935
34428
  ...after === void 0 ? {} : { providerRetryAfterMs: after }
33936
34429
  });
33937
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
+ }
33938
34444
  if (response.body === null) throw new LlmError("Kimi Code returned an empty response body", "PROVIDER_ERROR");
33939
34445
  const reader = response.body.getReader();
33940
34446
  const decoder = new TextDecoder();
@@ -34505,6 +35011,7 @@ function cacheStatsOrNull(sessionId) {
34505
35011
  ...stats,
34506
35012
  ...lastDrift === void 0 ? {} : { lastDrift },
34507
35013
  ...warmup === void 0 ? {} : { warmup },
35014
+ imageBudget: imageBudgetOf(sessionId),
34508
35015
  cacheWriteTokens: stats.cacheWriteTokens,
34509
35016
  cacheWrite5mTokens: stats.cacheWrite5mTokens,
34510
35017
  cacheWrite1hTokens: stats.cacheWrite1hTokens
@@ -34557,7 +35064,28 @@ function durableCacheStats(sessionId) {
34557
35064
  cacheWrite5mTokens: durable.cacheWrite5mTokens,
34558
35065
  cacheWrite1hTokens: durable.cacheWrite1hTokens,
34559
35066
  ...lastDrift === void 0 ? {} : { lastDrift },
34560
- ...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
34561
35089
  };
34562
35090
  }
34563
35091
  /**
@@ -34574,7 +35102,8 @@ function driftCauseOf(value) {
34574
35102
  case "system-prompt":
34575
35103
  case "tools":
34576
35104
  case "cache-key":
34577
- case "cold-key": return value;
35105
+ case "cold-key":
35106
+ case "image-offload": return value;
34578
35107
  default: return;
34579
35108
  }
34580
35109
  }
@@ -80176,6 +80705,124 @@ function subagentModelSelectionPreference(settings) {
80176
80705
  }
80177
80706
  }
80178
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
+ /**
80179
80826
  * Read the route an inherit-mode child would run on: the calling agent's own
80180
80827
  * effective request route. This mirrors the delegation seam's
80181
80828
  * `parentAgentOptionsForDelegation`, where the latest request header owns
@@ -80389,9 +81036,36 @@ function delegationModeOf(policies, toolName) {
80389
81036
  * @param scope - Whether an unrecorded Session falls back to the preference.
80390
81037
  * @returns the authorized routes in force for this agent, or an empty list when unrestricted.
80391
81038
  */
80392
- function effectiveAllowedRoutes(agent, preference, sessions, scope = "session") {
81039
+ function effectiveAllowedRoutes(agent, preference, sessions, scope = "session", lockSnapshot = false) {
80393
81040
  if (preference !== void 0 && !preference.enabled) return [];
80394
- 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;
80395
81069
  }
80396
81070
  /**
80397
81071
  * State a Session's routing posture in the `list_subagent_models` result.
@@ -80421,8 +81095,96 @@ function enrichListSubagentModelsOutput(output, allowed) {
80421
81095
  }
80422
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):";
80423
81097
  if (output.includes(header)) return output;
81098
+ const filtered = filterListSubagentModelsOutput(output, allowed);
80424
81099
  const suffix = `${header}\n${allowed.map((route) => `- ${route.provider}/${route.model} -> provider: "${route.provider}", model: "${route.model}"`).join("\n")}`;
80425
- 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
+ };
80426
81188
  }
80427
81189
  /**
80428
81190
  * The statement appended to `list_subagent_models` when no allowlist governs the
@@ -80533,7 +81295,7 @@ function rewriteAuthorizedPromptAssembly(assembly, allowed, toolNames = DEFAULT_
80533
81295
  * @param inheritToolNames - Delegation tools that start a child on the caller's own route.
80534
81296
  * @returns a denial reason, or undefined to leave the call untouched.
80535
81297
  */
80536
- 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) {
80537
81299
  const mode = delegationModeOf([...toolNames.map((name) => ({
80538
81300
  name,
80539
81301
  mode: "explicit"
@@ -80542,7 +81304,7 @@ function delegationDenialReason(agent, toolName, args, preference, sessions, too
80542
81304
  mode: "inherit"
80543
81305
  }))], toolName);
80544
81306
  if (mode === void 0) return void 0;
80545
- const allowed = effectiveAllowedRoutes(agent, preference, sessions, scope);
81307
+ const allowed = effectiveAllowedRoutes(agent, preference, sessions, scope, lockSnapshot);
80546
81308
  if (allowed.length === 0) return void 0;
80547
81309
  const request = asRecord$2(args) ?? {};
80548
81310
  const requestedProvider = asString$3(request["provider"]);
@@ -80570,7 +81332,26 @@ function delegationDenialReason(agent, toolName, args, preference, sessions, too
80570
81332
  * @returns a guard that denies delegations outside the Session allowlist.
80571
81333
  */
80572
81334
  function createSubagentAuthorization(options) {
80573
- 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);
80574
81355
  }
80575
81356
  /** Delegation names this guard recognizes when configuration omits them. */
80576
81357
  function normalizeDelegationToolNames(value) {
@@ -80662,14 +81443,19 @@ function installSubagentModelAuthorization(ctx, sessions, config = {}) {
80662
81443
  toolNames,
80663
81444
  inheritToolNames,
80664
81445
  scope,
80665
- declaresRouteParameters: routeParameterProbe(ctx)
81446
+ declaresRouteParameters: routeParameterProbe(ctx),
81447
+ routingOverrides: config.routingOverrides
81448
+ });
81449
+ const currentPreference = () => mergedPreference({
81450
+ settings,
81451
+ routingOverrides: config.routingOverrides
80666
81452
  });
80667
81453
  const disposeGuard = ctx.tools.guard((exec) => authorize(exec.agent, exec.name, exec.arguments));
80668
81454
  const eventHost = ctx;
80669
81455
  const disposePostExecute = eventHost.on?.("tools/post-execute", async (exec, result, next) => {
80670
81456
  const decision = await next();
80671
81457
  if (exec.name !== "list_subagent_models" || result.isError === true || decision?.kind !== "accept") return decision;
80672
- const allowed = effectiveAllowedRoutes(exec.agent, subagentModelSelectionPreference(settings), sessions, scope);
81458
+ const allowed = effectiveAllowedRoutes(exec.agent, currentPreference(), sessions, scope, true);
80673
81459
  const currentValue = decision.value !== void 0 ? decision.value : result.value;
80674
81460
  if (typeof currentValue === "string") {
80675
81461
  const enriched = enrichListSubagentModelsOutput(currentValue, allowed);
@@ -80697,7 +81483,7 @@ function installSubagentModelAuthorization(ctx, sessions, config = {}) {
80697
81483
  return decision;
80698
81484
  });
80699
81485
  const disposeAssemble = eventHost.on?.("system-prompt/assemble", async (_assembly, context, next) => {
80700
- 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);
80701
81487
  });
80702
81488
  return () => {
80703
81489
  disposeGuard();
@@ -94013,7 +94799,16 @@ function apply(ctx, pluginConfig = {}) {
94013
94799
  return installSubagentModelAuthorization(scoped, sessions, {
94014
94800
  toolNames: delegationToolNames,
94015
94801
  inheritToolNames: delegationInheritNames,
94016
- 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
+ }
94017
94812
  });
94018
94813
  }, "dsh-chatgpt-subscription: subagent model authorization");
94019
94814
  });
@@ -94361,7 +95156,12 @@ function apply(ctx, pluginConfig = {}) {
94361
95156
  const sessions = ctx.get("sessions");
94362
95157
  const target = sessions.get(sessionId);
94363
95158
  if (target === void 0) throw new Error(`Unknown session "${sessionId}".`);
94364
- 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;
94365
95165
  return Promise.resolve({
94366
95166
  sessionId,
94367
95167
  allowedModels: (allowed ?? []).map((route) => ({