dsh-qoder-connect 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect x="1" y="1" width="34" height="34" rx="8" fill="url(#g)" />
3
+ <path d="M19.6 7.5 11 20h5.6l-1.4 8.5L24.5 16h-5.6l.7-8.5Z" fill="#FFFFFF"/>
4
+ <circle cx="25.5" cy="25.5" r="3.2" fill="#45D9E7"/>
5
+ <defs><linearGradient id="g" x1="18" y1="1" x2="18" y2="35" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs>
6
+ </svg>
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, T as QoderCatalogStore, V as qoderCredentialIdentity, b as createQoderTransport, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, y as validateApiKey, z as QoderCredentialStore } from "./variants-C8ezztjV.js";
2
+ import { D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, T as QoderCatalogStore, V as qoderCredentialIdentity, b as createQoderTransport, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, y as validateApiKey, z as QoderCredentialStore } from "./variants-DuEgaUad.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
package/lib/index.d.ts CHANGED
@@ -834,6 +834,12 @@ declare function classifyUpstreamError(status: number, body: string): UpstreamEr
834
834
  * The transport's taxonomy (`src/qoder/errors.ts`) codes an HTTP 402 as
835
835
  * `INVALID_REQUEST`, so the status gets the last say for the quota and auth
836
836
  * families before the code-based defaults apply.
837
+ *
838
+ * `RATE_LIMIT` is tested before the auth family because the upstream's queue
839
+ * answer keeps its 401/403 status: a saturated qfmodel queue reads
840
+ * `code: RATE_LIMIT, status: 403` (body carries 10605/isQueued/
841
+ * retryAfterSeconds), and letting the status arm claim it reported a dead
842
+ * credential — which the host never retries — instead of a throttle it would.
837
843
  */
