dsh-provider-qoder 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ Select the region matching your account, enter your Qoder PAT into the input box
53
53
 
54
54
  Fetching models uses the **saved PAT and service region**. If you switch accounts or regions, you need to fetch models again. The initial model catalog is only a candidate reference; actual available models are subject to account query results. Model multipliers, reasoning effort options, and image-input capability are provided by Qoder and may not be available for all models.
55
55
 
56
- The model picker refreshes Qoder metadata for enabled models when opened, with a five-minute cache after a successful fetch.
56
+ The model picker refreshes Qoder metadata for enabled models when opened, with a five-minute cache after a successful fetch. When settings are writable, refreshed multipliers and capabilities are also synchronized to the saved catalog shown in **Custom Settings**, without enabling additional models. Failed refreshes retain the last known metadata.
57
57
 
58
58
  ### 3. View Account & Quota
59
59
 
package/README_CN.md CHANGED
@@ -53,7 +53,7 @@ dsh plugin --profile web add dsh-provider-qoder
53
53
 
54
54
  获取模型使用的是**已保存的 PAT 和服务区域**。更换账号或区域后,需要重新获取模型。初始模型目录仅供候选参考,实际可用模型以账号查询结果为准。模型倍率、推理强度选项及图片输入能力由 Qoder 提供,并非所有模型都有这些信息。
55
55
 
56
- 展开模型选择器时会自动刷新已启用 Qoder 模型的元数据,成功获取后缓存 5 分钟。
56
+ 展开模型选择器时会自动刷新已启用 Qoder 模型的元数据,成功获取后缓存 5 分钟。设置可写时,更新后的倍率和能力信息也会同步到**自定义设置**显示的已保存目录,不会自动启用其他模型。刷新失败时保留最近一次成功获取的信息。
57
57
 
58
58
  ### 3. 查看账号与额度
59
59
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # Adds only the Qoder provider plugin. Model selection remains user-owned.
2
2
  - insert:
3
- - id: qoder-subscription
3
+ - id: provider-qoder
4
4
  name: 'dsh-provider-qoder'
5
5
 
6
6
  - id: web
