dsh-provider-qoder 0.2.1 → 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/lib/index.js CHANGED
@@ -4,6 +4,8 @@ import crypto, { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
+ import { WebError } from "@deepseek-ai/dsh-web";
8
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
7
9
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
8
10
  //#region src/qoder/catalog.ts
9
11
  const discoveredMetadataKeys = [
@@ -234,6 +236,12 @@ function getQoderUserStatusUrl(region = "global") {
234
236
  const { openApiUrl } = resolveQoderEndpoints(region);
235
237
  return `${openApiUrl}/api/v3/user/status`;
236
238
  }
239
+ /** Path of the center web search route. */
240
+ const qoderWebSearchPath = "/api/v1/webSearch/oneSearch";
241
+ function getQoderWebSearchUrl(region = "global") {
242
+ const { centerUrl } = resolveQoderEndpoints(region);
243
+ return `${centerUrl.replace(/\/+$/u, "")}/algo${qoderWebSearchPath}?Encode=1`;
244
+ }
237
245
  //#endregion
238
246
  //#region src/qoder/errors.ts
239
247
  /**
@@ -254,7 +262,8 @@ function qoderHttpError(message, response) {
254
262
  return new QoderLlmError(message, code, {
255
263
  status,
256
264
  ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
257
- ...requestId === void 0 ? {} : { requestId }
265
+ ...requestId === void 0 ? {} : { requestId },
266
+ ...response.cause === void 0 ? {} : { cause: response.cause }
258
267
  });
259
268
  }
260
269
  function qoderRequestId(headers) {
@@ -333,7 +342,9 @@ function redactLogValue(value, depth = 0) {
333
342
  if (value instanceof Error) return {
334
343
  name: value.name,
335
344
  message: redactString(value.message),
336
- ..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {}
345
+ ..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {},
346
+ ..."cause" in value ? { cause: redactLogValue(value.cause, depth + 1) } : {},
347
+ ...value instanceof AggregateError ? { errors: redactLogValue(value.errors, depth + 1) } : {}
337
348
  };
338
349
  if (Array.isArray(value)) return value.slice(0, maxEntries).map((item) => redactLogValue(item, depth + 1));
339
350
  return Object.fromEntries(Object.entries(value).slice(0, maxEntries).map(([key, item]) => {
@@ -374,7 +385,7 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
374
385
  6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
375
386
  XcW+ML9FoCI6AOvOzwIDAQAB
376
387
  -----END PUBLIC KEY-----`;
377
- const qoderIdeVersion = "1.1.47";
388
+ const qoderIdeVersion = "1.1.60";
378
389
  const defaultUserAgent = `qoder/${qoderIdeVersion}`;
379
390
  const qoderDataPolicy = "disagree";
380
391
  const qoderLoginVersion = "v2";
@@ -448,6 +459,7 @@ function buildAuthHeaders(body, requestURL, creds) {
448
459
  "X-Request-Id": crypto.randomUUID()
449
460
  };
450
461
  }
462
+ const defaultResponseHeaderTimeoutMs = 6e4;
451
463
  const defaultMaxJsonBytes = 2 * 1024 * 1024;
452
464
  const defaultMaxErrorBytes = 16 * 1024;
453
465
  const metadataRetryBaseDelayMs = 200;
@@ -799,35 +811,6 @@ function toolResultText(block) {
799
811
  }
800
812
  return text;
801
813
  }
802
- /**
803
- * Check message shapes without performing any provider I/O.
804
- *
805
- * Callers run this before resolving credentials so an invalid request never
806
- * consumes a Qoder subscription.
807
- */
808
- function validateMessageShapes(messages) {
809
- for (const message of messages) {
810
- const toolResults = message.content.filter((block) => block.type === "tool-result");
811
- if (toolResults.length > 0) {
812
- 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.");
813
- for (const result of toolResults) toolResultText(result);
814
- continue;
815
- }
816
- for (const block of message.content) {
817
- if (block.type === "text") continue;
818
- if (block.type === "image") {
819
- if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
820
- continue;
821
- }
822
- if (block.type === "tool-call") {
823
- if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
824
- continue;
825
- }
826
- if (block.type === "reasoning") continue;
827
- throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
828
- }
829
- }
830
- }
831
814
  /** Reject a batch that exceeds the deployment image policy before any upload work starts. */
832
815
  function enforceImageLimits(images, attachments) {
833
816
  const limits = attachments.imageLimits;
@@ -842,10 +825,15 @@ async function resolveImagePart(block, context) {
842
825
  let image;
843
826
  try {
844
827
  const limits = attachments.imageLimits;
845
- image = await attachments.readImageRequest(block.attachment, {
846
- maxPixels: limits.maxImagePixels,
847
- maxBytes: limits.maxImageBytes
848
- }, 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);
849
837
  } catch (error) {
850
838
  if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
851
839
  if (error instanceof QoderLlmError) throw error;
@@ -870,8 +858,29 @@ function translateTools(tools) {
870
858
  }
871
859
  }));
872
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
+ }
873
883
  async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
874
- validateMessageShapes(messages);
875
884
  const context = {
876
885
  attachments,
877
886
  signal,
@@ -880,18 +889,20 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
880
889
  };
881
890
  const preserveThinking = pipeline?.preserveThinking ?? true;
882
891
  const output = [];
892
+ let lastAssistantToolCallIds = [];
883
893
  if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
884
894
  role: "system",
885
895
  content: systemPrompt
886
896
  });
887
897
  for (const message of messages) {
888
898
  const toolResults = message.content.filter((block) => block.type === "tool-result");
899
+ const nonToolBlocks = message.content.filter((block) => block.type !== "tool-result");
889
900
  if (toolResults.length > 0) {
890
- 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.");
891
901
  for (const result of toolResults) {
902
+ const callId = consumeToolCallId(message, result, lastAssistantToolCallIds, output.length);
892
903
  output.push({
893
904
  role: "tool",
894
- tool_call_id: String(result.toolCallId),
905
+ tool_call_id: callId,
895
906
  content: toolResultText(result)
896
907
  });
897
908
  const images = result.content.filter((block) => block.type === "image");
@@ -906,24 +917,27 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
906
917
  });
907
918
  }
908
919
  }
909
- continue;
920
+ if (nonToolBlocks.length === 0) continue;
910
921
  }
922
+ const contentBlocks = toolResults.length > 0 ? nonToolBlocks : message.content;
911
923
  let text = "";
912
924
  let reasoningText = "";
913
925
  const userContent = [];
914
926
  const pendingImages = [];
915
927
  let hasImage = false;
916
928
  const toolCalls = [];
