dsh-qoder-connect 0.1.9 → 0.2.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.
@@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
8
8
  import { EMPTY_RESPONSE_CODE, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage, createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
9
9
  import { homedir } from "node:os";
10
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
11
+ import { requestImageDimensions } from "@deepseek-ai/dsh-attachment";
10
12
  import { execFileSync } from "node:child_process";
11
13
  //#region src/paths.ts
12
14
  /**
@@ -853,6 +855,125 @@ async function probeModel(options) {
853
855
  };
854
856
  }
855
857
  //#endregion
858
+ //#region src/qoder/errors.ts
859
+ /**
860
+ * DSH-compatible LLM error representation.
861
+ *
862
+ * @module dsh-provider-qoder/qoder/errors
863
+ */
864
+ var QoderLlmError = class extends LlmError {
865
+ constructor(message, code = "UNKNOWN_ERROR", options) {
866
+ super(message, code, options);
867
+ }
868
+ };
869
+ /** The queue/limit markers Qoder puts in a rejection body (observed 2026-09-26). */
870
+ const QUEUE_BODY_MARKERS = [
871
+ /"code"\s*:\s*"?10605"?/u,
872
+ /"isQueued"\s*:\s*true/u,
873
+ /"queueCount"/u,
874
+ /"serviceAvailable"\s*:\s*false/u
875
+ ];
876
+ /**
877
+ * Whether an upstream rejection body is Qoder's **queue/limit** answer rather
878
+ * than an authorization failure.
879
+ *
880
+ * Qoder answers a saturated per-model queue (and a region whose service is not
881
+ * available) with an HTTP 401/403 — or a 200 SSE envelope carrying
882
+ * `statusCodeValue: 403` — whose body says `code: 10605`, `isQueued: true`,
883
+ * `queueCount`, `retryAfterSeconds` and sometimes `serviceAvailable: false`.
884
+ * Observed verbatim on 2026-09-26:
885
+ *
886
+ * {"code":"10605","message":"{\"isQueued\":true,\"modelKey\":\"qfmodel\",
887
+ * \"queueCount\":8887,\"queueType\":\"p3\",\"retryAfterSeconds\":30,
888
+ * \"serviceAvailable\":true,\"waitTime\":274}"}
889
+ *
890
+ * Reading that status as AUTH made the self-heal exchange fresh job tokens
891
+ * against a queue no token can jump, and — because the host retries
892
+ * `RATE_LIMIT` but deliberately not `AUTH` — it also suppressed the automatic
893
+ * retry that would have sailed through the 30-second window. The body is the
894
+ * only place the distinction is visible, so it gets the last say over status.
895
+ */
896
+ function qoderQueueSignal(body) {
897
+ if (body === void 0 || body === "") return void 0;
898
+ const text = body.replace(/\\/gu, "");
899
+ if (!QUEUE_BODY_MARKERS.some((marker) => marker.test(text))) return void 0;
900
+ const match = /"retryAfterSeconds"\s*:\s*(\d+)/u.exec(text);
901
+ return match === null ? {} : { retryAfterMs: Number(match[1]) * 1e3 };
902
+ }
903
+ /**
904
+ * Replace status digits that read as authorization failure with a neutral
905
+ * word, for messages the upstream used to announce a queue.
906
+ *
907
+ * `dsh-llm-pi-ai`'s error classifier is a text matcher whose first test is
908
+ * `/\b(?:401|403)\b/` — ahead of its 429/rate-limit test — so any message
909
+ * carrying those digits classifies AUTH no matter the structured code
910
+ * travelling beside it. A queue answer quoting the upstream's 403 (in the
911
+ * message prefix, the envelope status, or the body's `"code":"403"`) must not
912
+ * reach that matcher as-is, or the user sees "API 密钥无效" for a throttle
913
+ * (observed verbatim on 2026-09-26 23:30 and again at 23:40).
914
+ */
915
+ function deauthorizeDigits(message) {
916
+ return message.replace(/\b(?:401|403)\b/gu, "throttled");
917
+ }
918
+ function qoderHttpError(message, response, body) {
919
+ const { status } = response;
920
+ const headerRetryAfterMs = retryAfterMs(response.headers?.get("retry-after") ?? null);
921
+ const requestId = qoderRequestId(response.headers);
922
+ const queue = status === 401 || status === 403 || status === 429 ? qoderQueueSignal(body ?? message) : void 0;
923
+ if (queue !== void 0) {
924
+ const retryAfterMs = queue.retryAfterMs ?? headerRetryAfterMs;
925
+ return new QoderLlmError(deauthorizeDigits(message), "RATE_LIMIT", {
926
+ status,
927
+ ...retryAfterMs === void 0 ? {} : { providerRetryAfterMs: retryAfterMs },
928
+ ...requestId === void 0 ? {} : { requestId }
929
+ });
930
+ }
931
+ return new QoderLlmError(message, status === 401 || status === 403 ? "AUTH" : status === 408 ? "TIMEOUT" : status === 429 ? "RATE_LIMIT" : status >= 500 && status <= 599 ? "SERVER" : status >= 400 && status <= 499 ? "INVALID_REQUEST" : "PROVIDER_ERROR", {
932
+ status,
933
+ ...headerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs: headerRetryAfterMs },
934
+ ...requestId === void 0 ? {} : { requestId }
935
+ });
936
+ }
937
+ function qoderRequestId(headers) {
938
+ const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
939
+ return normalized ? ProviderRequestId(normalized) : void 0;
940
+ }
941
+ /**
942
+ * Whether this failure is an upstream authorization rejection worth one
943
+ * re-auth retry.
944
+ *
945
+ * The job token the transport signs requests with is cached in memory; an
946
+ * upstream that invalidates it mid-lifetime (gateway rotation or a fault
947
+ * window) answers HTTP 401/403 before any payload is produced. That state is
948
+ * distinguishable from a genuinely revoked PAT only by trying a fresh
949
+ * exchange, so callers clear their credential cache and retry once before
950
+ * reporting `AUTH` to the user.
951
+ *
952
+ * A `RATE_LIMIT` code is never a re-auth candidate, **even when its status is
953
+ * 401/403**: that is the upstream's queue answer (code 10605 / isQueued /
954
+ * retryAfterSeconds in the body), which keeps the status but says the
955
+ * credential was not the problem. Healing it exchanged job tokens against a
956
+ * queue no token can jump (observed 2026-09-26) and, by consuming the retry,
957
+ * suppressed the rate-limit retry that would have ridden out the window.
958
+ */
959
+ function isQoderAuthRejection(error) {
960
+ if (!(error instanceof LlmError)) return false;
961
+ if (error.code === "RATE_LIMIT") return false;
962
+ return error.code === "AUTH" || error.failure.status === 401 || error.failure.status === 403;
963
+ }
964
+ function retryAfterMs(value, nowMs = Date.now()) {
965
+ const normalized = value?.trim();
966
+ if (!normalized) return void 0;
967
+ if (/^\d+$/u.test(normalized)) {
968
+ const delayMs = Number(normalized) * 1e3;
969
+ return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : void 0;
970
+ }
971
+ const retryAt = Date.parse(normalized);
972
+ if (Number.isNaN(retryAt)) return void 0;
973
+ const delayMs = retryAt - nowMs;
974
+ return delayMs > 0 ? delayMs : void 0;
975
+ }
976
+ //#endregion
856
977
  //#region src/qoder/transport/endpoints.ts