package/lib/client.js CHANGED
@@ -1166,21 +1166,29 @@ window.__ModuleLoader__.load({
1166
1166
  "slots",
1167
1167
  "locale",
1168
1168
  "remote",
1169
- "remote.credentials",
1170
- "settingsScope"
1169
+ "remote.credentials"
1171
1170
  ];
1172
1171
  const fill = (text, values) => {
1173
1172
  if (!values) return text;
1174
1173
  return Object.entries(values).reduce((next, [key, value]) => next.replace(new RegExp(`\\{${key}\\}`, "g"), String(value)), text);
1175
1174
  };
1176
1175
  function apply(ctx) {
1176
+ ctx.inject(["settingsScope"], (child) => {
1177
+ const scopes = child.settingsScope;
1178
+ mount(child, scopes.bind({ namespace: settingsNamespace }));
1179
+ });
1180
+ ctx.inject(["configForms"], (child) => {
1181
+ const forms = child.configForms;
1182
+ mount(child, forms.get(settingsNamespace));
1183
+ });
1184
+ }
1185
+ function mount(ctx, modelScope) {
1177
1186
  ctx.effect(() => ctx.locale.register(localeNamespace, {
1178
1187
  zh,
1179
1188
  en
1180
1189
  }), "provider-qoder: credential copy");
1181
1190
  const credentials = ctx.remote.credentials;
1182
1191
  const rpc = createQoderRpcCaller();
1183
- const modelScope = ctx.settingsScope.bind({ namespace: settingsNamespace });
1184
1192
  const operations = {
1185
1193
  describe: async () => {
1186
1194
  try {
@@ -1210,27 +1218,24 @@ window.__ModuleLoader__.load({
1210
1218
  storeModels: async (region, models) => {
1211
1219
  try {
1212
1220
  const current = modelScope.getSnapshot().value;
1213
- await modelScope.set("modelsByRegion", {
1221
+ return await modelScope.set("modelsByRegion", {
1214
1222
  ...current?.modelsByRegion,
1215
1223
  [region]: models
1216
- });
1217
- return true;
1224
+ }) !== false;
1218
1225
  } catch {
1219
1226
  return false;
1220
1227
  }
1221
1228
  },
1222
1229
  storeRegion: async (region) => {
1223
1230
  try {
1224
- await modelScope.set("region", region);
1225
- return true;
1231
+ return await modelScope.set("region", region) !== false;
1226
1232
  } catch {
1227
1233
  return false;
1228
1234
  }
1229
1235
  },
1230
1236
  storeWebSearchMode: async (mode) => {
1231
1237
  try {
1232
- await modelScope.set("webSearchMode", mode);
1233
- return true;
1238
+ return await modelScope.set("webSearchMode", mode) !== false;
1234
1239
  } catch {
1235
1240
  return false;
1236
1241
  }
package/lib/index.d.ts CHANGED
@@ -51,11 +51,14 @@ interface Config {
51
51
  webSearchMode?: QoderWebSearchMode;
52
52
  }
53
53
  declare const Config: z<Config>;
54
+ type PluginConfig = Config;
55
+ declare const PluginConfig: z<NoInfer<Config>, NoInfer<Config>, "volatile">;
56
+ type LiveConfig = ReturnType<typeof PluginConfig>;
54
57
  //#endregion
55
58
  //#region src/dsh/plugin.d.ts
56
59
  declare const name = "provider-qoder";
57
60
  declare const inject: string[];
58
- declare function apply(ctx: Context, config?: Config): void;
61
+ declare function apply(ctx: Context, input?: Config | LiveConfig): void;
59
62
  //#endregion
60
63
  //#region src/qoder/account.d.ts
61
64
  /** Browser-safe Qoder subscriber account and quota types. */
@@ -225,4 +228,4 @@ declare class QoderSearchProvider implements WebSearchProvider {
225
228
  private resolveFallback;
226
229
  }
227
230
  //#endregion
228
- export { Config, QODER_SEARCH_PROVIDER_ID, type QoderAccountInfo, type QoderCatalogModel, type QoderModelsByRegion, type QoderRegion, QoderSearchClient, type QoderSearchClientOptions, QoderSearchProvider, type QoderSearchProviderOptions, type QoderWebSearchMode, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
231
+ export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, type QoderAccountInfo, type QoderCatalogModel, type QoderModelsByRegion, type QoderRegion, QoderSearchClient, type QoderSearchClientOptions, QoderSearchProvider, type QoderSearchProviderOptions, type QoderWebSearchMode, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { WebError } from "@deepseek-ai/dsh-web";
8
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
8
9
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
9
10
  //#region src/qoder/catalog.ts
10
11
  const discoveredMetadataKeys = [
@@ -261,7 +262,8 @@ function qoderHttpError(message, response) {
261
262
  return new QoderLlmError(message, code, {
262
263
  status,
263
264
  ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
264
- ...requestId === void 0 ? {} : { requestId }
265
+ ...requestId === void 0 ? {} : { requestId },
266
+ ...response.cause === void 0 ? {} : { cause: response.cause }
265
267
  });
266
268
  }
267
269
  function qoderRequestId(headers) {
@@ -383,7 +385,7 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
383
385
  6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
384
386
  XcW+ML9FoCI6AOvOzwIDAQAB
385
387
  -----END PUBLIC KEY-----`;
386
- const qoderIdeVersion = "1.1.47";
388
+ const qoderIdeVersion = "1.1.60";
387
389
  const defaultUserAgent = `qoder/${qoderIdeVersion}`;
388
390
  const qoderDataPolicy = "disagree";
389
391
  const qoderLoginVersion = "v2";
@@ -809,35 +811,6 @@ function toolResultText(block) {
809
811
  }
810
812
  return text;
811
813
  }
812
- /**
813
- * Check message shapes without performing any provider I/O.
814
- *
815
- * Callers run this before resolving credentials so an invalid request never
816
- * consumes a Qoder subscription.
817
- */
818
- function validateMessageShapes(messages) {
819
- for (const message of messages) {
820
- const toolResults = message.content.filter((block) => block.type === "tool-result");
821
- if (toolResults.length > 0) {
822
- if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
823
- for (const result of toolResults) toolResultText(result);
824
- continue;
825
- }
826
- for (const block of message.content) {
827
- if (block.type === "text") continue;
828
- if (block.type === "image") {
829
- if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
830
- continue;
831
- }
832
- if (block.type === "tool-call") {
833
- if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
834
- continue;
835
- }
836
- if (block.type === "reasoning") continue;
837
- throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
838
- }
839
- }
840
- }
841
814
  /** Reject a batch that exceeds the deployment image policy before any upload work starts. */
842
815
  function enforceImageLimits(images, attachments) {
843
816
  const limits = attachments.imageLimits;
@@ -852,10 +825,15 @@ async function resolveImagePart(block, context) {
852
825
  let image;
853
826
  try {
854
827
  const limits = attachments.imageLimits;
855
- image = await attachments.readImageRequest(block.attachment, {
856
- maxPixels: limits.maxImagePixels,
857
- maxBytes: limits.maxImageBytes
858
- }, signal);
828
+ const { width, height } = block.attachment;
829
+ const scale = Math.min(1, Math.sqrt(limits.maxImagePixels / (width * height)), limits.maxImageDimension / width, limits.maxImageDimension / height);
830
+ const target = {
831
+ width: Math.max(1, Math.floor(width * scale)),
832
+ height: Math.max(1, Math.floor(height * scale)),
833
+ maxBytes: limits.maxImageBytes,
834
+ maxPixels: limits.maxImagePixels
835
+ };
836
+ image = await attachments.readImageRequest(block.attachment, target, signal);
859
837
  } catch (error) {
860
838
  if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
861
839
  if (error instanceof QoderLlmError) throw error;
@@ -880,8 +858,29 @@ function translateTools(tools) {
880
858
  }
881
859
  }));
882
860
  }
861
+ /** Read a named property from an untyped object, returning it only if present. */
862
+ function prop(obj, key) {
863
+ return obj !== null && obj !== void 0 && typeof obj === "object" ? obj[key] : void 0;
864
+ }
865
+ /** Coerce a candidate value to a non-empty string tool call id, or `undefined`. */
866
+ function asCallId(value) {
867
+ if (typeof value !== "string") return void 0;
868
+ const trimmed = value.trim();
869
+ return trimmed.length > 0 ? trimmed : void 0;
870
+ }
871
+ function extractToolCallId(message, result) {
872
+ return asCallId(prop(result, "toolCallId")) ?? asCallId(prop(message, "toolCallId")) ?? asCallId(prop(message, "tool_call_id")) ?? asCallId(prop(message.source, "kind") === "tool" ? prop(message.source, "callId") : void 0);
873
+ }
874
+ function consumeToolCallId(message, result, pendingIds, fallbackIndex) {
875
+ const explicit = extractToolCallId(message, result);
876
+ if (explicit !== void 0) {
877
+ const idx = pendingIds.indexOf(explicit);
878
+ if (idx !== -1) pendingIds.splice(idx, 1);
879
+ return explicit;
880
+ }
881
+ return pendingIds.shift() ?? `call_unknown_${fallbackIndex}`;
882
+ }
883
883
  async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
884
- validateMessageShapes(messages);
885
884
  const context = {
886
885
  attachments,
887
886
  signal,
@@ -890,18 +889,20 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
890
889
  };
891
890
  const preserveThinking = pipeline?.preserveThinking ?? true;
892
891
  const output = [];
892
+ let lastAssistantToolCallIds = [];
893
893
  if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
894
894
  role: "system",
895
895
  content: systemPrompt
896
896
  });
897
897
  for (const message of messages) {
898
898
  const toolResults = message.content.filter((block) => block.type === "tool-result");
899
+ const nonToolBlocks = message.content.filter((block) => block.type !== "tool-result");
899
900
  if (toolResults.length > 0) {
900
- if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
901
901
  for (const result of toolResults) {
902
+ const callId = consumeToolCallId(message, result, lastAssistantToolCallIds, output.length);
902
903
  output.push({
903
904
  role: "tool",
904
- tool_call_id: String(result.toolCallId),
905
+ tool_call_id: callId,
905
906
  content: toolResultText(result)
906
907
  });
907
908
  const images = result.content.filter((block) => block.type === "image");
@@ -916,24 +917,27 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
916
917
  });
917
918
  }
918
919
  }
919
- continue;
920
+ if (nonToolBlocks.length === 0) continue;
920
921
  }
922
+ const contentBlocks = toolResults.length > 0 ? nonToolBlocks : message.content;
921
923
  let text = "";
922
924
  let reasoningText = "";
923
925
  const userContent = [];
924
926
  const pendingImages = [];
925
927
  let hasImage = false;
926
928
  const toolCalls = [];
927
- for (const block of message.content) {
929
+ for (const block of contentBlocks) {
930
+ if (message.role === "tool" && block.type !== "text" && block.type !== "image") throw unsupported(`Qoder tool results support text and images only; received ${block.type} content.`);
928
931
  if (block.type === "text") {
929
932
  text += block.text;
930
- if (message.role === "user") userContent.push({
933
+ if (message.role === "user" || toolResults.length > 0) userContent.push({
931
934
  type: "text",
932
935
  text: block.text
933
936
  });
934
937
  continue;
935
938
  }
936
939
  if (block.type === "image") {
940
+ if (message.role !== "user" && message.role !== "tool" && toolResults.length === 0) throw unsupported("Qoder image content is valid only in user messages.");
937
941
  hasImage = true;
938
942
  pendingImages.push({
939
943
  slot: userContent.length,
@@ -961,6 +965,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
961
965
  if (message.role === "assistant") {
962
966
  const hasReasoning = preserveThinking && reasoningText.length > 0;
963
967
  if (!text && toolCalls.length === 0 && !hasReasoning) continue;
968
+ if (toolCalls.length > 0) lastAssistantToolCallIds = toolCalls.map((tc) => tc.id);
964
969
  output.push({
965
970
  role: "assistant",
966
971
  content: text || " ",
@@ -969,20 +974,317 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
969
974
  });
970
975
  continue;
971
976
  }
977
+ if (message.role === "tool") {
978
+ const callId = consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length);
979
+ output.push({
980
+ role: "tool",
981
+ tool_call_id: callId,
982
+ content: text
983
+ });
984
+ if (pendingImages.length > 0) {
985
+ if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
986
+ output.push({
987
+ role: "user",
988
+ content: [{
989
+ type: "text",
990
+ text: `[${pendingImages.length} image${pendingImages.length === 1 ? "" : "s"} returned by the previous tool call]`
991
+ }, ...await Promise.all(pendingImages.map((pending) => resolveImagePart(pending.block, context)))]
992
+ });
993
+ }
994
+ continue;
995
+ }
972
996
  if (pendingImages.length > 0) {
973
997
  if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
974
998
  await Promise.all(pendingImages.map(async (pending) => {
975
999
  userContent[pending.slot] = await resolveImagePart(pending.block, context);
976
1000
  }));
977
1001
  }
1002
+ if (toolResults.length > 0 && !hasImage && text.trim().length === 0) continue;
1003
+ const wireRole = toolResults.length > 0 ? "user" : message.role;
1004
+ const wireToolCallId = wireRole === "tool" ? consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length) : void 0;
978
1005
  output.push({
979
- role: message.role,
980
- content: hasImage ? userContent.filter((part) => part !== void 0) : text
1006
+ role: wireRole,
1007
+ content: hasImage ? userContent.filter((part) => part !== void 0) : text,
1008
+ ...wireToolCallId ? { tool_call_id: wireToolCallId } : {}
981
1009
  });
982
1010
  }
983
1011
  return output;
984
1012
  }
985
1013
  //#endregion
1014
+ //#region src/qoder/transport/wire/turn-identity.ts
1015
+ /**
1016
+ * Per-turn Qoder request identity.
1017
+ *
1018
+ * A DSH turn issues one model request per reasoning step, so a single
1019
+ * subscriber prompt becomes dozens of consecutive Qoder requests. Qoder's
1020
+ * Credits panel aggregates consumption per agent run — the `business.id` a
1021
+ * request reports — so every step of one turn must carry the same run identity
1022
+ * while the next subscriber prompt opens a new one.
1023
+ *
1024
+ * The adapter never sees a turn number, and the host appends messages to a
1025
+ * turn's history while it runs — its own context, notifications, delegated
1026
+ * agents' messages — so the boundary is derived from the history here. A turn is
1027
+ * the multiset of user-role messages it has accounted for: a subscriber message
1028
+ * the run has not seen opens the next turn, while host-appended context,
1029
+ * transport-synthesized markers, and anything an auxiliary call carries continue
1030
+ * the open record.
1031
+ *
1032
+ * Known limit: text identifies a message because the host's message ids are not
1033
+ * part of the wire shape. A subscriber who sends the same words twice with the
1034
+ * first copy already gone from history — compaction replaced that span — shares
1035
+ * the open record instead of opening a new one. Carrying the host's message
1036
+ * identity through translation is the fix, and needs the host to expose it on
1037
+ * the request the adapter receives.
1038
+ *
1039
+ * @module dsh-provider-qoder/qoder/transport/wire/turn-identity
1040
+ */
1041
+ const defaultMaxSessions = 64;
1042
+ /** A conversation untouched for this long cannot still be mid-turn. */
1043
+ const defaultIdleSessionTtlMs = 3600 * 1e3;
1044
+ /**
1045
+ * Openings of everything the host appends while a turn runs.
1046
+ *
1047
+ * One subscriber prompt is what the subscriber perceives as one turn, so every
1048
+ * message the harness adds around that prompt — its own context, notifications,
1049
+ * and delegated agents' messages — must stay inside the same record. Only a
1050
+ * message outside this list is treated as a new prompt. The list is
1051
+ * evidence-based: every entry was observed in live DSH transcripts, and an
1052
+ * offline audit replays real sessions to keep it aligned. An unknown future
1053
+ * wrapper therefore opens one extra record instead of merging two turns.
1054
+ */
1055
+ const injectedContextOpenings = [
1056
+ "<system-reminder>",
1057
+ "<openviking-context",
1058
+ "<goal_complete>",
1059
+ "Current runtime context. This snapshot supersedes earlier runtime-context",
1060
+ "This is an automatically generated checkpoint condensing",
1061
+ "[model changed:",
1062
+ "background job ",
1063
+ "The approval policy changed"
1064
+ ];
1065
+ /**
1066
+ * Host messages that name their sender.
1067
+ *
1068
+ * A delegated agent's message and a settled background subagent both start with
1069
+ * a word a subscriber may legitimately use, so they are matched on the host's
1070
+ * own template — the sender's identity then a fixed phrase — instead of on a
1071
+ * bare prefix that would swallow real prompts.
1072
+ */
1073
+ const hostNotificationTemplate = /^(?:Agent|Background subagent) \S{3,} (?:sent a message:|finished\b)/;
1074
+ /**
1075
+ * Text this transport synthesizes while translating a turn's own tool results.
1076
+ *
1077
+ * A tool result carrying images becomes a user-role message, so it must be
1078
+ * recognized as part of the open record rather than as new subscriber input.
1079
+ */
1080
+ const syntheticMarkerTemplate = /^\[\d+ images? returned by the previous tool call\]/;
1081
+ /** Wire text of one user message, used for injection classification and text extraction. */
1082
+ function wireMessageText(message) {
1083
+ const content = message.content;
1084
+ if (typeof content === "string") return content;
1085
+ if (content === null) return "";
1086
+ return content.map((part) => part.type === "text" ? part.text : "").join("");
1087
+ }
1088
+ /** Classify one user-role wire message. */
1089
+ function qoderMessageOrigin(message) {
1090
+ const text = wireMessageText(message).trimStart();
1091
+ if (syntheticMarkerTemplate.test(text)) return "synthetic";
1092
+ if (hostNotificationTemplate.test(text)) return "injected";
1093
+ return injectedContextOpenings.some((opening) => text.startsWith(opening)) ? "injected" : "prompt";
1094
+ }
1095
+ /** Stable record id of one session turn. */
1096
+ function turnIdFor(sessionId, turnCount, anchor) {
1097
+ return crypto.createHash("sha256").update("qoder-turn").update("\0").update(sessionId).update("\0").update(String(turnCount)).update("\0").update(anchor).digest("hex").slice(0, 16);
1098
+ }
1099
+ /**
1100
+ * Fingerprint of one message, used to count how often a turn has seen it.
1101
+ *
1102
+ * Text identifies a message, never an image URL: a published URL is re-signed as
1103
+ * its cache entry expires, so a rotating URL would make the turn's own prompt
1104
+ * look like new input and split the record. Image count still separates a
1105
+ * message that carries images from one that does not.
1106
+ */
1107
+ function messageFingerprint(message) {
1108
+ const hash = crypto.createHash("sha256");
1109
+ hash.update(message.role);
1110
+ hash.update("\0");
1111
+ const content = message.content;
1112
+ if (typeof content === "string") hash.update(content);
1113
+ else if (content !== null) {
1114
+ let images = 0;
1115
+ for (const part of content) if (part.type === "text") {
1116
+ hash.update("\0text\0");
1117
+ hash.update(part.text);
1118
+ } else images += 1;
1119
+ hash.update(`\0images=${images}`);
1120
+ }
1121
+ return hash.digest("hex").slice(0, 16);
1122
+ }
1123
+ /**
1124
+ * Resolves the record identity of the turn one request belongs to.
1125
+ *
1126
+ * One tracker instance owns its sessions, so tests and transports stay
1127
+ * independent. State is created lazily per session and bounded by
1128
+ * `maxSessions`.
1129
+ */
1130
+ var QoderTurnTracker = class {
1131
+ maxSessions;
1132
+ idleSessionTtlMs;
1133
+ now;
1134
+ sessions = /* @__PURE__ */ new Map();
1135
+ constructor(options = {}) {
1136
+ this.maxSessions = options.maxSessions ?? defaultMaxSessions;
1137
+ this.idleSessionTtlMs = options.idleSessionTtlMs ?? defaultIdleSessionTtlMs;
1138
+ this.now = options.now ?? Date.now;
1139
+ }
1140
+ /**
1141
+ * Resolves the full wire identity for one Qoder request.
1142
+ *
1143
+ * Coordinates `request_set_id`, `chat_record_id`, and `business` metadata so
1144
+ * that requests within one subscriber turn share a single agent run and
1145
+ * consumption record.
1146
+ */
1147
+ resolveTurnIdentity(sessionId, messages, kind = "conversation", requestId = crypto.randomUUID(), modelKey) {
1148
+ const now = this.now();
1149
+ if (sessionId === void 0 || sessionId.length === 0) return {
1150
+ requestSetId: `qoder-request-${crypto.randomUUID()}`,
1151
+ chatRecordId: requestId,
1152
+ businessId: crypto.randomUUID(),
1153
+ beginAt: now
1154
+ };
1155
+ if (kind === "auxiliary") {
1156
+ if (this.sessions.has(sessionId)) this.sessions.get(sessionId).lastTouchedAt = now;
1157
+ return {
1158
+ requestSetId: `qoder-request-${crypto.randomUUID()}`,
1159
+ chatRecordId: requestId,
1160
+ businessId: crypto.randomUUID(),
1161
+ beginAt: now
1162
+ };
1163
+ }
1164
+ let state = this.sessions.get(sessionId);
1165
+ if (state === void 0) {
1166
+ state = {
1167
+ claimed: /* @__PURE__ */ new Map(),
1168
+ currentTurnId: "",
1169
+ currentBusinessId: "",
1170
+ turnOpenedAt: 0,
1171
+ lastTouchedAt: now,
1172
+ turnCount: 0,
1173
+ currentModel: modelKey
1174
+ };
1175
+ this.claimSession(sessionId, state);
1176
+ } else {
1177
+ state.lastTouchedAt = now;
1178
+ this.sessions.delete(sessionId);
1179
+ this.sessions.set(sessionId, state);
1180
+ }
1181
+ const modelChanged = state.currentModel !== void 0 && modelKey !== void 0 && state.currentModel !== modelKey;
1182
+ if (modelKey !== void 0) state.currentModel = modelKey;
1183
+ const present = /* @__PURE__ */ new Map();
1184
+ for (const message of messages) {
1185
+ if (message.role !== "user") continue;
1186
+ const fingerprint = messageFingerprint(message);
1187
+ const entry = present.get(fingerprint);
1188
+ if (entry === void 0) present.set(fingerprint, {
1189
+ count: 1,
1190
+ host: qoderMessageOrigin(message)
1191
+ });
1192
+ else entry.count += 1;
1193
+ }
1194
+ let opening;
1195
+ let unclaimed = 0;
1196
+ for (const [fingerprint, entry] of present) {
1197
+ const claimed = state.claimed.get(fingerprint) ?? 0;
1198
+ if (entry.count <= claimed) continue;
1199
+ state.claimed.set(fingerprint, entry.count);
1200
+ if (entry.host === "injected" || entry.host === "synthetic") continue;
1201
+ unclaimed += entry.count - claimed;
1202
+ opening = fingerprint;
1203
+ }
1204
+ for (const [fingerprint, claimed] of state.claimed) {
1205
+ const current = present.get(fingerprint);
1206
+ if (current === void 0) state.claimed.delete(fingerprint);
1207
+ else if (current.count < claimed) state.claimed.set(fingerprint, current.count);
1208
+ }
1209
+ if (opening !== void 0 && unclaimed > 0 || modelChanged) {
1210
+ state.turnCount += 1;
1211
+ state.currentTurnId = turnIdFor(sessionId, state.turnCount, opening ?? state.currentModel ?? "");
1212
+ state.currentBusinessId = crypto.randomUUID();
1213
+ state.turnOpenedAt = now;
1214
+ } else if (state.currentTurnId === "") {
1215
+ state.turnCount += 1;
1216
+ state.currentTurnId = turnIdFor(sessionId, state.turnCount, state.currentModel ?? "");
1217
+ state.currentBusinessId = crypto.randomUUID();
1218
+ state.turnOpenedAt = now;
1219
+ }
1220
+ return {
1221
+ requestSetId: state.currentTurnId,
1222
+ chatRecordId: requestId,
1223
+ businessId: state.currentBusinessId,
1224
+ beginAt: state.turnOpenedAt
1225
+ };
1226
+ }
1227
+ /**
1228
+ * Record id shared by every step of `sessionId`'s current turn.
1229
+ *
1230
+ * `null` means the request carries no session identity, so no conversation
1231
+ * identity can be derived and the caller must fall back to a per-request id.
1232
+ */
1233
+ resolveTurnRecordId(sessionId, messages, kind = "conversation") {
1234
+ if (sessionId === void 0 || sessionId.length === 0) return null;
1235
+ return this.resolveTurnIdentity(sessionId, messages, kind).requestSetId;
1236
+ }
1237
+ /**
1238
+ * Agent-run identity the Qoder service reports consumption against.
1239
+ *
1240
+ * The official client stamps one `business.id` per agent run and keeps it for
1241
+ * every request that run makes, which is what makes one subscriber turn one
1242
+ * consumption record. A request outside any conversation still gets its own.
1243
+ */
1244
+ resolveBusinessId(sessionId, messages, kind = "conversation") {
1245
+ const identity = this.resolveTurnIdentity(sessionId, messages, kind);
1246
+ return {
1247
+ businessId: identity.businessId,
1248
+ beginAt: identity.beginAt
1249
+ };
1250
+ }
1251
+ /** Forget one session, e.g. when its transport is disposed. */
1252
+ forget(sessionId) {
1253
+ this.sessions.delete(sessionId);
1254
+ }
1255
+ /** Forget every session. */
1256
+ clear() {
1257
+ this.sessions.clear();
1258
+ }
1259
+ claimSession(sessionId, state) {
1260
+ this.sessions.set(sessionId, state);
1261
+ this.evict();
1262
+ }
1263
+ /**
1264
+ * Keep the recorded sessions bounded.
1265
+ *
1266
+ * A conversation untouched for longer than the idle TTL cannot still be
1267
+ * running a turn, so it is dropped first and dropping it cannot split a
1268
+ * record. Sessions are ordered by last touch, which makes that idle set the
1269
+ * prefix of the map, so in practice this deletes from the coldest end; the TTL
1270
+ * only changes the outcome if the clock moves backwards. Once every recorded
1271
+ * conversation is more recent than the TTL, the ceiling drops the coldest one,
1272
+ * which can split the turn that conversation was running.
1273
+ */
1274
+ evict() {
1275
+ const idleBefore = this.now() - this.idleSessionTtlMs;
1276
+ for (const [id, state] of this.sessions) {
1277
+ if (this.sessions.size <= this.maxSessions) return;
1278
+ if (state.lastTouchedAt <= idleBefore) this.sessions.delete(id);
1279
+ }
1280
+ while (this.sessions.size > this.maxSessions) {
1281
+ const oldest = this.sessions.keys().next();
1282
+ if (oldest.done === true) return;
1283
+ this.sessions.delete(oldest.value);
1284
+ }
1285
+ }
1286
+ };
1287
+ //#endregion
986
1288
  //#region src/qoder/transport/wire/serialize.ts
987
1289
  /** Build the minimum qodercli request envelope for a validated DSH request. */
988
1290
  function stableHash(prefix, ...inputs) {
@@ -994,18 +1296,25 @@ function stableHash(prefix, ...inputs) {
994
1296
  }
995
1297
  return hash.digest("hex").slice(0, 16);
996
1298
  }
997
- function stableChatRecordId(model, messages, tools, maxTokens) {
998
- const hash = crypto.createHash("sha256");
999
- hash.update("qoder-record");
1000
- hash.update("\0");
1001
- hash.update(model);
1002
- hash.update("\0");
1003
- hash.update(JSON.stringify(messages));
1004
- hash.update("\0");
1005
- hash.update(JSON.stringify(tools));
1006
- hash.update("\0");
1007
- hash.update(`mt=${maxTokens}`);
1008
- return hash.digest("hex").slice(0, 16);
1299
+ /**
1300
+ * Turn identity shared by every request the process serves.
1301
+ *
1302
+ * Qoder's Credits panel aggregates consumption per agent run — the
1303
+ * `business.id` a request reports — so every step of one turn must carry the
1304
+ * same run identity while the next subscriber prompt opens a new one. The
1305
+ * tracker owns that boundary.
1306
+ */
1307
+ const qoderTurnTracker = new QoderTurnTracker();
1308
+ /**
1309
+ * Whether this request serves the subscriber's turn or the host's bookkeeping.
1310
+ *
1311
+ * The host marks an auxiliary call through `GenerateOptions.purpose`, and those
1312
+ * calls carry a message list of their own under the same session identity. They
1313
+ * must never move the open turn's boundary, or one subscriber prompt would
1314
+ * report two consumption records.
1315
+ */
1316
+ function callKind(options) {
1317
+ return options.purpose === void 0 ? "conversation" : "auxiliary";
1009
1318
  }
1010
1319
  /**
1011
1320
  * Reject an unusable request before any credential resolution or provider I/O.
@@ -1020,7 +1329,6 @@ function validateQoderRequestShape(options, model) {
1020
1329
  if (!model?.reasoningEfforts?.some((candidate) => candidate.id === effort)) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise reasoning effort "${effort}".`, "UNSUPPORTED_REASONING_EFFORT");
1021
1330
  }
1022
1331
  if (options.messages.some((message) => contentHasImage(message.content)) && model?.supportsImages !== true) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise image input.`, "UNSUPPORTED_CONTENT");
1023
- validateMessageShapes(options.messages);
1024
1332
  }
1025
1333
  /** Translate a request whose shape has already been validated. */
1026
1334
  function translateQoderMessages(options, attachments, pipeline) {
@@ -1030,7 +1338,7 @@ async function validateQoderRequest(options, model, attachments) {
1030
1338
  validateQoderRequestShape(options, model);
1031
1339
  return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
1032
1340
  }
1033
- async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
1341
+ async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments, tracker) {
1034
1342
  if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
1035
1343
  const modelKey = options.model || "cmodel";
1036
1344
  const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
@@ -1045,17 +1353,18 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1045
1353
  }]));
1046
1354
  let lastUserText = "";
1047
1355
  for (let index = messages.length - 1; index >= 0; index--) if (messages[index].role === "user") {
1048
- const content = messages[index].content;
1049
- lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
1356
+ lastUserText = wireMessageText(messages[index]);
1050
1357
  break;
1051
1358
  }
1052
- const stablePart = stableHash("qoder-session", userId, modelKey);
1359
+ const stablePart = stableHash("qoder-session", userId);
1053
1360
  const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
1054
- const recordId = stableChatRecordId(modelKey, messages, tools, maxTokens);
1361
+ const requestId = crypto.randomUUID();
1362
+ const sessionIdParam = options.sessionId === void 0 ? void 0 : String(options.sessionId);
1363
+ const identity = (tracker ?? qoderTurnTracker).resolveTurnIdentity(sessionIdParam, messages, callKind(options), requestId, modelKey);
1055
1364
  return {
1056
- request_id: crypto.randomUUID(),
1057
- request_set_id: recordId,
1058
- chat_record_id: recordId,
1365
+ request_id: requestId,
1366
+ request_set_id: identity.requestSetId,
1367
+ chat_record_id: identity.chatRecordId,
1059
1368
  session_id: sessionId,
1060
1369
  stream: true,
1061
1370
  chat_task: "FREE_INPUT",
@@ -1100,12 +1409,12 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1100
1409
  },
1101
1410
  business: {
1102
1411
  product: "cli",
1103
- version: "1.0.0",
1412
+ version: "1.0.60",
1104
1413
  type: "agent",
1105
1414
  stage: "start",
1106
- id: crypto.randomUUID(),
1415
+ id: identity.businessId,
1107
1416
  name: lastUserText.substring(0, 30),
1108
- begin_at: Date.now()
1417
+ begin_at: identity.beginAt
1109
1418
  }
1110
1419
  };
1111
1420
  }
@@ -2079,6 +2388,30 @@ const doneMarker = "[DONE]";
2079
2388
  function malformed(message) {
2080
2389
  return new QoderLlmError(message, "MALFORMED_RESPONSE");
2081
2390
  }
2391
+ function extractErrorDetail(envelope) {
2392
+ const raw = envelope.body ?? envelope.message;
2393
+ if (!raw) return void 0;
2394
+ if (typeof raw !== "string") return JSON.stringify(raw);
2395
+ const trimmed = raw.trim();
2396
+ try {
2397
+ const parsed = JSON.parse(trimmed);
2398
+ if (parsed && typeof parsed === "object") {
2399
+ let detailMsg;
2400
+ if (typeof parsed.details === "string") try {
2401
+ const inner = JSON.parse(parsed.details);
2402
+ if (typeof inner?.error?.message === "string") detailMsg = inner.error.message.trim();
2403
+ else if (typeof inner?.message === "string") detailMsg = inner.message.trim();
2404
+ } catch {}
2405
+ const msg = typeof parsed.message === "string" ? parsed.message.trim() : void 0;
2406
+ const code = typeof parsed.code === "string" ? parsed.code.trim() : void 0;
2407
+ const err = typeof parsed.error === "string" ? parsed.error.trim() : void 0;
2408
+ const text = detailMsg || msg || err;
2409
+ if (code && text) return `${code}: ${text}`;
2410
+ return text || code || trimmed;
2411
+ }
2412
+ } catch {}
2413
+ return trimmed;
2414
+ }
2082
2415
  function parseEnvelope(rawData) {
2083
2416
  let value;
2084
2417
  try {
@@ -2090,7 +2423,13 @@ function parseEnvelope(rawData) {
2090
2423
  const envelope = value;
2091
2424
  if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
2092
2425
  if (envelope.statusCodeValue !== void 0 && (!Number.isInteger(envelope.statusCodeValue) || envelope.statusCodeValue < 100 || envelope.statusCodeValue > 599)) throw malformed("Qoder SSE envelope has an invalid status code.");
2093
- if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) throw qoderHttpError(`Qoder service returned upstream error status ${envelope.statusCodeValue}.`, { status: envelope.statusCodeValue });
2426
+ if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) {
2427
+ const detail = extractErrorDetail(envelope);
2428
+ throw qoderHttpError(detail ? `Qoder service returned upstream error status ${envelope.statusCodeValue}: ${detail}` : `Qoder service returned upstream error status ${envelope.statusCodeValue}.`, {
2429
+ status: envelope.statusCodeValue,
2430
+ cause: envelope.body
2431
+ });
2432
+ }
2094
2433
  if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
2095
2434
  return envelope;
2096
2435
  }
@@ -2241,7 +2580,8 @@ async function* parseQoderSse(stream, options = {}) {
2241
2580
  sawDone = true;
2242
2581
  break;
2243
2582
  }
2244
- const body = parseEnvelope(rawData).body?.trim();
2583
+ const envelope = parseEnvelope(rawData);
2584
+ const body = typeof envelope.body === "string" ? envelope.body.trim() : "";
2245
2585
  if (!body) continue;
2246
2586
  if (body === doneMarker) {
2247
2587
  sawDone = true;
@@ -2435,7 +2775,28 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2435
2775
  ...reqId === void 0 ? {} : { requestId: reqId }
2436
2776
  });
2437
2777
  resetIdleTimer();
2438
- if (!response.ok) throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response);
2778
+ if (!response.ok) {
2779
+ let bodyText = "";
2780
+ try {
2781
+ bodyText = (await response.text()).trim();
2782
+ } catch {}
2783
+ let detail = "";
2784
+ if (bodyText) try {
2785
+ const parsed = JSON.parse(bodyText);
2786
+ const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message.trim() : void 0;
2787
+ const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : void 0;
2788
+ if (code && msg) detail = `: ${code}: ${msg}`;
2789
+ else if (msg) detail = `: ${msg}`;
2790
+ else detail = `: ${bodyText.slice(0, 300)}`;
2791
+ } catch {
2792
+ detail = `: ${bodyText.slice(0, 300)}`;
2793
+ }
2794
+ throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}${detail}.`, {
2795
+ status: response.status,
2796
+ headers: response.headers,
2797
+ cause: bodyText || void 0
2798
+ });
2799
+ }
2439
2800
  if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
2440
2801
  let firstChunkDurationMs;
2441
2802
  let tokenUsage;
@@ -2677,6 +3038,10 @@ const Config = z.object({
2677
3038
  "disabled"
2678
3039
  ]).default("auto")
2679
3040
  });
3041
+ const PluginConfig = Config.volatile();
3042
+ function readConfig(value) {
3043
+ return "get" in value ? Config(structuredClone(value.get())) : value;
3044
+ }
2680
3045
  function resolveModels(models) {
2681
3046
  const resolved = models ?? defaultModels;
2682
3047
  if (resolved.length === 0) throw new Error("provider-qoder: at least one model is required");
@@ -2715,6 +3080,7 @@ var QoderAdapter = class extends LlmAdapter {
2715
3080
  catalogModels;
2716
3081
  providerId;
2717
3082
  providerName;
3083
+ onModelsDiscovered;
2718
3084
  discoveries = /* @__PURE__ */ new WeakMap();
2719
3085
  constructor(options) {
2720
3086
  super();
@@ -2722,6 +3088,7 @@ var QoderAdapter = class extends LlmAdapter {
2722
3088
  this.catalogModels = options.models && options.models.length > 0 ? options.models : defaultModels;
2723
3089
  this.providerId = options.providerId ?? "dsh-provider-qoder";
2724
3090
  this.providerName = options.providerName ?? "Qoder";
3091
+ this.onModelsDiscovered = options.onModelsDiscovered;
2725
3092
  }
2726
3093
  providerInfo(provider) {
2727
3094
  return {
@@ -2739,8 +3106,10 @@ var QoderAdapter = class extends LlmAdapter {
2739
3106
  }
2740
3107
  const entry = cached;
2741
3108
  if (entry.inflight === void 0 && Date.now() >= entry.expiresAt) entry.inflight = Promise.resolve().then(() => transport.discoverModels()).then((models) => {
3109
+ if (this.discoveries.get(transport) !== entry || this.resolveTransport() !== transport) return;
2742
3110
  entry.models = models;
2743
3111
  entry.expiresAt = Date.now() + 300 * 1e3;
3112
+ this.publishDiscoveredModels(transport, models);
2744
3113
  }).catch(() => {}).finally(() => {
2745
3114
  entry.inflight = void 0;
2746
3115
  });
@@ -2748,6 +3117,18 @@ var QoderAdapter = class extends LlmAdapter {
2748
3117
  if (this.resolveTransport() !== transport) return this.listModels(provider);
2749
3118
  return this.effectiveModels().map((model) => modelInfo(provider, model));
2750
3119
  }
3120
+ /**
3121
+ * Announce one accepted discovery without joining the catalog read.
3122
+ *
3123
+ * Persisting metadata can queue behind unrelated settings writes, so awaiting
3124
+ * it here would stall every `listModels` call on settings storage.
3125
+ */
3126
+ publishDiscoveredModels(transport, models) {
3127
+ try {
3128
+ const pending = this.onModelsDiscovered?.(transport, models);
3129
+ if (pending !== void 0 && pending !== null && typeof pending.catch === "function") pending.catch(() => {});
3130
+ } catch {}
3131
+ }
2751
3132
  effectiveModels() {
2752
3133
  return mergeQoderDiscoveryMetadata(this.catalogModels, this.discoveries.get(this.resolveTransport())?.models ?? []);
2753
3134
  }
@@ -2861,6 +3242,39 @@ async function resolveManagedQoderPat(credentials) {
2861
3242
  if (hit === void 0 || isEnvironmentCredentialSource(hit.source)) return "";
2862
3243
  return hit.value.trim();
2863
3244
  }
3245
+ //#endregion
3246
+ //#region src/dsh/settings.ts
3247
+ function bindQoderSettings(owner, ctx, input, options) {
3248
+ const settings = ctx.settings;
3249
+ if (typeof settings.register === "function") {
3250
+ const namespace = "provider-qoder";
3251
+ return {
3252
+ namespace,
3253
+ scope: settings.register(namespace, Config, options)
3254
+ };
3255
+ }
3256
+ const namespace = owner.fiber.entry?.options.id ?? "provider-qoder";
3257
+ const get = () => readConfig(input);
3258
+ options.validate?.(get());
3259
+ ctx.effect(() => owner.on("internal/config", function(_raw, next) {
3260
+ const value = next();
3261
+ if (this === owner.fiber) options.validate?.(Config(value));
3262
+ return value;
3263
+ }));
3264
+ return {
3265
+ namespace,
3266
+ scope: {
3267
+ get,
3268
+ update: (patch) => settings.update(namespace, patch),
3269
+ watch: (listener) => {
3270
+ const on = owner.on.bind(owner);
3271
+ return ctx.effect(() => on("loader/volatile-update", () => {
3272
+ Promise.resolve().then(listener).catch((error) => ctx.emit("internal/error", error));
3273
+ }));
3274
+ }
3275
+ }
3276
+ };
3277
+ }
2864
3278
  /**
2865
3279
  * Absolute Fetch-route prefix. Connection mounts its shared, authenticated API
2866
3280
  * channel at `/api`, and every exact Fetch route lives directly below it.
@@ -2969,7 +3383,8 @@ function publicError(code, message, details = { issues: [] }) {
2969
3383
  }
2970
3384
  };
2971
3385
  }
2972
- function apply(ctx, config = {}) {
3386
+ function apply(ctx, input = {}) {
3387
+ const config = readConfig(input);
2973
3388
  const logger = ctx.logger;
2974
3389
  const initialRegion = config.region ?? "global";
2975
3390
  const hasConfiguredModels = config.models !== void 0 && config.models.length > 0;
@@ -3022,7 +3437,16 @@ function apply(ctx, config = {}) {
3022
3437
  resolveTransport: () => activeTransport,
3023
3438
  models: initial.models,
3024
3439
  providerId: providerQoder,
3025
- providerName: "Qoder"
3440
+ providerName: "Qoder",
3441
+ onModelsDiscovered: (transport, models) => {
3442
+ if (transport !== activeTransport || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3443
+ const region = activeTransportConfig.region;
3444
+ discoveredCatalogs[region] = models;
3445
+ refreshAdapter();
3446
+ persistDiscoveredModels(region).catch((error) => {
3447
+ logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error));
3448
+ });
3449
+ }
3026
3450
  });
3027
3451
  const registration = ctx.llm.registerAdapter([providerQoder], adapter);
3028
3452
  const refreshAdapter = () => {
@@ -3040,7 +3464,7 @@ function apply(ctx, config = {}) {
3040
3464
  registration.replace([providerQoder]);
3041
3465
  };
3042
3466
  ctx.inject(["settings"], (settingsCtx) => {
3043
- const scope = settingsCtx.settings.register(settingsNamespace, Config, {
3467
+ const { scope, namespace } = bindQoderSettings(ctx, settingsCtx, input, {
3044
3468
  base: baseConfig,
3045
3469
  validate: (value) => {
3046
3470
  for (const region of Object.keys(value.modelsByRegion ?? {})) if (region !== "global" && region !== "china") throw new Error(`provider-qoder: unsupported model catalog region "${region}"`);
@@ -3050,34 +3474,39 @@ function apply(ctx, config = {}) {
3050
3474
  });
3051
3475
  legacyModelsRegion = scope.get().region ?? initialRegion;
3052
3476
  current = () => scope.get();
3053
- persistDiscoveredModels = async (region) => {
3054
- const value = scope.get();
3055
- const selected = modelsFor(value, region, legacyModelsRegion);
3056
- const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
3057
- if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
3058
- ...value.modelsByRegion,
3059
- [region]: enriched
3060
- } });
3477
+ let bindingActive = true;
3478
+ const persistCatalog = async (region) => {
3479
+ while (bindingActive && ctx.fiber.state !== fiberUnloading && ctx.fiber.state !== fiberDisposed) {
3480
+ if (!settingsCtx.settings.writable || discoveredCatalogs[region].length === 0) return;
3481
+ const snapshot = settingsCtx.settings.describe().find((section) => section.ns === namespace);
3482
+ if (snapshot === void 0) return;
3483
+ const selected = modelsFor(snapshot.value, region, legacyModelsRegion);
3484
+ const enriched = modelsFor(Config({ modelsByRegion: { [region]: mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]) } }), region);
3485
+ if (hasSameQoderDiscoveryMetadata(selected, enriched)) return;
3486
+ try {
3487
+ await settingsCtx.settings.update(namespace, { modelsByRegion: { [region]: enriched } }, snapshot.revision);
3488
+ return;
3489
+ } catch (error) {
3490
+ if (!(error instanceof SettingsConflictError)) throw error;
3491
+ }
3492
+ }
3061
3493
  };
3494
+ persistDiscoveredModels = persistCatalog;
3062
3495
  refreshAdapter();
3063
3496
  const loaded = scope.get();
3064
3497
  const loadedRegion = loaded.region ?? "global";
3065
- if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: {
3066
- ...loaded.modelsByRegion,
3067
- [loadedRegion]: resolveModels(loaded.models)
3068
- } }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
3069
- scope.watch(async (next) => {
3070
- if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3498
+ if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: { [loadedRegion]: resolveModels(loaded.models) } }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
3499
+ scope.watch(async () => {
3500
+ if (!bindingActive || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3071
3501
  refreshAdapter();
3072
- const region = next.region ?? "global";
3073
- const selected = modelsFor(next, region, legacyModelsRegion);
3074
- const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
3075
- if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
3076
- ...next.modelsByRegion,
3077
- [region]: enriched
3078
- } });
3502
+ await persistCatalog(scope.get().region ?? "global");
3079
3503
  });
3504
+ for (const region of ["global", "china"]) {
3505
+ if (discoveredCatalogs[region].length === 0) continue;
3506
+ persistCatalog(region).catch((error) => logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error)));
3507
+ }
3080
3508
  settingsCtx.effect(() => () => {
3509
+ bindingActive = false;
3081
3510
  if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3082
3511
  persistDiscoveredModels = async () => {};
3083
3512
  current = () => baseConfig;
@@ -3169,4 +3598,4 @@ function apply(ctx, config = {}) {
3169
3598
  });
3170
3599
  }
3171
3600
  //#endregion
3172
- export { Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
3601
+ export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-provider-qoder",
3
3
  "description": "Global Qoder subscription adapter for the DeepSeek Harness LLM seam",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -80,23 +80,28 @@
80
80
  "react": "^18.2.0"
81
81
  },
82
82
  "dependencies": {
83
- "@deepseek-ai/schemastery": "3.18.1"
83
+ "@deepseek-ai/schemastery": "3.18.3"
84
84
  },
85
85
  "devDependencies": {
86
- "@deepseek-ai/cordis": "4.0.2",
87
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
88
- "@deepseek-ai/dsh-api-remotes": "0.1.2-rc.1",
89
- "@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
90
- "@deepseek-ai/dsh-client-connection": "0.1.2-rc.1",
91
- "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
92
- "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
93
- "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
94
- "@deepseek-ai/dsh-credentials": "0.1.2-rc.1",
95
- "@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
96
- "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
97
- "@deepseek-ai/dsh-settings": "0.1.2-rc.1",
98
- "@deepseek-ai/dsh-web": "0.1.2-rc.1",
99
- "@deepseek-ai/schemastery": "3.18.1",
86
+ "@deepseek-ai/cordis": "4.0.3",
87
+ "@deepseek-ai/cordis-plugin-loader": "1.0.4",
88
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.1",
89
+ "@deepseek-ai/dsh-api-remotes": "0.1.7-alpha.1",
90
+ "@deepseek-ai/dsh-app-boot": "0.1.7-alpha.1",
91
+ "@deepseek-ai/dsh-attachment": "0.1.7-alpha.1",
92
+ "@deepseek-ai/dsh-brand": "0.1.7-alpha.1",
93
+ "@deepseek-ai/dsh-client-connection": "0.1.7-alpha.1",
94
+ "@deepseek-ai/dsh-client-locale": "0.1.7-alpha.1",
95
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-alpha.1",
96
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-alpha.1",
97
+ "@deepseek-ai/dsh-config-editor": "0.1.7-alpha.1",
98
+ "@deepseek-ai/dsh-credentials": "0.1.7-alpha.1",
99
+ "@deepseek-ai/dsh-invariants": "0.1.7-alpha.1",
100
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.1",
101
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.1",
102
+ "@deepseek-ai/dsh-settings": "0.1.7-alpha.1",
103
+ "@deepseek-ai/dsh-settings-legacy": "npm:@deepseek-ai/dsh-settings@0.1.2-rc.1",
104
+ "@deepseek-ai/dsh-web": "0.1.7-alpha.1",
100
105
  "@types/node": "24.3.0",
101
106
  "@types/react": "18.3.1",
102
107
  "lightningcss": "^1.33.0",