917
- 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.`);
918
931
  if (block.type === "text") {
919
932
  text += block.text;
920
- if (message.role === "user") userContent.push({
933
+ if (message.role === "user" || toolResults.length > 0) userContent.push({
921
934
  type: "text",
922
935
  text: block.text
923
936
  });
924
937
  continue;
925
938
  }
926
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.");
927
941
  hasImage = true;
928
942
  pendingImages.push({
929
943
  slot: userContent.length,
@@ -951,6 +965,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
951
965
  if (message.role === "assistant") {
952
966
  const hasReasoning = preserveThinking && reasoningText.length > 0;
953
967
  if (!text && toolCalls.length === 0 && !hasReasoning) continue;
968
+ if (toolCalls.length > 0) lastAssistantToolCallIds = toolCalls.map((tc) => tc.id);
954
969
  output.push({
955
970
  role: "assistant",
956
971
  content: text || " ",
@@ -959,20 +974,317 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
959
974
  });
960
975
  continue;
961
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
+ }
962
996
  if (pendingImages.length > 0) {
963
997
  if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
964
998
  await Promise.all(pendingImages.map(async (pending) => {
965
999
  userContent[pending.slot] = await resolveImagePart(pending.block, context);
966
1000
  }));
967
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;
968
1005
  output.push({
969
- role: message.role,
970
- 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 } : {}
971
1009
  });
972
1010
  }
973
1011
  return output;
974
1012
  }
975
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
976
1288
  //#region src/qoder/transport/wire/serialize.ts
977
1289
  /** Build the minimum qodercli request envelope for a validated DSH request. */
978
1290
  function stableHash(prefix, ...inputs) {
@@ -984,18 +1296,25 @@ function stableHash(prefix, ...inputs) {
984
1296
  }
985
1297
  return hash.digest("hex").slice(0, 16);
986
1298
  }
987
- function stableChatRecordId(model, messages, tools, maxTokens) {
988
- const hash = crypto.createHash("sha256");
989
- hash.update("qoder-record");
990
- hash.update("\0");
991
- hash.update(model);
992
- hash.update("\0");
993
- hash.update(JSON.stringify(messages));
994
- hash.update("\0");
995
- hash.update(JSON.stringify(tools));
996
- hash.update("\0");
997
- hash.update(`mt=${maxTokens}`);
998
- 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";
999
1318
  }
1000
1319
  /**
1001
1320
  * Reject an unusable request before any credential resolution or provider I/O.
@@ -1010,7 +1329,6 @@ function validateQoderRequestShape(options, model) {
1010
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");
1011
1330
  }
1012
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");
1013
- validateMessageShapes(options.messages);
1014
1332
  }
1015
1333
  /** Translate a request whose shape has already been validated. */
1016
1334
  function translateQoderMessages(options, attachments, pipeline) {
@@ -1020,7 +1338,7 @@ async function validateQoderRequest(options, model, attachments) {
1020
1338
  validateQoderRequestShape(options, model);
1021
1339
  return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
1022
1340
  }
1023
- async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
1341
+ async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments, tracker) {
1024
1342
  if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
1025
1343
  const modelKey = options.model || "cmodel";
1026
1344
  const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
@@ -1035,17 +1353,18 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1035
1353
  }]));
1036
1354
  let lastUserText = "";
1037
1355
  for (let index = messages.length - 1; index >= 0; index--) if (messages[index].role === "user") {
1038
- const content = messages[index].content;
1039
- lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
1356
+ lastUserText = wireMessageText(messages[index]);
1040
1357
  break;
1041
1358
  }
1042
- const stablePart = stableHash("qoder-session", userId, modelKey);
1359
+ const stablePart = stableHash("qoder-session", userId);
1043
1360
  const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
1044
- 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);
1045
1364
  return {
1046
- request_id: crypto.randomUUID(),
1047
- request_set_id: recordId,
1048
- chat_record_id: recordId,
1365
+ request_id: requestId,
1366
+ request_set_id: identity.requestSetId,
1367
+ chat_record_id: identity.chatRecordId,
1049
1368
  session_id: sessionId,
1050
1369
  stream: true,
1051
1370
  chat_task: "FREE_INPUT",
@@ -1090,12 +1409,12 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1090
1409
  },
1091
1410
  business: {
1092
1411
  product: "cli",
1093
- version: "1.0.0",
1412
+ version: "1.0.60",
1094
1413
  type: "agent",
1095
1414
  stage: "start",
1096
- id: crypto.randomUUID(),
1415
+ id: identity.businessId,
1097
1416
  name: lastUserText.substring(0, 30),
1098
- begin_at: Date.now()
1417
+ begin_at: identity.beginAt
1099
1418
  }
1100
1419
  };
1101
1420
  }
@@ -1426,13 +1745,20 @@ function normalizeQuota(raw) {
1426
1745
  unit
1427
1746
  };
1428
1747
  }
1748
+ /**
1749
+ * Normalize an epoch-millis (or parseable date string) expiry to ISO-8601.
1750
+ *
1751
+ * The provider uses the int64 maximum as a "never expires" sentinel, which is
1752
+ * outside the `Date` range: an unusable value resolves to `undefined` instead
1753
+ * of throwing, so one bad field cannot abort the whole account read.
1754
+ */
1429
1755
  function normalizeExpiresAt(rawExpires) {
1430
1756
  if (rawExpires === void 0 || rawExpires === null) return void 0;
1431
- if (typeof rawExpires === "number" && rawExpires > 0) return new Date(rawExpires).toISOString();
1432
- if (typeof rawExpires === "string" && rawExpires.length > 0) {
1433
- const parsed = Date.parse(rawExpires);
1434
- if (!Number.isNaN(parsed) && parsed > 0) return new Date(parsed).toISOString();
1435
- }
1757
+ const parsed = typeof rawExpires === "number" ? rawExpires : typeof rawExpires === "string" && rawExpires.length > 0 ? Date.parse(rawExpires) : NaN;
1758
+ if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
1759
+ const date = new Date(parsed);
1760
+ if (!Number.isFinite(date.getTime())) return void 0;
1761
+ return date.toISOString();
1436
1762
  }
1437
1763
  function asString(value) {
1438
1764
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
@@ -1447,6 +1773,76 @@ function asNumber(value) {
1447
1773
  if (Number.isFinite(num)) return num;
1448
1774
  }
1449
1775
  }
1776
+ /**
1777
+ * The provider names a resource package with internal identifiers (campaign
1778
+ * ids) that must never reach the UI, so the subscriber-facing copy only ever
1779
+ * comes from `displayLabels`.
1780
+ */
1781
+ function findDisplayLabel(raw, dimension) {
1782
+ if (!Array.isArray(raw)) return void 0;
1783
+ for (const entry of raw) {
1784
+ if (!entry || typeof entry !== "object") continue;
1785
+ const obj = entry;
1786
+ if (asString(obj.dimension) === dimension) return obj;
1787
+ }
1788
+ }
1789
+ /**
1790
+ * Normalize one `displayLabels` entry into language-tagged copy.
1791
+ *
1792
+ * The provider's value lives under `valueI18n`/`value_i18n`, keyed by language
1793
+ * tag, plus `value` as the untagged fallback. An entry with neither is dropped
1794
+ * rather than surfaced as an empty label.
1795
+ */
1796
+ function normalizeLocalizedText(raw) {
1797
+ if (!raw || typeof raw !== "object") return void 0;
1798
+ const obj = raw;
1799
+ const values = {};
1800
+ const localized = obj.valueI18n ?? obj.value_i18n;
1801
+ if (localized && typeof localized === "object") for (const [locale, text] of Object.entries(localized)) {
1802
+ const resolved = asString(text);
1803
+ if (resolved !== void 0) values[locale] = resolved;
1804
+ }
1805
+ const fallback = asString(obj.value);
1806
+ if (fallback === void 0 && Object.keys(values).length === 0) return void 0;
1807
+ return {
1808
+ values,
1809
+ fallback: fallback ?? ""
1810
+ };
1811
+ }
1812
+ /**
1813
+ * Normalize the dedicated (entitlement-scoped) resource packages.
1814
+ *
1815
+ * Copy is read from `displayLabels` only, never from `name`/`description`,
1816
+ * which carry internal campaign identifiers. Entries without a usable size are
1817
+ * dropped and a non-array value yields `undefined`, so an unexpected payload
1818
+ * never fails the account read that also feeds the model catalog.
1819
+ */
1820
+ function normalizeResourcePackages(raw) {
1821
+ if (!Array.isArray(raw)) return void 0;
1822
+ const packages = [];
1823
+ for (const entry of raw) {
1824
+ if (!entry || typeof entry !== "object") continue;
1825
+ const obj = entry;
1826
+ const quota = normalizeQuota(obj);
1827
+ if (quota === void 0 || quota.total <= 0 && quota.used <= 0 && quota.remaining <= 0) continue;
1828
+ const id = asString(obj.id);
1829
+ const title = normalizeLocalizedText(findDisplayLabel(obj.displayLabels ?? obj.display_labels, "title"));
1830
+ const description = normalizeLocalizedText(findDisplayLabel(obj.displayLabels ?? obj.display_labels, "description"));
1831
+ const expiresAt = normalizeExpiresAt(obj.expiresAt);
1832
+ const available = asBoolean(obj.available);
1833
+ const status = asString(obj.status);
1834
+ packages.push({
1835
+ ...id !== void 0 ? { id } : {},
1836
+ ...title !== void 0 ? { title } : {},
1837
+ ...description !== void 0 ? { description } : {},
1838
+ ...quota,
1839
+ ...expiresAt !== void 0 ? { expiresAt } : {},
1840
+ ...available !== void 0 ? { available } : {},
1841
+ ...status !== void 0 ? { status } : {}
1842
+ });
1843
+ }
1844
+ return packages.length > 0 ? packages : void 0;
1845
+ }
1450
1846
  function normalizeOrganization(raw) {
1451
1847
  if (!raw || typeof raw !== "object") return void 0;
1452
1848
  const obj = raw;
@@ -1569,6 +1965,14 @@ var QoderUsageReader = class {
1569
1965
  if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
1570
1966
  else this.cache.clear();
1571
1967
  }
1968
+ /**
1969
+ * Read `GET /api/v2/quota/usage` and normalize it for the host/client seam.
1970
+ *
1971
+ * The `raw` echo keeps the upstream fields for diagnostics, but never
1972
+ * `dedicatedResourcePackages`: those entries carry the campaign identifiers
1973
+ * that the normalized list deliberately discards, and this payload crosses
1974
+ * the seam into the browser.
1975
+ */
1572
1976
  async fetchUsage(jobToken, signal) {
1573
1977
  const data = await openApiJsonRequest(this.fetchImpl, {
1574
1978
  url: getQoderUsageUrl(this.region),
@@ -1579,13 +1983,17 @@ var QoderUsageReader = class {
1579
1983
  operation: "Usage",
1580
1984
  logCategory: "account.usage"
1581
1985
  });
1986
+ const dedicatedResourcePackages = normalizeResourcePackages(data.dedicatedResourcePackages);
1987
+ const rawUsage = { ...data };
1988
+ delete rawUsage.dedicatedResourcePackages;
1582
1989
  return {
1583
1990
  userQuota: normalizeQuota(data.userQuota),
1584
1991
  orgResourcePackage: normalizeQuota(data.orgResourcePackage),
1992
+ ...dedicatedResourcePackages !== void 0 ? { dedicatedResourcePackages } : {},
1585
1993
  totalUsagePercentage: typeof data.totalUsagePercentage === "number" ? data.totalUsagePercentage : void 0,
1586
1994
  isQuotaExceeded: typeof data.isQuotaExceeded === "boolean" ? data.isQuotaExceeded : false,
1587
1995
  expiresAt: normalizeExpiresAt(data.expiresAt),
1588
- raw: data
1996
+ raw: rawUsage
1589
1997
  };
1590
1998
  }
1591
1999
  async fetchPlan(jobToken, signal) {
@@ -1659,6 +2067,163 @@ function qoderEncodeBody(plaintext) {
1659
2067
  }
1660
2068
  return out;
1661
2069
  }
2070
+ function searchAborted(signal, fallback) {
2071
+ return new WebError("Web search was cancelled.", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
2072
+ }
2073
+ function isAbortError(error) {
2074
+ return error instanceof DOMException && error.name === "AbortError";
2075
+ }
2076
+ var QoderSearchClient = class {
2077
+ fetchImpl;
2078
+ region;
2079
+ logger;
2080
+ timeoutMs;
2081
+ resolveCredentials;
2082
+ refreshCredentials;
2083
+ now;
2084
+ constructor(options) {
2085
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
2086
+ this.region = options.region ?? "global";
2087
+ this.logger = options.logger;
2088
+ this.timeoutMs = options.timeoutMs ?? 3e4;
2089
+ this.resolveCredentials = options.resolveCredentials;
2090
+ this.refreshCredentials = options.refreshCredentials;
2091
+ this.now = options.now ?? Date.now;
2092
+ }
2093
+ async search(request, signal) {
2094
+ if (signal?.aborted) throw searchAborted(signal);
2095
+ let credentials = await this.resolveCredentials(signal);
2096
+ if (signal?.aborted) throw searchAborted(signal);
2097
+ const first = await this.attempt(request, credentials, signal);
2098
+ if (first.result !== void 0) return first.result;
2099
+ if (first.retryable && this.refreshCredentials !== void 0) {
2100
+ this.logger?.debug?.("[web-search] refreshing credentials before retry", {
2101
+ region: this.region,
2102
+ reason: first.reason
2103
+ });
2104
+ try {
2105
+ credentials = await this.refreshCredentials(signal);
2106
+ } catch (error) {
2107
+ if (signal?.aborted) throw searchAborted(signal, error);
2108
+ throw new WebError(`Qoder credential refresh failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
2109
+ }
2110
+ const second = await this.attempt(request, credentials, signal);
2111
+ if (second.result !== void 0) return second.result;
2112
+ throw new WebError(`Qoder web search failed: ${second.reason}`, "WEB_PROVIDER_ERROR");
2113
+ }
2114
+ throw new WebError(`Qoder web search failed: ${first.reason}`, "WEB_PROVIDER_ERROR");
2115
+ }
2116
+ async attempt(request, credentials, signal) {
2117
+ const url = getQoderWebSearchUrl(this.region);
2118
+ const innerPayload = {
2119
+ query: request.query,
2120
+ timeRange: "NoLimit",
2121
+ contents: {
2122
+ mainText: false,
2123
+ markdownText: false,
2124
+ summary: false
2125
+ }
2126
+ };
2127
+ const encodedBody = qoderEncodeBody(JSON.stringify({
2128
+ payload: JSON.stringify(innerPayload),
2129
+ encodeVersion: "1"
2130
+ }));
2131
+ const timeout = AbortSignal.timeout(this.timeoutMs);
2132
+ const requestSignal = signal !== void 0 ? AbortSignal.any([signal, timeout]) : timeout;
2133
+ const startedAt = this.now();
2134
+ this.logger?.debug?.("[web-search] started", {
2135
+ region: this.region,
2136
+ url,
2137
+ query: request.query
2138
+ });
2139
+ let response;
2140
+ try {
2141
+ const authHeaders = buildAuthHeaders(encodedBody, url, credentials);
2142
+ response = await this.fetchImpl(url, {
2143
+ method: "POST",
2144
+ headers: {
2145
+ ...authHeaders,
2146
+ "Content-Type": "application/json",
2147
+ "Accept": "application/json",
2148
+ "Accept-Encoding": "identity"
2149
+ },
2150
+ body: encodedBody,
2151
+ signal: requestSignal
2152
+ });
2153
+ } catch (error) {
2154
+ if (signal?.aborted) throw searchAborted(signal, error);
2155
+ if (timeout.aborted || isAbortError(error)) return {
2156
+ retryable: false,
2157
+ reason: "request timed out"
2158
+ };
2159
+ this.logger?.warn?.("[web-search] network failure", { region: this.region }, redactLogValue(error));
2160
+ return {
2161
+ retryable: false,
2162
+ reason: `network failure: ${String(error)}`
2163
+ };
2164
+ }
2165
+ const elapsedMs = this.now() - startedAt;
2166
+ this.logger?.debug?.("[web-search] response received", {
2167
+ status: response.status,
2168
+ elapsedMs
2169
+ });
2170
+ if (!response.ok) return {
2171
+ retryable: response.status === 401 || response.status === 403,
2172
+ reason: `HTTP ${response.status} ${response.statusText}`.trim()
2173
+ };
2174
+ let text;
2175
+ try {
2176
+ text = await readLimitedText(response, defaultMaxJsonBytes, "Qoder web search response");
2177
+ } catch (error) {
2178
+ if (signal?.aborted) throw searchAborted(signal, error);
2179
+ return {
2180
+ retryable: false,
2181
+ reason: `failed to read response body: ${String(error)}`
2182
+ };
2183
+ }
2184
+ let parsed;
2185
+ try {
2186
+ parsed = JSON.parse(text);
2187
+ } catch (error) {
2188
+ return {
2189
+ retryable: false,
2190
+ reason: `invalid JSON response: ${String(error)}`
2191
+ };
2192
+ }
2193
+ if (parsed.errorCode != null && parsed.errorCode !== 0) return {
2194
+ retryable: false,
2195
+ reason: `backend error code ${parsed.errorCode}: ${parsed.errorMsg ?? "unknown"}`
2196
+ };
2197
+ const rawItems = parsed.pageItems ?? [];
2198
+ const sources = [];
2199
+ const seen = /* @__PURE__ */ new Set();
2200
+ for (const item of rawItems) {
2201
+ if (!item.link || item.link.trim().length === 0) continue;
2202
+ const link = item.link.trim();
2203
+ if (seen.has(link)) continue;
2204
+ seen.add(link);
2205
+ sources.push({
2206
+ url: link,
2207
+ ...item.title !== void 0 && item.title.trim().length > 0 ? { title: item.title.trim() } : {},
2208
+ ...item.snippet !== void 0 && item.snippet.trim().length > 0 ? { snippet: item.snippet.trim() } : {},
2209
+ ...item.publishedAt !== void 0 && item.publishedAt.trim().length > 0 ? { publishedAt: item.publishedAt.trim() } : {}
2210
+ });
2211
+ }
2212
+ this.logger?.debug?.("[web-search] succeeded", {
2213
+ region: this.region,
2214
+ resultsCount: sources.length,
2215
+ elapsedMs
2216
+ });
2217
+ return {
2218
+ result: {
2219
+ sources,
2220
+ truncated: false
2221
+ },
2222
+ retryable: false,
2223
+ reason: ""
2224
+ };
2225
+ }
2226
+ };
1662
2227
  //#endregion