838
844
  declare function kindFromQoderFailure(failure: {
839
845
  code: string;
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-C8ezztjV.js";
1
+ import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-DuEgaUad.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -628,7 +628,7 @@ function createQoderShim(options) {
628
628
  req.on("close", () => controller.abort());
629
629
  const result = await client.chatStream(raw, controller.signal);
630
630
  if (!result.ok) {
631
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
631
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`);
632
632
  return;
633
633
  }
634
634
  res.writeHead(200, {
@@ -2113,9 +2113,16 @@ function jobTokenHintText(at) {
2113
2113
  * fresh token is not necessarily an authorization problem at all, and the real
2114
2114
  * reason travels in the failure message itself (see the SSE envelope body).
2115
2115
  * Claiming "quota" or "revoked" here would be a guess presented as a finding.
2116
+ *
2117
+ * It also must not point the reader at "the error above": the failed heal does
2118
+ * not always belong to a chat whose failure renders a card in this
2119
+ * conversation. A heal inside the session-title request — same transport,
2120
+ * same shared notice state — fails silently there, so the row used to send
2121
+ * users looking for an error card that does not exist. The row states only
2122
+ * what is known: the retry ran, it did not recover, the request failed.
2116
2123
  */
2117
2124
  function jobTokenRefreshFailedHintText(at) {
2118
- return `jobToken 已重换但仍被上游拒绝(${new Date(at).toLocaleTimeString("zh-CN", { hour12: false })})— 自愈未恢复,请查看上方错误详情`;
2125
+ return `jobToken 已重换但仍被上游拒绝(${new Date(at).toLocaleTimeString("zh-CN", { hour12: false })})— 自愈未恢复,该请求已失败`;
2119
2126
  }
2120
2127
  /** Build one variant's stores, transport, and probe state. */
2121
2128
  function createVariantRuntime(ctx, config, variant, current, identityOf) {
@@ -853,6 +853,125 @@ async function probeModel(options) {
853
853
  };
854
854
  }
855
855
  //#endregion
856
+ //#region src/qoder/errors.ts
857
+ /**
858
+ * DSH-compatible LLM error representation.
859
+ *
860
+ * @module dsh-provider-qoder/qoder/errors
861
+ */
862
+ var QoderLlmError = class extends LlmError {
863
+ constructor(message, code = "UNKNOWN_ERROR", options) {
864
+ super(message, code, options);
865
+ }
866
+ };
867
+ /** The queue/limit markers Qoder puts in a rejection body (observed 2026-09-26). */
868
+ const QUEUE_BODY_MARKERS = [
869
+ /"code"\s*:\s*"?10605"?/u,
870
+ /"isQueued"\s*:\s*true/u,
871
+ /"queueCount"/u,
872
+ /"serviceAvailable"\s*:\s*false/u
873
+ ];
874
+ /**
875
+ * Whether an upstream rejection body is Qoder's **queue/limit** answer rather
876
+ * than an authorization failure.
877
+ *
878
+ * Qoder answers a saturated per-model queue (and a region whose service is not
879
+ * available) with an HTTP 401/403 — or a 200 SSE envelope carrying
880
+ * `statusCodeValue: 403` — whose body says `code: 10605`, `isQueued: true`,
881
+ * `queueCount`, `retryAfterSeconds` and sometimes `serviceAvailable: false`.
882
+ * Observed verbatim on 2026-09-26:
883
+ *
884
+ * {"code":"10605","message":"{\"isQueued\":true,\"modelKey\":\"qfmodel\",
885
+ * \"queueCount\":8887,\"queueType\":\"p3\",\"retryAfterSeconds\":30,
886
+ * \"serviceAvailable\":true,\"waitTime\":274}"}
887
+ *
888
+ * Reading that status as AUTH made the self-heal exchange fresh job tokens
889
+ * against a queue no token can jump, and — because the host retries
890
+ * `RATE_LIMIT` but deliberately not `AUTH` — it also suppressed the automatic
891
+ * retry that would have sailed through the 30-second window. The body is the
892
+ * only place the distinction is visible, so it gets the last say over status.
893
+ */
894
+ function qoderQueueSignal(body) {
895
+ if (body === void 0 || body === "") return void 0;
896
+ const text = body.replace(/\\/gu, "");
897
+ if (!QUEUE_BODY_MARKERS.some((marker) => marker.test(text))) return void 0;
898
+ const match = /"retryAfterSeconds"\s*:\s*(\d+)/u.exec(text);
899
+ return match === null ? {} : { retryAfterMs: Number(match[1]) * 1e3 };
900
+ }
901
+ /**
902
+ * Replace status digits that read as authorization failure with a neutral
903
+ * word, for messages the upstream used to announce a queue.
904
+ *
905
+ * `dsh-llm-pi-ai`'s error classifier is a text matcher whose first test is
906
+ * `/\b(?:401|403)\b/` — ahead of its 429/rate-limit test — so any message
907
+ * carrying those digits classifies AUTH no matter the structured code
908
+ * travelling beside it. A queue answer quoting the upstream's 403 (in the
909
+ * message prefix, the envelope status, or the body's `"code":"403"`) must not
910
+ * reach that matcher as-is, or the user sees "API 密钥无效" for a throttle
911
+ * (observed verbatim on 2026-09-26 23:30 and again at 23:40).
912
+ */
913
+ function deauthorizeDigits(message) {
914
+ return message.replace(/\b(?:401|403)\b/gu, "throttled");
915
+ }
916
+ function qoderHttpError(message, response, body) {
917
+ const { status } = response;
918
+ const headerRetryAfterMs = retryAfterMs(response.headers?.get("retry-after") ?? null);
919
+ const requestId = qoderRequestId(response.headers);
920
+ const queue = status === 401 || status === 403 || status === 429 ? qoderQueueSignal(body ?? message) : void 0;
921
+ if (queue !== void 0) {
922
+ const retryAfterMs = queue.retryAfterMs ?? headerRetryAfterMs;
923
+ return new QoderLlmError(deauthorizeDigits(message), "RATE_LIMIT", {
924
+ status,
925
+ ...retryAfterMs === void 0 ? {} : { providerRetryAfterMs: retryAfterMs },
926
+ ...requestId === void 0 ? {} : { requestId }
927
+ });
928
+ }
929
+ 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", {
930
+ status,
931
+ ...headerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs: headerRetryAfterMs },
932
+ ...requestId === void 0 ? {} : { requestId }
933
+ });
934
+ }
935
+ function qoderRequestId(headers) {
936
+ const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
937
+ return normalized ? ProviderRequestId(normalized) : void 0;
938
+ }
939
+ /**
940
+ * Whether this failure is an upstream authorization rejection worth one
941
+ * re-auth retry.
942
+ *
943
+ * The job token the transport signs requests with is cached in memory; an
944
+ * upstream that invalidates it mid-lifetime (gateway rotation or a fault
945
+ * window) answers HTTP 401/403 before any payload is produced. That state is
946
+ * distinguishable from a genuinely revoked PAT only by trying a fresh
947
+ * exchange, so callers clear their credential cache and retry once before
948
+ * reporting `AUTH` to the user.
949
+ *
950
+ * A `RATE_LIMIT` code is never a re-auth candidate, **even when its status is
951
+ * 401/403**: that is the upstream's queue answer (code 10605 / isQueued /
952
+ * retryAfterSeconds in the body), which keeps the status but says the
953
+ * credential was not the problem. Healing it exchanged job tokens against a
954
+ * queue no token can jump (observed 2026-09-26) and, by consuming the retry,
955
+ * suppressed the rate-limit retry that would have ridden out the window.
956
+ */
957
+ function isQoderAuthRejection(error) {
958
+ if (!(error instanceof LlmError)) return false;
959
+ if (error.code === "RATE_LIMIT") return false;
960
+ return error.code === "AUTH" || error.failure.status === 401 || error.failure.status === 403;
961
+ }
962
+ function retryAfterMs(value, nowMs = Date.now()) {
963
+ const normalized = value?.trim();
964
+ if (!normalized) return void 0;
965
+ if (/^\d+$/u.test(normalized)) {
966
+ const delayMs = Number(normalized) * 1e3;
967
+ return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : void 0;
968
+ }
969
+ const retryAt = Date.parse(normalized);
970
+ if (Number.isNaN(retryAt)) return void 0;
971
+ const delayMs = retryAt - nowMs;
972
+ return delayMs > 0 ? delayMs : void 0;
973
+ }
974
+ //#endregion
856
975
  //#region src/qoder/transport/endpoints.ts
857
976
  const qoderRegionEndpoints = {
858
977
  global: {
@@ -915,59 +1034,6 @@ function getQoderClaimCampaignUrl(region = "global", campaignId) {
915
1034
  return `${openApiUrl}/sash/api/v1/me/campaigns/${encodeURIComponent(campaignId)}/claim`;
916
1035
  }
917
1036
  //#endregion
918
- //#region src/qoder/errors.ts
919
- /**
920
- * DSH-compatible LLM error representation.
921
- *
922
- * @module dsh-provider-qoder/qoder/errors
923
- */
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;
943
- }
944
- /**
945
- * Whether this failure is an upstream authorization rejection worth one
946
- * re-auth retry.
947
- *
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.
954
- */
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
1037
  //#region src/qoder/transport/machine-id.ts
972
1038
  function defaultMachineIdPaths() {
973
1039
  return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
@@ -1294,7 +1360,7 @@ async function openApiJsonRequest(fetchImpl, options) {
1294
1360
  const text = await readLimitedText(response, response.ok ? defaultMaxJsonBytes : defaultMaxErrorBytes, `Qoder ${options.operation} response`);
1295
1361
  if (!response.ok) {
1296
1362
  options.logger?.error?.(`[Qoder ${options.operation}] Request failed`, redactLogPayload(text));
1297
- throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response);
1363
+ throw qoderHttpError(`Failed to execute Qoder ${options.operation} with status ${response.status}.`, response, text);
1298
1364
  }
1299
1365
  let data;
1300
1366
  try {
@@ -1332,6 +1398,14 @@ async function waitForFlight(promise, signal) {
1332
1398
  var QoderAuthService = class {
1333
1399
  cache = /* @__PURE__ */ new Map();
1334
1400
  inFlight = /* @__PURE__ */ new Map();
1401
+ /**
1402
+ * Self-heal exchanges in flight, keyed like the cache. Deliberately separate
1403
+ * from {@link inFlight}: that flight may be aborted by a departing
1404
+ * concurrent waiter (a quota poll, a catalog sweep) whose abort has nothing
1405
+ * to do with the heal, so healers must not share its controller. Concurrent
1406
+ * heals instead share this flight — see {@link exchangeFresh}.
1407
+ */
1408
+ healFlights = /* @__PURE__ */ new Map();
1335
1409
  fetchImpl;
1336
1410
  timeoutMs;
1337
1411
  resolveMachineId;
@@ -1354,10 +1428,31 @@ var QoderAuthService = class {
1354
1428
  * (the quota poll, the catalog sweep), and a retry that joins it would then
1355
1429
  * be cancelled by a path that has nothing to do with the chat. The result
1356
1430
  * replaces the cache entry.
1431
+ *
1432
+ * Two heals for the same credential that overlap share one exchange instead
1433
+ * of racing: each racer's `clear` deleted what the previous one had just
1434
+ * cached, and its own `cache.set` overwrote the other's — so the cache could
1435
+ * end up naming a token the losing request was no longer using, and with an
1436
+ * upstream that retires the previous job token on a new exchange the racers
1437
+ * also invalidated each other's credentials (one chat recovering while a
1438
+ * concurrent one failed, exactly the 2026-09-26 22:36 observation). The
1439
+ * shared exchange runs on its own timeout signal, so one healer's abort —
1440
+ * its chat request went away — cannot cancel an exchange other healers, and
1441
+ * the future `getCredentials` callers about to read the refreshed cache, are
1442
+ * waiting on.
1357
1443
  */
1358
1444
  async exchangeFresh(pat, signal) {
1445
+ const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
1446
+ const existing = this.healFlights.get(cacheKey);
1447
+ if (existing !== void 0 && !existing.settled) return waitForFlight(existing.promise, signal);
1359
1448
  this.clear(pat);
1360
- return await this.exchangeAndResolve(pat, signal ?? AbortSignal.timeout(this.timeoutMs));
1449
+ const created = { settled: false };
1450
+ created.promise = this.exchangeAndResolve(pat, AbortSignal.timeout(this.timeoutMs)).finally(() => {
1451
+ created.settled = true;
1452
+ if (this.healFlights.get(cacheKey) === created) this.healFlights.delete(cacheKey);
1453
+ });
1454
+ this.healFlights.set(cacheKey, created);
1455
+ return waitForFlight(created.promise, signal);
1361
1456
  }
1362
1457
  async getCredentials(pat, signal) {
1363
1458
  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");
@@ -2793,7 +2888,7 @@ function parseEnvelope(rawData) {
2793
2888
  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
2889
  if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) {
2795
2890
  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 });
2891
+ throw qoderHttpError(envelope.body ? `Qoder service returned upstream error status ${status}: ${envelope.body}` : `Qoder service returned upstream error status ${status}.`, { status }, envelope.body);
2797
2892
  }
2798
2893
  if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
2799
2894
  return envelope;
@@ -3128,7 +3223,15 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
3128
3223
  ...reqId === void 0 ? {} : { requestId: reqId }
3129
3224
  });
3130
3225
  resetIdleTimer();
3131
- if (!response.ok) throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response);
3226
+ if (!response.ok) {
3227
+ let errorBody;
3228
+ try {
3229
+ errorBody = await readLimitedText(response, defaultMaxErrorBytes, "Qoder chat error response");
3230
+ } catch {
3231
+ errorBody = void 0;
3232
+ }
3233
+ throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}.`, response, errorBody);
3234
+ }
3132
3235
  if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
3133
3236
  let firstChunkDurationMs;
3134
3237
  let tokenUsage;
@@ -3416,9 +3519,11 @@ var DefaultQoderTransport = class {
3416
3519
  this.logger?.warn?.("[Qoder Stream] Chat rejected as unauthorized; exchanging a fresh job token and retrying once", { status: error.failure.status });
3417
3520
  }
3418
3521
  let lastRejection;
3522
+ let noticeAt;
3419
3523
  for (let round = 0; round < 2; round++) {
3420
3524
  const refreshed = await this.auth.exchangeFresh(pat, options.signal);
3421
3525
  this.pendingRefreshAt = Date.now();
3526
+ noticeAt = this.pendingRefreshAt;
3422
3527
  try {
3423
3528
  yield* this.streamChat(options, model, refreshed, messages, () => {
3424
3529
  this.onChatAccepted();
@@ -3442,7 +3547,7 @@ var DefaultQoderTransport = class {
3442
3547
  }
3443
3548
  }
3444
3549
  }
3445
- this.discardPendingRefreshNotice();
3550
+ if (this.pendingRefreshAt === noticeAt) this.discardPendingRefreshNotice();
3446
3551
  if (!this.refreshFailureAnnounced) {
3447
3552
  this.refreshFailureAnnounced = true;
3448
3553
  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 +3623,7 @@ function normalizeCredits(credits) {
3518
3623
  */
3519
3624
  function classifyUpstreamError(status, body) {
3520
3625
  const lower = body.toLowerCase();
3626
+ if (qoderQueueSignal(body) !== void 0) return "soft_rate";
3521
3627
  if (status === 402) return "quota_exceeded";
3522
3628
  if (status === 429) return "soft_rate";
3523
3629
  if (status === 401 || status === 403) return "auth";
@@ -3551,11 +3657,18 @@ function classifyUpstreamError(status, body) {
3551
3657
  * The transport's taxonomy (`src/qoder/errors.ts`) codes an HTTP 402 as
3552
3658
  * `INVALID_REQUEST`, so the status gets the last say for the quota and auth
3553
3659
  * families before the code-based defaults apply.
3660
+ *
3661
+ * `RATE_LIMIT` is tested before the auth family because the upstream's queue
3662
+ * answer keeps its 401/403 status: a saturated qfmodel queue reads
3663
+ * `code: RATE_LIMIT, status: 403` (body carries 10605/isQueued/
3664
+ * retryAfterSeconds), and letting the status arm claim it reported a dead
3665
+ * credential — which the host never retries — instead of a throttle it would.
3554
3666
  */
3555
3667
  function kindFromQoderFailure(failure) {
3556
3668
  if (failure.code === "MISSING_CREDENTIAL") return "missing_credential";
3669
+ if (failure.code === "RATE_LIMIT") return "soft_rate";
3557
3670
  if (failure.code === "AUTH" || failure.status === 401 || failure.status === 403) return "auth";
3558
- if (failure.code === "RATE_LIMIT" || failure.status === 429) return "soft_rate";
3671
+ if (failure.status === 429) return "soft_rate";
3559
3672
  if (failure.code === "QUOTA" || failure.status === 402) return "quota_exceeded";
3560
3673
  if (failure.code.startsWith("INVALID_") || failure.code.startsWith("UNSUPPORTED_") || failure.code === "ATTACHMENT") return "client";
3561
3674
  return "server";
@@ -4275,7 +4388,7 @@ const DEFAULT_MAX_TOKENS = 32768;
4275
4388
  const DEFAULT_CONTEXT_WINDOW = 18e4;
4276
4389
  //#endregion
4277
4390
  //#region src/version.ts
4278
- const QODER_CONNECT_VERSION = "0.1.9";
4391
+ const QODER_CONNECT_VERSION = "0.2.0";
4279
4392
  //#endregion
4280
4393
  //#region src/host-heartbeat.ts
4281
4394
  /**
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
+ }
package/package.json CHANGED
@@ -1,120 +1,123 @@
1
- {
2
- "name": "dsh-qoder-connect",
3
- "displayName": "DSH Qoder Connect",
4
- "description": "将 Qoder(国内版 / 国际版)的模型接入 DeepSeek Harness — bring Qoder (CN & international) models into DeepSeek Harness.",
5
- "version": "0.1.9",
6
- "author": "corrinehu",
7
- "keywords": [
8
- "dsh-plugin",
9
- "deepseek-harness",
10
- "deepseek",
11
- "qoder",
12
- "model-provider"
13
- ],
14
- "type": "module",
15
- "engines": {
16
- "dsh": ">=0.1.5-rc.2",
17
- "node": "^22.19.0 || >=24.0.0"
18
- },
19
- "main": "lib/index.js",
20
- "types": "lib/index.d.ts",
21
- "bin": {
22
- "dsh-qoder-connect": "lib/bin.js"
23
- },
24
- "scripts": {
25
- "prepack": "npm run build",
26
- "build": "tsdown",
27
- "test": "node -r ./tests/sandbox-shim.cjs ./node_modules/vitest/vitest.mjs run",
28
- "test:qoder": "node --test --test-isolation=none --experimental-strip-types tests/qoder/*.test.ts",
29
- "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
30
- "check": "pnpm run typecheck && pnpm run test && pnpm run test:qoder && pnpm run build"
31
- },
32
- "exports": {
33
- ".": {
34
- "types": "./lib/index.d.ts",
35
- "default": "./lib/index.js"
36
- },
37
- "./client": "./lib/client.js",
38
- "./cordis.patch.yml": "./cordis.patch.yml",
39
- "./package.json": "./package.json"
40
- },
41
- "files": [
42
- "lib",
43
- "assets",
44
- "cordis.patch.yml",
45
- "README.md",
46
- "README.en.md",
47
- "LICENSE"
48
- ],
49
- "license": "MIT",
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/masknull/dsh-qoder-connect.git"
53
- },
54
- "homepage": "https://github.com/masknull/dsh-qoder-connect#readme",
55
- "bugs": "https://github.com/masknull/dsh-qoder-connect/issues",
56
- "dsh": {
57
- "bundle": {
58
- "patch": "./cordis.patch.yml"
59
- },
60
- "client": {
61
- "inject": [
62
- "@deepseek-ai/dsh-api-remotes",
63
- "@deepseek-ai/dsh-api-session-controller",
64
- "@deepseek-ai/dsh-client-ui-renderer",
65
- "@deepseek-ai/dsh-client-ui-settings-plugins",
66
- "@deepseek-ai/dsh-client-ui-settings",
67
- "@deepseek-ai/dsh-client-ui-conversation",
68
- "@deepseek-ai/dsh-client-ui-model-selection",
69
- "@deepseek-ai/dsh-client-ui-sidebar",
70
- "@deepseek-ai/dsh-client-locale"
71
- ],
72
- "platform": "web"
73
- }
74
- },
75
- "peerDependencies": {
76
- "@deepseek-ai/cordis": "^4.0.2",
77
- "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
78
- "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
79
- "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
80
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
81
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
82
- "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
83
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
84
- "@deepseek-ai/schemastery": "^3.18.2",
85
- "@earendil-works/pi-ai": "^0.85.1",
86
- "react": "^18.2.0"
87
- },
88
- "devDependencies": {
89
- "@deepseek-ai/cordis": "^4.0.2",
90
- "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
91
- "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
92
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
93
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
94
- "@deepseek-ai/dsh-client-ui-model-selection": "0.1.5-rc.1",
95
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
96
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
97
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
98
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.1",
99
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
100
- "@deepseek-ai/dsh-credentials": "^0.1.5-rc.1",
101
- "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
102
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
103
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
104
- "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
105
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
106
- "@deepseek-ai/schemastery": "^3.18.2",
107
- "@earendil-works/pi-ai": "^0.85.1",
108
- "@types/node": "^22.20.0",
109
- "@types/react": "~18.3.1",
110
- "@types/react-test-renderer": "^18.3.1",
111
- "react": "^18.2.0",
112
- "react-test-renderer": "18.3.1",
113
- "tsdown": "^0.22.2",
114
- "typescript": "^5.9.3",
115
- "vitest": "^4.1.8"
116
- },
117
- "dependencies": {
118
- "js-yaml": "^4.1.0"
119
- }
120
- }
1
+ {
2
+ "name": "dsh-qoder-connect",
3
+ "displayName": "DSH Qoder Connect",
4
+ "description": "将 Qoder(国内版 / 国际版)的模型接入 DeepSeek Harness — bring Qoder (CN & international) models into DeepSeek Harness.",
5
+ "version": "0.2.0",
6
+ "author": "corrinehu",
7
+ "keywords": [
8
+ "dsh-plugin",
9
+ "deepseek-harness",
10
+ "deepseek",
11
+ "qoder",
12
+ "model-provider"
13
+ ],
14
+ "type": "module",
15
+ "engines": {
16
+ "dsh": ">=0.1.5-rc.2",
17
+ "node": "^22.19.0 || >=24.0.0"
18
+ },
19
+ "main": "lib/index.js",
20
+ "types": "lib/index.d.ts",
21
+ "bin": {
22
+ "dsh-qoder-connect": "lib/bin.js"
23
+ },
24
+ "scripts": {
25
+ "prepack": "npm run build",
26
+ "build": "tsdown",
27
+ "test": "node -r ./tests/sandbox-shim.cjs ./node_modules/vitest/vitest.mjs run",
28
+ "test:qoder": "node --test --test-isolation=none --experimental-strip-types tests/qoder/*.test.ts",
29
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
30
+ "check": "pnpm run typecheck && pnpm run test && pnpm run test:qoder && pnpm run build"
31
+ },
32
+ "exports": {
33
+ ".": {
34
+ "types": "./lib/index.d.ts",
35
+ "default": "./lib/index.js"
36
+ },
37
+ "./client": "./lib/client.js",
38
+ "./cordis.patch.yml": "./cordis.patch.yml",
39
+ "./package.json": "./package.json",
40
+ "./locale/*.json": "./locale/*.json"
41
+ },
42
+ "files": [
43
+ "lib",
44
+ "assets",
45
+ "cordis.patch.yml",
46
+ "README.md",
47
+ "README.en.md",
48
+ "LICENSE",
49
+ "locale/*.json"
50
+ ],
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/masknull/dsh-qoder-connect.git"
55
+ },
56
+ "homepage": "https://github.com/masknull/dsh-qoder-connect#readme",
57
+ "bugs": "https://github.com/masknull/dsh-qoder-connect/issues",
58
+ "dsh": {
59
+ "bundle": {
60
+ "patch": "./cordis.patch.yml"
61
+ },
62
+ "client": {
63
+ "inject": [
64
+ "@deepseek-ai/dsh-api-remotes",
65
+ "@deepseek-ai/dsh-api-session-controller",
66
+ "@deepseek-ai/dsh-client-ui-renderer",
67
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
68
+ "@deepseek-ai/dsh-client-ui-settings",
69
+ "@deepseek-ai/dsh-client-ui-conversation",
70
+ "@deepseek-ai/dsh-client-ui-model-selection",
71
+ "@deepseek-ai/dsh-client-ui-sidebar",
72
+ "@deepseek-ai/dsh-client-locale"
73
+ ],
74
+ "platform": "web"
75
+ }
76
+ },
77
+ "peerDependencies": {
78
+ "@deepseek-ai/cordis": "^4.0.2",
79
+ "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
80
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
81
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
82
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
83
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
84
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
85
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
86
+ "@deepseek-ai/schemastery": "^3.18.2",
87
+ "@earendil-works/pi-ai": "^0.85.1",
88
+ "react": "^18.2.0"
89
+ },
90
+ "devDependencies": {
91
+ "@deepseek-ai/cordis": "^4.0.2",
92
+ "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
93
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
94
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
95
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
96
+ "@deepseek-ai/dsh-client-ui-model-selection": "0.1.5-rc.1",
97
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
98
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
99
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
100
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.1",
101
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
102
+ "@deepseek-ai/dsh-credentials": "^0.1.5-rc.1",
103
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
104
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
105
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
106
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
107
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
108
+ "@deepseek-ai/schemastery": "^3.18.2",
109
+ "@earendil-works/pi-ai": "^0.85.1",
110
+ "@types/node": "^22.20.0",
111
+ "@types/react": "~18.3.1",
112
+ "@types/react-test-renderer": "^18.3.1",
113
+ "react": "^18.2.0",
114
+ "react-test-renderer": "18.3.1",
115
+ "tsdown": "^0.22.2",
116
+ "typescript": "^5.9.3",
117
+ "vitest": "^4.1.8"
118
+ },
119
+ "dependencies": {
120
+ "js-yaml": "^4.1.0"
121
+ },
122
+ "icon": "./assets/icon.svg"
123
+ }