857
978
  const qoderRegionEndpoints = {
858
979
  global: {
@@ -915,64 +1036,41 @@ function getQoderClaimCampaignUrl(region = "global", campaignId) {
915
1036
  return `${openApiUrl}/sash/api/v1/me/campaigns/${encodeURIComponent(campaignId)}/claim`;
916
1037
  }
917
1038
  //#endregion
918
- //#region src/qoder/errors.ts
1039
+ //#region src/qoder/transport/machine-id.ts
919
1040
  /**
920
- * DSH-compatible LLM error representation.
1041
+ * The machine id Qoder's own desktop CLI keeps at
1042
+ * `$HOME/.qoder/.auth/machine_id`.
921
1043
  *
922
- * @module dsh-provider-qoder/qoder/errors
1044
+ * Strictly a read source for this plugin: when the official client generated
1045
+ * one, the transport reuses it so the same device presents one stable
1046
+ * identity upstream. The plugin never writes there — that tree belongs to
1047
+ * the official CLI.
923
1048
  */
924
- var QoderLlmError = class extends LlmError {
925
- constructor(message, code = "UNKNOWN_ERROR", options) {
926
- super(message, code, options);
927
- }
928
- };
929
- function qoderHttpError(message, response) {
930
- const { status } = response;
931
- const code = status === 401 || status === 403 ? "AUTH" : status === 408 ? "TIMEOUT" : status === 429 ? "RATE_LIMIT" : status >= 500 && status <= 599 ? "SERVER" : status >= 400 && status <= 499 ? "INVALID_REQUEST" : "PROVIDER_ERROR";
932
- const providerRetryAfterMs = retryAfterMs(response.headers?.get("retry-after") ?? null);
933
- const requestId = qoderRequestId(response.headers);
934
- return new QoderLlmError(message, code, {
935
- status,
936
- ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
937
- ...requestId === void 0 ? {} : { requestId }
938
- });
939
- }
940
- function qoderRequestId(headers) {
941
- const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
942
- return normalized ? ProviderRequestId(normalized) : void 0;
1049
+ function officialMachineIdPath() {
1050
+ return join(homedir(), ".qoder", ".auth", "machine_id");
943
1051
  }
944
1052
  /**
945
- * Whether this failure is an upstream authorization rejection worth one
946
- * re-auth retry.
1053
+ * The trusted locations consulted when a caller nominates no chain: the
1054
+ * official Qoder CLI's id first, this plugin's own seed second.
947
1055
  *
948
- * The job token the transport signs requests with is cached in memory; an
949
- * upstream that invalidates it mid-lifetime (gateway rotation or a fault
950
- * window) answers HTTP 401/403 before any payload is produced. That state is
951
- * distinguishable from a genuinely revoked PAT only by trying a fresh
952
- * exchange, so callers clear their credential cache and retry once before
953
- * reporting `AUTH` to the user.
1056
+ * A freshly created id is saved ONLY at the end of the chain, which is now
1057
+ * always inside the plugin's data directory. An earlier default ended the
1058
+ * chain at `~/.dsh/qoder/machine_id`, derived from `os.homedir()` rather
1059
+ * than the resolved harness home, so any host with `DSH_HOME` pointed at
1060
+ * another location got a device credential scattered into the wrong `$HOME`
1061
+ * — outside every profile, with no owner deleting it. That fallback is
1062
+ * gone.
954
1063
  */
955
- function isQoderAuthRejection(error) {
956
- return error instanceof LlmError && (error.code === "AUTH" || error.failure.status === 401 || error.failure.status === 403);
957
- }
958
- function retryAfterMs(value, nowMs = Date.now()) {
959
- const normalized = value?.trim();
960
- if (!normalized) return void 0;
961
- if (/^\d+$/u.test(normalized)) {
962
- const delayMs = Number(normalized) * 1e3;
963
- return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : void 0;
964
- }
965
- const retryAt = Date.parse(normalized);
966
- if (Number.isNaN(retryAt)) return void 0;
967
- const delayMs = retryAt - nowMs;
968
- return delayMs > 0 ? delayMs : void 0;
969
- }
970
- //#endregion
971
- //#region src/qoder/transport/machine-id.ts
972
1064
  function defaultMachineIdPaths() {
973
- return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
1065
+ return [officialMachineIdPath(), qoderMachineIdPath()];
974
1066
  }
975
- /** Read Qoder's machine id or create the DSH-owned fallback. */
1067
+ /**
1068
+ * Read Qoder's machine id, or create this plugin's seed at the chain's end.
1069
+ *
1070
+ * `paths` is the read chain; its last entry doubles as the create point.
1071
+ * Passing no chain defaults to {@link defaultMachineIdPaths}; passing an
1072
+ * empty array nominates no save location, which keeps the id process-local.
1073
+ */
976
1074
  function getMachineId(paths = defaultMachineIdPaths()) {
977
1075
  for (const path of paths) {
978
1076
  if (!existsSync(path)) continue;
@@ -983,7 +1081,8 @@ function getMachineId(paths = defaultMachineIdPaths()) {
983
1081
  }
984
1082
  const machineId = crypto.randomUUID();
985
1083
  const savePath = paths.at(-1);
986
- if (savePath !== void 0) try {
1084
+ if (savePath === void 0) return machineId;
1085
+ try {
987
1086
  mkdirSync(dirname(savePath), { recursive: true });
988
1087
  writeFileSync(savePath, machineId, {
989
1088
  encoding: "utf8",
@@ -1196,22 +1295,34 @@ async function readLimitedText(response, maxBytes, label) {
1196
1295
  }
1197
1296
  if (!response.body) return "";
1198
1297
  const reader = response.body.getReader();
1199
- const decoder = new TextDecoder();
1298
+ const chunks = [];
1200
1299
  let bytes = 0;
1201
- let text = "";
1202
1300
  try {
1203
1301
  while (true) {
1204
1302
  const { done, value } = await reader.read();
1205
1303
  if (done) break;
1206
1304
  bytes += value.byteLength;
1207
1305
  if (bytes > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1208
- text += decoder.decode(value, { stream: true });
1306
+ chunks.push(value);
1209
1307
  }
1210
- return text + decoder.decode();
1211
1308
  } finally {
1212
1309
  await reader.cancel().catch(() => void 0);
1213
1310
  reader.releaseLock();
1214
1311
  }
1312
+ let payload = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
1313
+ const encoding = (response.headers.get("content-encoding") ?? "").toLowerCase();
1314
+ const looksGzipped = payload.length >= 2 && payload[0] === 31 && payload[1] === 139;
1315
+ if (encoding !== "" || looksGzipped) {
1316
+ try {
1317
+ if (encoding.includes("gzip") || encoding === "" && looksGzipped) payload = gunzipSync(payload);
1318
+ else if (encoding.includes("br")) payload = brotliDecompressSync(payload);
1319
+ else if (encoding.includes("deflate")) payload = inflateSync(payload);
1320
+ } catch {
1321
+ throw new QoderLlmError(`${label} arrived compressed but could not be decompressed.`, "MALFORMED_RESPONSE");
1322
+ }
1323
+ if (payload.byteLength > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1324
+ }
1325
+ return new TextDecoder().decode(payload);
1215
1326
  }
1216
1327
  var SingleFlight = class {
1217
1328
  flights = /* @__PURE__ */ new Map();
@@ -1264,6 +1375,7 @@ async function openApiJsonRequest(fetchImpl, options) {
1264
1375
  try {
1265
1376
  const headers = {
1266
1377
  accept: "application/json",
1378
+ "accept-encoding": "identity",
1267
1379
  "user-agent": options.userAgent ?? defaultUserAgent,
1268
1380
  "cosy-version": "1.0.1",
1269
1381
  "cosy-clienttype": "5",
@@ -1294,7 +1406,7 @@ async function openApiJsonRequest(fetchImpl, options) {
1294
1406
  const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, `Qoder ${options.operation} response`);
1295
1407
  if (!response.ok) {
1296
1408
  options.logger?.error?.(`[Qoder ${options.operation}] Request failed`, redactLogPayload(text));
1297
- throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response);
1409
+ throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response, text);
1298
1410
  }
1299
1411
  let data;
1300
1412
  try {
@@ -1332,6 +1444,14 @@ async function waitForFlight(promise, signal) {
1332
1444
  var QoderAuthService = class {
1333
1445
  cache = /* @__PURE__ */ new Map();
1334
1446
  inFlight = /* @__PURE__ */ new Map();
1447
+ /**
1448
+ * Self-heal exchanges in flight, keyed like the cache. Deliberately separate
1449
+ * from {@link inFlight}: that flight may be aborted by a departing
1450
+ * concurrent waiter (a quota poll, a catalog sweep) whose abort has nothing
1451
+ * to do with the heal, so healers must not share its controller. Concurrent
1452
+ * heals instead share this flight — see {@link exchangeFresh}.
1453
+ */
1454
+ healFlights = /* @__PURE__ */ new Map();
1335
1455
  fetchImpl;
1336
1456
  timeoutMs;
1337
1457
  resolveMachineId;
@@ -1354,10 +1474,31 @@ var QoderAuthService = class {
1354
1474
  * (the quota poll, the catalog sweep), and a retry that joins it would then
1355
1475
  * be cancelled by a path that has nothing to do with the chat. The result
1356
1476
  * replaces the cache entry.
1477
+ *
1478
+ * Two heals for the same credential that overlap share one exchange instead
1479
+ * of racing: each racer's `clear` deleted what the previous one had just
1480
+ * cached, and its own `cache.set` overwrote the other's — so the cache could
1481
+ * end up naming a token the losing request was no longer using, and with an
1482
+ * upstream that retires the previous job token on a new exchange the racers
1483
+ * also invalidated each other's credentials (one chat recovering while a
1484
+ * concurrent one failed, exactly the 2026-09-26 22:36 observation). The
1485
+ * shared exchange runs on its own timeout signal, so one healer's abort —
1486
+ * its chat request went away — cannot cancel an exchange other healers, and
1487
+ * the future `getCredentials` callers about to read the refreshed cache, are
1488
+ * waiting on.
1357
1489
  */
1358
1490
  async exchangeFresh(pat, signal) {
1491
+ const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
1492
+ const existing = this.healFlights.get(cacheKey);
1493
+ if (existing !== void 0 && !existing.settled) return waitForFlight(existing.promise, signal);
1359
1494
  this.clear(pat);
1360
- return await this.exchangeAndResolve(pat, signal ?? AbortSignal.timeout(this.timeoutMs));
1495
+ const created = { settled: false };
1496
+ created.promise = this.exchangeAndResolve(pat, AbortSignal.timeout(this.timeoutMs)).finally(() => {
1497
+ created.settled = true;
1498
+ if (this.healFlights.get(cacheKey) === created) this.healFlights.delete(cacheKey);
1499
+ });
1500
+ this.healFlights.set(cacheKey, created);
1501
+ return waitForFlight(created.promise, signal);
1361
1502
  }
1362
1503
  async getCredentials(pat, signal) {
1363
1504
  if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
@@ -1552,6 +1693,7 @@ async function fetchQoderModels(credentials, options = {}) {
1552
1693
  method: "GET",
1553
1694
  headers: {
1554
1695
  accept: "application/json",
1696
+ "accept-encoding": "identity",
1555
1697
  ...buildAuthHeaders(null, url, credentials)
1556
1698
  },
1557
1699
  signal: deadline.signal
@@ -1591,9 +1733,16 @@ async function fetchQoderModels(credentials, options = {}) {
1591
1733
  function unsupported(message) {
1592
1734
  return new QoderLlmError(message, "UNSUPPORTED_CONTENT");
1593
1735
  }
1594
- function toolResultText(block) {
1736
+ /**
1737
+ * Flatten one tool-result message's content to wire text.
1738
+ *
1739
+ * DSH 0.1.7 models a tool result as a first-class `role: 'tool'` message
1740
+ * (before 0.1.6 it was a `tool-result` block inside a user message); either
1741
+ * way the payload is a content array whose text/图像 blocks this flattens.
1742
+ */
1743
+ function toolResultText(content) {
1595
1744
  let text = "";
1596
- for (const nested of block.content) {
1745
+ for (const nested of content) {
1597
1746
  if (nested.type === "image") continue;
1598
1747
  if (nested.type !== "text") throw unsupported(`Qoder tool results support text only; received nested ${String(nested.type)} content.`);
1599
1748
  text += nested.text;
@@ -1604,16 +1753,19 @@ function toolResultText(block) {
1604
1753
  * Check message shapes without performing any provider I/O.
1605
1754
  *
1606
1755
  * Callers run this before resolving credentials so an invalid request never
1607
- * consumes a Qoder subscription.
1756
+ * consumes a Qoder subscription. The input is DSH's request shape — durable
1757
+ * messages and one-shot user inputs alike; only `role` and `content` are read.
1608
1758
  */
1609
1759
  function validateMessageShapes(messages) {
1610
1760
  for (const message of messages) {
1611
- const toolResults = message.content.filter((block) => block.type === "tool-result");
1612
- if (toolResults.length > 0) {
1613
- 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.");
1614
- for (const result of toolResults) toolResultText(result);
1761
+ if (message.role === "tool") {
1762
+ for (const block of message.content) {
1763
+ if (block.type === "text" || block.type === "image") continue;
1764
+ throw unsupported(`Qoder tool results support text and image content only; received ${String(block.type)}.`);
1765
+ }
1615
1766
  continue;
1616
1767
  }
1768
+ if (message.role === "developer") throw unsupported("Qoder transport does not support developer messages (dynamic tool addition/removal).");
1617
1769
  for (const block of message.content) {
1618
1770
  if (block.type === "text") continue;
1619
1771
  if (block.type === "image") {
@@ -1642,10 +1794,12 @@ async function resolveImagePart(block, context) {
1642
1794
  if (attachments === void 0) throw new QoderLlmError("Qoder image input requires the DSH attachment service.", "ATTACHMENT");
1643
1795
  let image;
1644
1796
  try {
1645
- const limits = attachments.imageLimits;
1797
+ const { width: sourceWidth, height: sourceHeight } = block.attachment;
1798
+ const projected = requestImageDimensions(sourceWidth, sourceHeight, attachments.imageLimits.maxImagePixels);
1646
1799
  image = await attachments.readImageRequest(block.attachment, {
1647
- maxPixels: limits.maxImagePixels,
1648
- maxBytes: limits.maxImageBytes
1800
+ width: projected.width,
1801
+ height: projected.height,
1802
+ maxBytes: attachments.imageLimits.maxImageBytes
1649
1803
  }, signal);
1650
1804
  } catch (error) {
1651
1805
  if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
@@ -1686,26 +1840,22 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1686
1840
  content: systemPrompt
1687
1841
  });
1688
1842
  for (const message of messages) {
1689
- const toolResults = message.content.filter((block) => block.type === "tool-result");
1690
- if (toolResults.length > 0) {
1691
- 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.");
1692
- for (const result of toolResults) {
1843
+ if (message.role === "tool") {
1844
+ output.push({
1845
+ role: "tool",
1846
+ tool_call_id: String(message.toolCallId),
1847
+ content: toolResultText(message.content)
1848
+ });
1849
+ const images = message.content.filter((block) => block.type === "image");
1850
+ if (images.length > 0) {
1851
+ if (attachments !== void 0) enforceImageLimits(images, attachments);
1693
1852
  output.push({
1694
- role: "tool",
1695
- tool_call_id: String(result.toolCallId),
1696
- content: toolResultText(result)
1853
+ role: "user",
1854
+ content: [{
1855
+ type: "text",
1856
+ text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1857
+ }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1697
1858
  });
1698
- const images = result.content.filter((block) => block.type === "image");
1699
- if (images.length > 0) {
1700
- if (attachments !== void 0) enforceImageLimits(images, attachments);
1701
- output.push({
1702
- role: "user",
1703
- content: [{
1704
- type: "text",
1705
- text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1706
- }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1707
- });
1708
- }
1709
1859
  }
1710
1860
  continue;
1711
1861
  }
@@ -1748,6 +1898,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1748
1898
  if (message.role === "assistant") reasoningText += block.text;
1749
1899
  continue;
1750
1900
  }
1901
+ throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
1751
1902
  }
1752
1903
  if (message.role === "assistant") {
1753
1904
  const hasReasoning = preserveThinking && reasoningText.length > 0;
@@ -1766,6 +1917,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1766
1917
  userContent[pending.slot] = await resolveImagePart(pending.block, context);
1767
1918
  }));
1768
1919
  }
1920
+ if (message.role === "developer") continue;
1769
1921
  output.push({
1770
1922
  role: message.role,
1771
1923
  content: hasImage ? userContent.filter((part) => part !== void 0) : text
@@ -2793,7 +2945,7 @@ function parseEnvelope(rawData) {
2793
2945
  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.");
2794
2946
  if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) {
2795
2947
  const status = envelope.statusCodeValue;
2796
- throw qoderHttpError(envelope.body ? `Qoder service returned upstream error status ${status}: ${envelope.body}` : `Qoder service returned upstream error status ${status}.`, { status });
2948
+ throw qoderHttpError(envelope.body ? `Qoder service returned upstream error status ${status}: ${envelope.body}` : `Qoder service returned upstream error status ${status}.`, { status }, envelope.body);
2797
2949
  }
2798
2950
  if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
2799
2951
  return envelope;
@@ -3128,7 +3280,15 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
3128
3280
  ...reqId === void 0 ? {} : { requestId: reqId }
3129
3281
  });
3130
3282
  resetIdleTimer();
3131
- if (!response.ok) throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response);
3283
+ if (!response.ok) {
3284
+ let errorBody;
3285
+ try {
3286
+ errorBody = await readLimitedText(response, defaultMaxErrorBytes, "Qoder chat error response");
3287
+ } catch {
3288
+ errorBody = void 0;
3289
+ }
3290
+ throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response, errorBody);
3291
+ }
3132
3292
  if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
3133
3293
  let firstChunkDurationMs;
3134
3294
  let tokenUsage;
@@ -3416,9 +3576,11 @@ var DefaultQoderTransport = class {
3416
3576
  this.logger?.warn?.("[Qoder Stream] Chat rejected as unauthorized; exchanging a fresh job token and retrying once", { status: error.failure.status });
3417
3577
  }
3418
3578
  let lastRejection;
3579
+ let noticeAt;
3419
3580
  for (let round = 0; round < 2; round++) {
3420
3581
  const refreshed = await this.auth.exchangeFresh(pat, options.signal);
3421
3582
  this.pendingRefreshAt = Date.now();
3583
+ noticeAt = this.pendingRefreshAt;
3422
3584
  try {
3423
3585
  yield* this.streamChat(options, model, refreshed, messages, () => {
3424
3586
  this.onChatAccepted();
@@ -3442,7 +3604,7 @@ var DefaultQoderTransport = class {
3442
3604
  }
3443
3605
  }
3444
3606
  }
3445
- this.discardPendingRefreshNotice();
3607
+ if (this.pendingRefreshAt === noticeAt) this.discardPendingRefreshNotice();
3446
3608
  if (!this.refreshFailureAnnounced) {
3447
3609
  this.refreshFailureAnnounced = true;
3448
3610
  this.logger?.warn?.("[Qoder Stream] Job token refresh did not recover the chat; the upstream still rejects it", { status: lastRejection instanceof LlmError ? lastRejection.failure.status : void 0 });
@@ -3518,6 +3680,7 @@ function normalizeCredits(credits) {
3518
3680
  */
3519
3681
  function classifyUpstreamError(status, body) {
3520
3682
  const lower = body.toLowerCase();
3683
+ if (qoderQueueSignal(body) !== void 0) return "soft_rate";
3521
3684
  if (status === 402) return "quota_exceeded";
3522
3685
  if (status === 429) return "soft_rate";
3523
3686
  if (status === 401 || status === 403) return "auth";
@@ -3551,11 +3714,18 @@ function classifyUpstreamError(status, body) {
3551
3714
  * The transport's taxonomy (`src/qoder/errors.ts`) codes an HTTP 402 as
3552
3715
  * `INVALID_REQUEST`, so the status gets the last say for the quota and auth
3553
3716
  * families before the code-based defaults apply.
3717
+ *
3718
+ * `RATE_LIMIT` is tested before the auth family because the upstream's queue
3719
+ * answer keeps its 401/403 status: a saturated qfmodel queue reads
3720
+ * `code: RATE_LIMIT, status: 403` (body carries 10605/isQueued/
3721
+ * retryAfterSeconds), and letting the status arm claim it reported a dead
3722
+ * credential — which the host never retries — instead of a throttle it would.
3554
3723
  */
3555
3724
  function kindFromQoderFailure(failure) {
3556
3725
  if (failure.code === "MISSING_CREDENTIAL") return "missing_credential";
3726
+ if (failure.code === "RATE_LIMIT") return "soft_rate";
3557
3727
  if (failure.code === "AUTH" || failure.status === 401 || failure.status === 403) return "auth";
3558
- if (failure.code === "RATE_LIMIT" || failure.status === 429) return "soft_rate";
3728
+ if (failure.status === 429) return "soft_rate";
3559
3729
  if (failure.code === "QUOTA" || failure.status === 402) return "quota_exceeded";
3560
3730
  if (failure.code.startsWith("INVALID_") || failure.code.startsWith("UNSUPPORTED_") || failure.code === "ATTACHMENT") return "client";
3561
3731
  return "server";
@@ -4275,7 +4445,7 @@ const DEFAULT_MAX_TOKENS = 32768;
4275
4445
  const DEFAULT_CONTEXT_WINDOW = 18e4;
4276
4446
  //#endregion
4277
4447
  //#region src/version.ts
4278
- const QODER_CONNECT_VERSION = "0.1.9";
4448
+ const QODER_CONNECT_VERSION = "0.2.1";
4279
4449
  //#endregion
4280
4450
  //#region src/host-heartbeat.ts
4281
4451
  /**
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Qoder Connector",
4
+ "description": "Connect Qoder CLI's model service: per-region model toggles, context windows, quota lookup, and daily check-in."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Qoder 连接器",
4
+ "description": "接入 Qoder CLI 的模型服务:双区域模型开关、上下文窗口、额度查询与每日签到。"
5
+ }
6
+ }