1663
2228
  //#region src/qoder/transport/wire/thinking.ts
1664
2229
  const thinkingTags = [{
@@ -1823,6 +2388,30 @@ const doneMarker = "[DONE]";
1823
2388
  function malformed(message) {
1824
2389
  return new QoderLlmError(message, "MALFORMED_RESPONSE");
1825
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
+ }
1826
2415
  function parseEnvelope(rawData) {
1827
2416
  let value;
1828
2417
  try {
@@ -1834,7 +2423,13 @@ function parseEnvelope(rawData) {
1834
2423
  const envelope = value;
1835
2424
  if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
1836
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.");
1837
- 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
+ }
1838
2433
  if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
1839
2434
  return envelope;
1840
2435
  }
@@ -1866,10 +2461,18 @@ function finishKind(value) {
1866
2461
  if (value === void 0 || value === null || value === "") return void 0;
1867
2462
  if (value === "stop") return "stop";
1868
2463
  if (value === "length") return "max-tokens";
1869
- if (value === "tool_calls" || value === "toolUse") return "tool-calls";
2464
+ if (value === "tool_calls" || value === "toolUse" || value === "function_call") return "tool-calls";
1870
2465
  if (value === "content_filter") throw new QoderLlmError("Qoder blocked the response through its content filter.", "PROVIDER_ERROR");
1871
2466
  throw malformed(`Qoder returned unknown finish reason "${value}".`);
1872
2467
  }
2468
+ /** Whether a choice delta streams content, reasoning, or tool-call data. Control-only frames do not. */
2469
+ function carriesStreamDelta(delta) {
2470
+ if (delta == null) return false;
2471
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true;
2472
+ if (typeof delta.content === "string" && delta.content) return true;
2473
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return true;
2474
+ return false;
2475
+ }
1873
2476
  async function* parseQoderSse(stream, options = {}) {
1874
2477
  const reader = stream.getReader();
1875
2478
  const decoder = new TextDecoder();
@@ -1882,6 +2485,7 @@ async function* parseQoderSse(stream, options = {}) {
1882
2485
  let activeTextual;
1883
2486
  let pendingUsage;
1884
2487
  let terminalKind = "stop";
2488
+ let sawFinishReason = false;
1885
2489
  let sawContent = false;
1886
2490
  const maxBufferChars = options.maxBufferChars ?? 2097152;
1887
2491
  const closeTextual = () => {
@@ -1976,7 +2580,8 @@ async function* parseQoderSse(stream, options = {}) {
1976
2580
  sawDone = true;
1977
2581
  break;
1978
2582
  }
1979
- const body = parseEnvelope(rawData).body?.trim();
2583
+ const envelope = parseEnvelope(rawData);
2584
+ const body = typeof envelope.body === "string" ? envelope.body.trim() : "";
1980
2585
  if (!body) continue;
1981
2586
  if (body === doneMarker) {
1982
2587
  sawDone = true;
@@ -1985,7 +2590,11 @@ async function* parseQoderSse(stream, options = {}) {
1985
2590
  const innerChunk = parseInner(body);
1986
2591
  pendingUsage = tokenUsage(innerChunk) ?? pendingUsage;
1987
2592
  for (const choice of innerChunk.choices ?? []) {
1988
- terminalKind = finishKind(choice.finish_reason) ?? terminalKind;
2593
+ const kind = finishKind(choice.finish_reason);
2594
+ if (kind !== void 0) {
2595
+ terminalKind = kind;
2596
+ sawFinishReason = true;
2597
+ } else if (carriesStreamDelta(choice.delta)) sawFinishReason = false;
1989
2598
  const delta = choice.delta;
1990
2599
  if (!delta) continue;
1991
2600
  if (typeof delta.reasoning_content === "string" && delta.reasoning_content) for (const segment of thinkingParser.pushReasoning(delta.reasoning_content)) for (const chunk of appendSegment(segment)) yield chunk;
@@ -2055,7 +2664,7 @@ async function* parseQoderSse(stream, options = {}) {
2055
2664
  await reader.cancel().catch(() => void 0);
2056
2665
  reader.releaseLock();
2057
2666
  }
2058
- if (!sawDone) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
2667
+ if (!sawDone && !sawFinishReason) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
2059
2668
  for (const segment of thinkingParser.finish()) for (const chunk of appendSegment(segment)) yield chunk;
2060
2669
  for (const chunk of closeTextual()) yield chunk;
2061
2670
  for (const [, state] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
@@ -2123,7 +2732,10 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2123
2732
  let headerTimedOut = false;
2124
2733
  let idleTimedOut = false;
2125
2734
  let idleTimer;
2735
+ let chunkCount = 0;
2736
+ let reqId;
2126
2737
  const startedAt = performance.now();
2738
+ let lastActivityAt = startedAt;
2127
2739
  const onCallerAbort = () => requestController.abort(options.signal?.reason);
2128
2740
  options.signal?.addEventListener("abort", onCallerAbort, { once: true });
2129
2741
  const headerTimer = setTimeout(() => {
@@ -2131,6 +2743,7 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2131
2743
  requestController.abort("response header timeout");
2132
2744
  }, dependencies.responseHeaderTimeoutMs);
2133
2745
  const resetIdleTimer = () => {
2746
+ lastActivityAt = performance.now();
2134
2747
  if (idleTimer !== void 0) clearTimeout(idleTimer);
2135
2748
  idleTimer = setTimeout(() => {
2136
2749
  idleTimedOut = true;
@@ -2154,34 +2767,100 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2154
2767
  signal: requestController.signal
2155
2768
  });
2156
2769
  clearTimeout(headerTimer);
2770
+ reqId = qoderRequestId(response.headers);
2157
2771
  dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
2158
2772
  region: dependencies.region,
2159
2773
  status: response.status,
2160
2774
  durationMs: Math.round(performance.now() - startedAt),
2161
- ...qoderRequestId(response.headers) === void 0 ? {} : { requestId: qoderRequestId(response.headers) }
2775
+ ...reqId === void 0 ? {} : { requestId: reqId }
2162
2776
  });
2163
2777
  resetIdleTimer();
2164
- 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
+ }
2165
2800
  if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
2166
- yield* parseQoderSse(response.body, { onActivity: resetIdleTimer });
2801
+ let firstChunkDurationMs;
2802
+ let tokenUsage;
2803
+ let finishReason;
2804
+ const streamStartedAt = performance.now();
2805
+ for await (const chunk of parseQoderSse(response.body, { onActivity: resetIdleTimer })) {
2806
+ chunkCount++;
2807
+ if (firstChunkDurationMs === void 0) {
2808
+ firstChunkDurationMs = Math.round(performance.now() - startedAt);
2809
+ dependencies.logger?.debug?.("[Qoder Stream] First chunk received", {
2810
+ durationMs: firstChunkDurationMs,
2811
+ ...reqId === void 0 ? {} : { requestId: reqId }
2812
+ });
2813
+ }
2814
+ if (chunk.type === "usage") tokenUsage = chunk.usage;
2815
+ if (chunk.type === "finish") finishReason = chunk.reason.kind;
2816
+ yield chunk;
2817
+ }
2818
+ dependencies.logger?.debug?.("[Qoder Stream] Stream completed", {
2819
+ durationMs: Math.round(performance.now() - startedAt),
2820
+ streamDurationMs: Math.round(performance.now() - streamStartedAt),
2821
+ chunkCount,
2822
+ ...finishReason === void 0 ? {} : { finishReason },
2823
+ ...tokenUsage === void 0 ? {} : { usage: tokenUsage },
2824
+ ...reqId === void 0 ? {} : { requestId: reqId }
2825
+ });
2167
2826
  } catch (error) {
2168
2827
  if (options.signal?.aborted) throw aborted$1("Request was aborted.");
2828
+ const elapsedMs = Math.round(performance.now() - startedAt);
2169
2829
  if (headerTimedOut) {
2170
2830
  const failure = new QoderLlmError("Qoder model request exceeded its response header timeout.", "TIMEOUT");
2171
- dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure));
2831
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2832
+ phase: "header",
2833
+ elapsedMs,
2834
+ timeoutMs: dependencies.responseHeaderTimeoutMs
2835
+ });
2172
2836
  throw failure;
2173
2837
  }
2174
2838
  if (idleTimedOut) {
2175
2839
  const failure = new QoderLlmError("Qoder model stream exceeded its idle timeout.", "TIMEOUT");
2176
- dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure));
2840
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2841
+ phase: "stream-idle",
2842
+ chunkCount,
2843
+ idleDurationMs: Math.round(performance.now() - lastActivityAt),
2844
+ elapsedMs,
2845
+ ...reqId === void 0 ? {} : { requestId: reqId }
2846
+ });
2177
2847
  throw failure;
2178
2848
  }
2179
2849
  if (error instanceof QoderLlmError) {
2180
- dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error));
2850
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error), {
2851
+ chunkCount,
2852
+ elapsedMs,
2853
+ ...reqId === void 0 ? {} : { requestId: reqId }
2854
+ });
2181
2855
  throw error;
2182
2856
  }
2183
2857
  const failure = new QoderLlmError("Qoder transport request failed.", "TRANSPORT", { cause: error });
2184
- dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), { cause: redactLogValue(error) });
2858
+ dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
2859
+ chunkCount,
2860
+ elapsedMs,
2861
+ ...reqId === void 0 ? {} : { requestId: reqId },
2862
+ cause: redactLogValue(error)
2863
+ });
2185
2864
  throw failure;
2186
2865
  } finally {
2187
2866
  clearTimeout(headerTimer);
@@ -2209,6 +2888,7 @@ var DefaultQoderTransport = class {
2209
2888
  modelFlights = new SingleFlight();
2210
2889
  attachments;
2211
2890
  imageUploader;
2891
+ searchClient;
2212
2892
  preserveThinking;
2213
2893
  constructor(options) {
2214
2894
  this.region = options.region;
@@ -2216,7 +2896,7 @@ var DefaultQoderTransport = class {
2216
2896
  this.fetchImpl = options.fetch ?? globalThis.fetch;
2217
2897
  this.logger = options.logger;
2218
2898
  this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 3e5;
2219
- this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ?? 3e4;
2899
+ this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ?? 6e4;
2220
2900
  this.metadataTimeoutMs = options.metadataTimeoutMs;
2221
2901
  this.attachments = options.attachments;
2222
2902
  this.preserveThinking = options.preserveThinking;
@@ -2245,6 +2925,20 @@ var DefaultQoderTransport = class {
2245
2925
  return this.auth.getCredentials(pat, signal);
2246
2926
  }
2247
2927
  });
2928
+ this.searchClient = new QoderSearchClient({
2929
+ fetch: this.fetchImpl,
2930
+ logger: this.logger,
2931
+ region: this.region,
2932
+ resolveCredentials: async (signal) => {
2933
+ const pat = await this.requirePat(signal);
2934
+ return this.auth.getCredentials(pat, signal);
2935
+ },
2936
+ refreshCredentials: async (signal) => {
2937
+ const pat = await this.requirePat(signal);
2938
+ this.auth.clear(pat);
2939
+ return this.auth.getCredentials(pat, signal);
2940
+ }
2941
+ });
2248
2942
  }
2249
2943
  stream(options, model) {
2250
2944
  return this.generate(options, model);
@@ -2270,6 +2964,9 @@ var DefaultQoderTransport = class {
2270
2964
  signal: options?.signal
2271
2965
  });
2272
2966
  }
2967
+ async searchWeb(request, signal) {
2968
+ return this.searchClient.search(request, signal);
2969
+ }
2273
2970
  async requirePat(signal) {
2274
2971
  if (signal?.aborted) throw aborted("Qoder request was aborted.");
2275
2972
  const pat = (await this.resolvePat()).trim();
@@ -2333,8 +3030,18 @@ const Config = z.object({
2333
3030
  modelsByRegion: modelsByRegionSchema.default({}),
2334
3031
  models: z.array(catalogModel),
2335
3032
  streamIdleTimeoutMs: z.number().step(1).min(1).default(defaultStreamIdleTimeoutMs),
2336
- preserveThinking: z.boolean().default(true)
3033
+ responseHeaderTimeoutMs: z.number().step(1).min(1).default(defaultResponseHeaderTimeoutMs),
3034
+ preserveThinking: z.boolean().default(true),
3035
+ webSearchMode: z.union([
3036
+ "auto",
3037
+ "always",
3038
+ "disabled"
3039
+ ]).default("auto")
2337
3040
  });
3041
+ const PluginConfig = Config.volatile();
3042
+ function readConfig(value) {
3043
+ return "get" in value ? Config(structuredClone(value.get())) : value;
3044
+ }
2338
3045
  function resolveModels(models) {
2339
3046
  const resolved = models ?? defaultModels;
2340
3047
  if (resolved.length === 0) throw new Error("provider-qoder: at least one model is required");
@@ -2353,6 +3060,10 @@ function modelsFor(config, region, legacyModelsRegion = config.region ?? "global
2353
3060
  return resolveModels(defaultModels);
2354
3061
  }
2355
3062
  //#endregion
3063
+ //#region src/dsh/provider.ts
3064
+ /** DSH model provider route, matching the plugin package name. */
3065
+ const QODER_PROVIDER_ID = "dsh-provider-qoder";
3066
+ //#endregion
2356
3067
  //#region src/dsh/adapter.ts
2357
3068
  /** Thin DSH adapter over the Qoder transport seam. */
2358
3069
  function modelInfo(provider, model) {
@@ -2369,12 +3080,15 @@ var QoderAdapter = class extends LlmAdapter {
2369
3080
  catalogModels;
2370
3081
  providerId;
2371
3082
  providerName;
3083
+ onModelsDiscovered;
3084
+ discoveries = /* @__PURE__ */ new WeakMap();
2372
3085
  constructor(options) {
2373
3086
  super();
2374
3087
  this.resolveTransport = options.resolveTransport;
2375
3088
  this.catalogModels = options.models && options.models.length > 0 ? options.models : defaultModels;
2376
- this.providerId = options.providerId ?? "qoder-official";
3089
+ this.providerId = options.providerId ?? "dsh-provider-qoder";
2377
3090
  this.providerName = options.providerName ?? "Qoder";
3091
+ this.onModelsDiscovered = options.onModelsDiscovered;
2378
3092
  }
2379
3093
  providerInfo(provider) {
2380
3094
  return {
@@ -2382,15 +3096,55 @@ var QoderAdapter = class extends LlmAdapter {
2382
3096
  name: this.providerName
2383
3097
  };
2384
3098
  }
2385
- listModels(provider) {
2386
- return Promise.resolve(this.catalogModels.map((model) => modelInfo(provider, model)));
3099
+ /** Refresh advertised metadata on catalog reads, sharing a five-minute transport-local cache. */
3100
+ async listModels(provider) {
3101
+ const transport = this.resolveTransport();
3102
+ let cached = this.discoveries.get(transport);
3103
+ if (cached === void 0) {
3104
+ cached = { expiresAt: 0 };
3105
+ this.discoveries.set(transport, cached);
3106
+ }
3107
+ const entry = cached;
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;
3110
+ entry.models = models;
3111
+ entry.expiresAt = Date.now() + 300 * 1e3;
3112
+ this.publishDiscoveredModels(transport, models);
3113
+ }).catch(() => {}).finally(() => {
3114
+ entry.inflight = void 0;
3115
+ });
3116
+ await entry.inflight;
3117
+ if (this.resolveTransport() !== transport) return this.listModels(provider);
3118
+ return this.effectiveModels().map((model) => modelInfo(provider, model));
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
+ }
3132
+ effectiveModels() {
3133
+ return mergeQoderDiscoveryMetadata(this.catalogModels, this.discoveries.get(this.resolveTransport())?.models ?? []);
2387
3134
  }
2388
3135
  replaceModels(models) {
2389
3136
  this.catalogModels = models;
2390
3137
  }
3138
+ /** Publish an explicit discovery without letting older in-flight reads overwrite it. */
3139
+ updateDiscoveredModels(transport, models) {
3140
+ this.discoveries.set(transport, {
3141
+ models,
3142
+ expiresAt: Date.now() + 300 * 1e3
3143
+ });
3144
+ }
2391
3145
  resolveModel(provider, modelId, signal) {
2392
3146
  if (signal?.aborted) return Promise.reject(new QoderLlmError("Qoder model resolution was aborted.", "ABORTED"));
2393
- const configured = this.catalogModels.find((model) => model.id === modelId);
3147
+ const configured = this.effectiveModels().find((model) => model.id === modelId);
2394
3148
  if (configured === void 0) return Promise.resolve({
2395
3149
  provider,
2396
3150
  id: modelId,
@@ -2413,11 +3167,64 @@ var QoderAdapter = class extends LlmAdapter {
2413
3167
  }
2414
3168
  stream(options) {
2415
3169
  if (options.provider !== this.providerId) throw new QoderLlmError(`Qoder adapter does not own provider "${options.provider}".`, "INVALID_PROVIDER");
2416
- const model = this.catalogModels.find((candidate) => candidate.id === (options.model || "cmodel"));
3170
+ const model = this.effectiveModels().find((candidate) => candidate.id === (options.model || "cmodel"));
2417
3171
  return this.resolveTransport().stream(options, model);
2418
3172
  }
2419
3173
  };
2420
3174
  //#endregion
3175
+ //#region src/dsh/search-provider.ts
3176
+ /**
3177
+ * DSH WebSearchProvider implementation with initiator-aware dynamic routing.
3178
+ *
3179
+ * Implements the `@deepseek-ai/dsh-web` capability seam, routing search queries
3180
+ * to Qoder Center when an agent runs with a Qoder model (`dsh-provider-qoder`),
3181
+ * or delegating to a fallback search provider when another model is active.
3182
+ *
3183
+ * @module dsh-provider-qoder/dsh/search-provider
3184
+ */
3185
+ const QODER_SEARCH_PROVIDER_ID = "qoder";
3186
+ const QODER_MODEL_PROVIDER_ROUTE = QODER_PROVIDER_ID;
3187
+ var QoderSearchProvider = class {
3188
+ id = QODER_SEARCH_PROVIDER_ID;
3189
+ ctx;
3190
+ resolveTransport;
3191
+ searchClient;
3192
+ getWebSearchMode;
3193
+ fallbackProvider;
3194
+ constructor(options) {
3195
+ this.ctx = options.ctx;
3196
+ this.resolveTransport = options.resolveTransport;
3197
+ this.searchClient = options.searchClient;
3198
+ this.getWebSearchMode = options.getWebSearchMode;
3199
+ this.fallbackProvider = options.fallbackProvider;
3200
+ }
3201
+ available() {
3202
+ return this.getWebSearchMode() !== "disabled";
3203
+ }
3204
+ async search(request, signal) {
3205
+ const mode = this.getWebSearchMode();
3206
+ if (mode === "disabled") throw new WebError("Qoder web search is disabled in settings.", "WEB_PROVIDER_UNAVAILABLE");
3207
+ const agent = (this.ctx.get("agents") ?? this.ctx.agents)?.currentInitiator?.();
3208
+ const defaultModelService = this.ctx.get("agentDefaultModel");
3209
+ const providerRoute = agent?.options?.provider ?? defaultModelService?.get?.()?.provider;
3210
+ if (mode === "always" || providerRoute === QODER_MODEL_PROVIDER_ROUTE) {
3211
+ if (this.resolveTransport !== void 0) return this.resolveTransport().searchWeb(request, signal);
3212
+ if (this.searchClient !== void 0) return this.searchClient.search(request, signal);
3213
+ throw new WebError("No Qoder transport configured for web search.", "WEB_PROVIDER_ERROR");
3214
+ }
3215
+ const fallback = this.resolveFallback();
3216
+ if (fallback && fallback.available()) return fallback.search(request, signal);
3217
+ throw new WebError(`Current model provider is "${providerRoute ?? "unknown"}" rather than "${QODER_MODEL_PROVIDER_ROUTE}", and no fallback web search provider is available. Switch to a Qoder model or configure an ambient search provider.`, "WEB_PROVIDER_UNAVAILABLE");
3218
+ }
3219
+ resolveFallback() {
3220
+ if (this.fallbackProvider && this.fallbackProvider.available()) return this.fallbackProvider;
3221
+ const providers = this.ctx.get("web")?.searchProviders;
3222
+ if (providers && typeof providers.entries === "function") {
3223
+ for (const [id, provider] of providers.entries()) if (id !== "qoder" && provider.available()) return provider;
3224
+ }
3225
+ }
3226
+ };
3227
+ //#endregion
2421
3228
  //#region src/dsh/credential-contract.ts
2422
3229
  /** Shared DSH host/browser contract for the one managed Qoder credential. */
2423
3230
  const qoderCredentialRef = "QODER_MANAGED_CREDENTIAL";
@@ -2436,6 +3243,116 @@ async function resolveManagedQoderPat(credentials) {
2436
3243
  return hit.value.trim();
2437
3244
  }
2438
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
+ }
3278
+ /**
3279
+ * Absolute Fetch-route prefix. Connection mounts its shared, authenticated API
3280
+ * channel at `/api`, and every exact Fetch route lives directly below it.
3281
+ */
3282
+ const qoderRpcApiPath = `/api/qoder-subscription`;
3283
+ /** Endpoints the account card and the model catalog call. */
3284
+ const qoderRpcEndpoints = ["account", "models"];
3285
+ /** Absolute Fetch-route path of one endpoint. */
3286
+ function qoderRpcPath(endpoint) {
3287
+ return `${qoderRpcApiPath}/${endpoint}`;
3288
+ }
3289
+ /** Type guard for an untrusted endpoint name. */
3290
+ function isQoderRpcEndpoint(value) {
3291
+ return typeof value === "string" && qoderRpcEndpoints.includes(value);
3292
+ }
3293
+ //#endregion
3294
+ //#region src/dsh/rpc.ts
3295
+ /**
3296
+ * Mount every Qoder settings endpoint on the shared API channel.
3297
+ *
3298
+ * Registering is caller-owned: the returned disposer removes the routes again,
3299
+ * so the caller can tie them to its fiber with `ctx.effect`.
3300
+ *
3301
+ * @param ctx - host context carrying the Connection service.
3302
+ * @param handler - decoded endpoint handler, shared by every endpoint.
3303
+ * @returns disposer removing every registered route.
3304
+ * @throws when Connection exposes no exact Fetch registry.
3305
+ */
3306
+ function registerQoderRpc(ctx, handler) {
3307
+ const registry = ctx.connection?.fetch;
3308
+ if (typeof registry?.register !== "function") throw new Error("provider-qoder: connection exposes no exact Fetch route registry");
3309
+ const disposers = qoderRpcEndpoints.map((endpoint) => registry.register({
3310
+ path: qoderRpcPath(endpoint),
3311
+ methods: ["POST"],
3312
+ requestBody: "buffered",
3313
+ fetch: (request) => handleQoderRpcRequest(endpoint, handler, request)
3314
+ }));
3315
+ return () => {
3316
+ for (const dispose of disposers) if (typeof dispose === "function") dispose();
3317
+ };
3318
+ }
3319
+ /**
3320
+ * Decode one request, run the endpoint handler, and encode its result.
3321
+ *
3322
+ * Failures stay inside the response envelope so the client keeps a single error
3323
+ * path; only a malformed body and a handler crash use a transport status.
3324
+ */
3325
+ async function handleQoderRpcRequest(endpoint, handler, request) {
3326
+ if (request.signal.aborted) return Response.json({
3327
+ ok: false,
3328
+ error: {
3329
+ code: "ABORTED",
3330
+ message: "Request aborted",
3331
+ details: { issues: [] }
3332
+ }
3333
+ });
3334
+ let payload = {};
3335
+ const body = await request.text();
3336
+ if (body.trim().length > 0) try {
3337
+ payload = JSON.parse(body);
3338
+ } catch {
3339
+ return new Response("body is not JSON", { status: 400 });
3340
+ }
3341
+ try {
3342
+ return Response.json(await handler(endpoint, payload, request.signal));
3343
+ } catch (error) {
3344
+ if (request.signal.aborted) return Response.json({
3345
+ ok: false,
3346
+ error: {
3347
+ code: "ABORTED",
3348
+ message: "Request aborted",
3349
+ details: { issues: [] }
3350
+ }
3351
+ });
3352
+ return new Response(`handler failure: ${String(error)}`, { status: 500 });
3353
+ }
3354
+ }
3355
+ //#endregion
2439
3356
  //#region src/dsh/plugin.ts
2440
3357
  const name = "provider-qoder";
2441
3358
  const inject = [
@@ -2444,9 +3361,8 @@ const inject = [
2444
3361
  "connection",
2445
3362
  "attachments"
2446
3363
  ];
2447
- const providerQoder = "qoder-official";
3364
+ const providerQoder = QODER_PROVIDER_ID;
2448
3365
  const settingsNamespace = "provider-qoder";
2449
- const qoderChannel = "/qoder-subscription";
2450
3366
  const fiberDisposed = 4;
2451
3367
  const fiberUnloading = 5;
2452
3368
  function logError(error) {
@@ -2457,17 +3373,18 @@ function logError(error) {
2457
3373
  ..."code" in error ? { code: error.code } : {}
2458
3374
  };
2459
3375
  }
2460
- function publicError(message) {
3376
+ function publicError(code, message, details = { issues: [] }) {
2461
3377
  return {
2462
3378
  ok: false,
2463
3379
  error: {
2464
- code: "internal",
3380
+ code,
2465
3381
  message,
2466
- details: { issues: [] }
3382
+ details
2467
3383
  }
2468
3384
  };
2469
3385
  }
2470
- function apply(ctx, config = {}) {
3386
+ function apply(ctx, input = {}) {
3387
+ const config = readConfig(input);
2471
3388
  const logger = ctx.logger;
2472
3389
  const initialRegion = config.region ?? "global";
2473
3390
  const hasConfiguredModels = config.models !== void 0 && config.models.length > 0;
@@ -2476,7 +3393,9 @@ function apply(ctx, config = {}) {
2476
3393
  modelsByRegion: { ...config.modelsByRegion },
2477
3394
  ...hasConfiguredModels ? { models: resolveModels(config.models) } : {},
2478
3395
  streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? 3e5,
2479
- preserveThinking: config.preserveThinking ?? true
3396
+ responseHeaderTimeoutMs: config.responseHeaderTimeoutMs ?? 6e4,
3397
+ preserveThinking: config.preserveThinking ?? true,
3398
+ webSearchMode: config.webSearchMode ?? "auto"
2480
3399
  };
2481
3400
  let current = () => baseConfig;
2482
3401
  let legacyModelsRegion = initialRegion;
@@ -2485,11 +3404,12 @@ function apply(ctx, config = {}) {
2485
3404
  global: [],
2486
3405
  china: []
2487
3406
  };
2488
- const createTransport = (region, streamIdleTimeoutMs, preserveThinking, resolvePat = () => resolveManagedQoderPat(ctx.credentials)) => createQoderTransport({
3407
+ const createTransport = (region, streamIdleTimeoutMs, responseHeaderTimeoutMs, preserveThinking, resolvePat = () => resolveManagedQoderPat(ctx.credentials)) => createQoderTransport({
2489
3408
  region,
2490
3409
  resolvePat,
2491
3410
  logger,
2492
3411
  streamIdleTimeoutMs,
3412
+ responseHeaderTimeoutMs,
2493
3413
  attachments: ctx.attachments,
2494
3414
  preserveThinking
2495
3415
  });
@@ -2500,30 +3420,43 @@ function apply(ctx, config = {}) {
2500
3420
  region,
2501
3421
  models: mergeQoderDiscoveryMetadata(modelsFor(value, region, legacyModelsRegion), discoveredCatalogs[region]),
2502
3422
  streamIdleTimeoutMs: value.streamIdleTimeoutMs ?? 3e5,
2503
- preserveThinking: value.preserveThinking ?? true
3423
+ responseHeaderTimeoutMs: value.responseHeaderTimeoutMs ?? 6e4,
3424
+ preserveThinking: value.preserveThinking ?? true,
3425
+ webSearchMode: value.webSearchMode ?? "auto"
2504
3426
  };
2505
3427
  };
2506
3428
  const initial = resolveConfig();
2507
- let activeTransport = createTransport(initial.region, initial.streamIdleTimeoutMs, initial.preserveThinking);
3429
+ let activeTransport = createTransport(initial.region, initial.streamIdleTimeoutMs, initial.responseHeaderTimeoutMs, initial.preserveThinking);
2508
3430
  let activeTransportConfig = {
2509
3431
  region: initial.region,
2510
3432
  streamIdleTimeoutMs: initial.streamIdleTimeoutMs,
3433
+ responseHeaderTimeoutMs: initial.responseHeaderTimeoutMs,
2511
3434
  preserveThinking: initial.preserveThinking
2512
3435
  };
2513
3436
  const adapter = new QoderAdapter({
2514
3437
  resolveTransport: () => activeTransport,
2515
3438
  models: initial.models,
2516
3439
  providerId: providerQoder,
2517
- 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
+ }
2518
3450
  });
2519
3451
  const registration = ctx.llm.registerAdapter([providerQoder], adapter);
2520
3452
  const refreshAdapter = () => {
2521
3453
  const next = resolveConfig();
2522
- if (next.region !== activeTransportConfig.region || next.streamIdleTimeoutMs !== activeTransportConfig.streamIdleTimeoutMs || next.preserveThinking !== activeTransportConfig.preserveThinking) {
2523
- activeTransport = createTransport(next.region, next.streamIdleTimeoutMs, next.preserveThinking);
3454
+ if (next.region !== activeTransportConfig.region || next.streamIdleTimeoutMs !== activeTransportConfig.streamIdleTimeoutMs || next.responseHeaderTimeoutMs !== activeTransportConfig.responseHeaderTimeoutMs || next.preserveThinking !== activeTransportConfig.preserveThinking) {
3455
+ activeTransport = createTransport(next.region, next.streamIdleTimeoutMs, next.responseHeaderTimeoutMs, next.preserveThinking);
2524
3456
  activeTransportConfig = {
2525
3457
  region: next.region,
2526
3458
  streamIdleTimeoutMs: next.streamIdleTimeoutMs,
3459
+ responseHeaderTimeoutMs: next.responseHeaderTimeoutMs,
2527
3460
  preserveThinking: next.preserveThinking
2528
3461
  };
2529
3462
  }
@@ -2531,7 +3464,7 @@ function apply(ctx, config = {}) {
2531
3464
  registration.replace([providerQoder]);
2532
3465
  };
2533
3466
  ctx.inject(["settings"], (settingsCtx) => {
2534
- const scope = settingsCtx.settings.register(settingsNamespace, Config, {
3467
+ const { scope, namespace } = bindQoderSettings(ctx, settingsCtx, input, {
2535
3468
  base: baseConfig,
2536
3469
  validate: (value) => {
2537
3470
  for (const region of Object.keys(value.modelsByRegion ?? {})) if (region !== "global" && region !== "china") throw new Error(`provider-qoder: unsupported model catalog region "${region}"`);
@@ -2541,34 +3474,39 @@ function apply(ctx, config = {}) {
2541
3474
  });
2542
3475
  legacyModelsRegion = scope.get().region ?? initialRegion;
2543
3476
  current = () => scope.get();
2544
- persistDiscoveredModels = async (region) => {
2545
- const value = scope.get();
2546
- const selected = modelsFor(value, region, legacyModelsRegion);
2547
- const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
2548
- if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
2549
- ...value.modelsByRegion,
2550
- [region]: enriched
2551
- } });
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
+ }
2552
3493
  };
3494
+ persistDiscoveredModels = persistCatalog;
2553
3495
  refreshAdapter();
2554
3496
  const loaded = scope.get();
2555
3497
  const loadedRegion = loaded.region ?? "global";
2556
- if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: {
2557
- ...loaded.modelsByRegion,
2558
- [loadedRegion]: resolveModels(loaded.models)
2559
- } }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
2560
- scope.watch(async (next) => {
2561
- 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;
2562
3501
  refreshAdapter();
2563
- const region = next.region ?? "global";
2564
- const selected = modelsFor(next, region, legacyModelsRegion);
2565
- const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
2566
- if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
2567
- ...next.modelsByRegion,
2568
- [region]: enriched
2569
- } });
3502
+ await persistCatalog(scope.get().region ?? "global");
2570
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
+ }
2571
3508
  settingsCtx.effect(() => () => {
3509
+ bindingActive = false;
2572
3510
  if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
2573
3511
  persistDiscoveredModels = async () => {};
2574
3512
  current = () => baseConfig;
@@ -2577,8 +3515,10 @@ function apply(ctx, config = {}) {
2577
3515
  });
2578
3516
  const discoverModels = async (signal, suppliedPat) => {
2579
3517
  const snapshot = resolveConfig();
3518
+ const snapshotTransport = activeTransport;
2580
3519
  const normalizedPat = suppliedPat?.trim();
2581
- const models = await (normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) : activeTransport).discoverModels(signal);
3520
+ const models = await (normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.responseHeaderTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) : snapshotTransport).discoverModels(signal);
3521
+ adapter.updateDiscoveredModels(snapshotTransport, models);
2582
3522
  discoveredCatalogs[snapshot.region] = models;
2583
3523
  refreshAdapter();
2584
3524
  await persistDiscoveredModels(snapshot.region);
@@ -2593,40 +3533,69 @@ function apply(ctx, config = {}) {
2593
3533
  maxTokens: model.maxTokens
2594
3534
  }));
2595
3535
  });
2596
- if (ctx.connection?.rpc) {
2597
- const handler = async (endpoint, payload, signal) => {
2598
- if (endpoint !== "account" && endpoint !== "models") return publicError(`Unknown endpoint: ${endpoint}`);
2599
- signal.throwIfAborted();
2600
- if (endpoint === "models") try {
2601
- return {
2602
- ok: true,
2603
- value: await discoverModels(signal)
2604
- };
2605
- } catch (error) {
2606
- if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED") throw error;
2607
- logger?.error?.("[Qoder RPC] Failed to discover models", logError(error));
2608
- return publicError(error instanceof Error ? error.message : "Failed to discover Qoder models");
2609
- }
2610
- const force = typeof payload === "object" && payload !== null && "force" in payload ? payload.force === true : false;
2611
- logger?.debug?.("[Qoder RPC] Reading subscriber account", { force });
3536
+ const handler = async (endpoint, payload, signal) => {
3537
+ if (!isQoderRpcEndpoint(endpoint)) return publicError("UNKNOWN_ENDPOINT", `Unknown endpoint: ${endpoint}`);
3538
+ if (signal.aborted) return publicError("ABORTED", "Request aborted");
3539
+ const executeRpc = async (operation, task) => {
2612
3540
  try {
2613
- const account = await activeTransport.readAccount({
2614
- force,
2615
- signal
2616
- });
2617
- logger?.debug?.("[Qoder RPC] Subscriber account resolved");
2618
3541
  return {
2619
3542
  ok: true,
2620
- value: account
3543
+ value: await task()
2621
3544
  };
2622
3545
  } catch (error) {
2623
- if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED") throw error;
2624
- logger?.error?.("[Qoder RPC] Failed to read subscriber account", logError(error));
2625
- return publicError(error instanceof Error ? error.message : "Failed to load Qoder account");
3546
+ if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED") {
3547
+ logger?.debug?.(`[Qoder RPC] ${operation} was aborted`);
3548
+ return publicError("ABORTED", "Request aborted");
3549
+ }
3550
+ let code = "INTERNAL";
3551
+ if (error instanceof QoderLlmError) if (error.code === "MISSING_CREDENTIAL" || error.code === "NO_CREDENTIALS") code = "NO_CREDENTIALS";
3552
+ else if (error.code === "AUTH") code = "UNAUTHENTICATED";
3553
+ else if (error.code === "TIMEOUT") code = "TIMEOUT";
3554
+ else code = "UPSTREAM_ERROR";
3555
+ logger?.error?.(`[Qoder RPC] Failed to ${operation}`, logError(error));
3556
+ return publicError(code, error instanceof Error ? error.message : `Failed to ${operation}`);
2626
3557
  }
2627
3558
  };
2628
- ctx.effect(() => ctx.connection.rpc.handle(qoderChannel, handler), "provider-qoder: loopback account RPC");
3559
+ if (endpoint === "models") return await executeRpc("discover Qoder models", () => discoverModels(signal));
3560
+ const force = typeof payload === "object" && payload !== null && "force" in payload ? payload.force === true : false;
3561
+ logger?.debug?.("[Qoder RPC] Reading subscriber account", { force });
3562
+ const outcome = await executeRpc("load Qoder account", () => activeTransport.readAccount({
3563
+ force,
3564
+ signal
3565
+ }));
3566
+ if (outcome.ok) logger?.debug?.("[Qoder RPC] Subscriber account resolved");
3567
+ return outcome;
3568
+ };
3569
+ try {
3570
+ ctx.effect(() => registerQoderRpc(ctx, handler), "provider-qoder: settings RPC routes");
3571
+ } catch (error) {
3572
+ logger?.error?.("[Qoder RPC] Failed to register the settings RPC routes", logError(error));
2629
3573
  }
3574
+ ctx.inject(["web"], (webCtx) => {
3575
+ const searchProvider = new QoderSearchProvider({
3576
+ ctx,
3577
+ resolveTransport: () => activeTransport,
3578
+ getWebSearchMode: () => resolveConfig().webSearchMode ?? "auto"
3579
+ });
3580
+ webCtx.web.registerSearchProvider(searchProvider);
3581
+ if (typeof webCtx.web.search === "function") {
3582
+ const originalSearch = webCtx.web.search.bind(webCtx.web);
3583
+ webCtx.effect(() => {
3584
+ webCtx.web.search = async (request, signal) => {
3585
+ const mode = resolveConfig().webSearchMode ?? "auto";
3586
+ if (mode === "disabled") return originalSearch(request, signal);
3587
+ const agent = (ctx.get("agents") ?? ctx.agents)?.currentInitiator?.();
3588
+ const defaultModelService = ctx.get("agentDefaultModel");
3589
+ const isQoderActive = (agent?.options?.provider ?? defaultModelService?.get?.()?.provider) === providerQoder;
3590
+ if (mode === "always" || isQoderActive) return searchProvider.search(request, signal);
3591
+ return originalSearch(request, signal);
3592
+ };
3593
+ return () => {
3594
+ webCtx.web.search = originalSearch;
3595
+ };
3596
+ }, "provider-qoder: transparent web search router");
3597
+ }
3598
+ });
2630
3599
  }
2631
3600
  //#endregion
2632
- export { Config, apply, inject, name };
3601
+ export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };