dsh-provider-qoder 0.4.2 → 0.4.3

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
@@ -1,9 +1,9 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage } from "@deepseek-ai/dsh-llm";
2
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
3
3
  import crypto, { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { dirname, join } from "node:path";
5
+ import { homedir, hostname } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { WebError } from "@deepseek-ai/dsh-web";
8
8
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
9
9
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
@@ -348,10 +348,177 @@ function getQoderWebSearchUrl(region = "global") {
348
348
  * @module dsh-provider-qoder/qoder/errors
349
349
  */
350
350
  var QoderLlmError = class extends LlmError {
351
+ upstreamCode;
352
+ source;
353
+ httpStatus;
351
354
  constructor(message, code = "UNKNOWN_ERROR", options) {
352
355
  super(message, code, options);
353
356
  }
354
357
  };
358
+ const modelBusinessErrors = {
359
+ "103": {
360
+ code: "PROVIDER_ERROR",
361
+ message: "Qoder rejected a duplicate model request."
362
+ },
363
+ "105": {
364
+ code: "AUTH",
365
+ message: "Qoder subscriber authentication has expired."
366
+ },
367
+ "110": {
368
+ code: QUOTA_EXCEEDED_CODE,
369
+ message: "Qoder daily usage limit has been reached."
370
+ },
371
+ "112": {
372
+ code: QUOTA_EXCEEDED_CODE,
373
+ message: "Qoder personal Credits are exhausted."
374
+ },
375
+ "114": {
376
+ code: QUOTA_EXCEEDED_CODE,
377
+ message: "Qoder free trial account limit has been reached."
378
+ },
379
+ "115": {
380
+ code: QUOTA_EXCEEDED_CODE,
381
+ message: "Qoder free subscriber quota limit has been reached."
382
+ },
383
+ "116": {
384
+ code: QUOTA_EXCEEDED_CODE,
385
+ message: "Qoder Teams administrator Credits are exhausted."
386
+ },
387
+ "117": {
388
+ code: QUOTA_EXCEEDED_CODE,
389
+ message: "Qoder Teams member Credits are exhausted."
390
+ },
391
+ "119": {
392
+ code: QUOTA_EXCEEDED_CODE,
393
+ message: "Qoder Vela model free usage limit has been reached."
394
+ },
395
+ "122": {
396
+ code: QUOTA_EXCEEDED_CODE,
397
+ message: "Qoder billing group Credits limit has been reached."
398
+ },
399
+ "406": {
400
+ code: "PROVIDER_ERROR",
401
+ message: "Qoder rejected the request through its content safety policy."
402
+ },
403
+ "416": {
404
+ code: "INVALID_REQUEST",
405
+ message: "Qoder cannot satisfy the requested range."
406
+ },
407
+ "430": {
408
+ code: "INVALID_REQUEST",
409
+ message: "Qoder does not support this request; check client compatibility."
410
+ },
411
+ "10605": {
412
+ code: "RATE_LIMIT",
413
+ message: "Qoder model is queued."
414
+ },
415
+ "100400": {
416
+ code: "PROVIDER_ERROR",
417
+ message: "Qoder custom model service failed."
418
+ },
419
+ "100401": {
420
+ code: "AUTH",
421
+ message: "Qoder custom model authentication failed."
422
+ },
423
+ "100403": {
424
+ code: "PROVIDER_ERROR",
425
+ message: "Qoder custom model is unavailable."
426
+ }
427
+ };
428
+ /** Decode only model rejection diagnostics, with bounded depth, work and JSON size. */
429
+ function modelErrorDetails(raw, status) {
430
+ let upstreamCode;
431
+ let codeDepth = -1;
432
+ let detail;
433
+ let textCode;
434
+ let delayMs;
435
+ let remaining = 100;
436
+ const seen = /* @__PURE__ */ new Set();
437
+ function visit(value, depth) {
438
+ if (depth > 10 || --remaining < 0) return;
439
+ if (typeof value === "string") {
440
+ const text = value.trim();
441
+ if (!text) return;
442
+ if (text.length <= 16 * 1024) try {
443
+ visit(JSON.parse(text), depth + 1);
444
+ return;
445
+ } catch {}
446
+ const diagnostic = text.slice(0, 16 * 1024);
447
+ if (isContextWindowExceededError(diagnostic)) textCode = CONTEXT_WINDOW_EXCEEDED_CODE;
448
+ else if (textCode === void 0 && isQuotaExceededError(diagnostic)) textCode = QUOTA_EXCEEDED_CODE;
449
+ detail = text.slice(0, 300);
450
+ return;
451
+ }
452
+ if (!value || typeof value !== "object" || Array.isArray(value) || seen.has(value)) return;
453
+ seen.add(value);
454
+ const obj = value;
455
+ for (const key of [
456
+ "code",
457
+ "error_code",
458
+ "errorCode",
459
+ "errCode"
460
+ ]) {
461
+ const candidate = obj[key];
462
+ const code = (typeof candidate === "number" && Number.isFinite(candidate) ? String(candidate) : typeof candidate === "string" ? candidate.trim() : void 0)?.slice(0, 100);
463
+ if (!code || code === "0" || code === String(status) && !Object.hasOwn(modelBusinessErrors, code)) continue;
464
+ if (depth >= codeDepth) {
465
+ upstreamCode = code;
466
+ codeDepth = depth;
467
+ }
468
+ }
469
+ const seconds = obj.retryAfterSeconds;
470
+ if (typeof seconds === "number" && Number.isFinite(seconds * 1e3) && seconds > 0) delayMs = Math.max(delayMs ?? 0, seconds * 1e3);
471
+ for (const key of [
472
+ "message",
473
+ "body",
474
+ "details",
475
+ "error",
476
+ "cause",
477
+ "data",
478
+ "qoderApiError",
479
+ "providerError"
480
+ ]) visit(obj[key], depth + 1);
481
+ }
482
+ visit(raw, 0);
483
+ return {
484
+ upstreamCode,
485
+ detail,
486
+ textCode,
487
+ delayMs
488
+ };
489
+ }
490
+ /** Model business semantics precede HTTP status; other transport capabilities keep their own protocols. */
491
+ function qoderModelError(message, response) {
492
+ const details = modelErrorDetails(response.cause, response.status);
493
+ const known = details.upstreamCode === void 0 ? void 0 : Object.hasOwn(modelBusinessErrors, details.upstreamCode) ? modelBusinessErrors[details.upstreamCode] : void 0;
494
+ const fallback = qoderHttpError(message, response);
495
+ let code = fallback.code;
496
+ let summary = details.detail ? `${message}: ${details.detail}` : message;
497
+ if (details.upstreamCode !== void 0) {
498
+ code = known?.code ?? "PROVIDER_ERROR";
499
+ summary = `${known?.message ?? summary} (Qoder code ${details.upstreamCode}).`;
500
+ } else if (details.textCode === CONTEXT_WINDOW_EXCEEDED_CODE) {
501
+ code = CONTEXT_WINDOW_EXCEEDED_CODE;
502
+ summary = "Qoder model context window has been exceeded.";
503
+ } else if (details.textCode === QUOTA_EXCEEDED_CODE) {
504
+ code = QUOTA_EXCEEDED_CODE;
505
+ summary = "Qoder subscriber quota is exhausted.";
506
+ }
507
+ const providerRetryAfterMs = Math.max(fallback.failure.providerRetryAfterMs ?? 0, details.delayMs ?? 0) || void 0;
508
+ const error = new QoderLlmError(summary, code, {
509
+ status: response.status,
510
+ ...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
511
+ ...fallback.failure.requestId === void 0 ? {} : { requestId: fallback.failure.requestId },
512
+ ...response.cause === void 0 ? {} : { cause: response.cause }
513
+ });
514
+ error.upstreamCode = details.upstreamCode;
515
+ error.source = response.source;
516
+ error.httpStatus = response.httpStatus ?? (response.source === "http" ? response.status : void 0);
517
+ return error;
518
+ }
519
+ function canRefreshModelCredentials(error) {
520
+ return error.source === "http" && (error.failure.status === 401 || error.failure.status === 403) && error.code === "AUTH" && (error.upstreamCode === void 0 || error.upstreamCode === "105");
521
+ }
355
522
  function qoderHttpError(message, response) {
356
523
  const { status } = response;
357
524
  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";
@@ -382,11 +549,16 @@ function retryAfterMs(value, nowMs = Date.now()) {
382
549
  }
383
550
  //#endregion
384
551
  //#region src/qoder/transport/machine-id.ts
385
- function defaultMachineIdPaths() {
386
- return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
387
- }
552
+ /** Match the native CLI's region-specific config directory overrides. */
553
+ function qoderMachineIdPaths(region = "global", env = process.env, home = homedir()) {
554
+ const prefix = region === "china" ? "QODERCN_" : "QODER_";
555
+ const cliHome = env[`${prefix}CLI_HOME`] ?? env.GEMINI_CLI_HOME ?? home;
556
+ const configDir = env[`${prefix}CONFIG_DIR`];
557
+ return [join(configDir ? resolve(configDir) : join(cliHome, region === "china" ? ".qoder-cn" : ".qoder"), ".auth", "machine_id"), join(home, ".dsh", "qoder", "machine_id")];
558
+ }
559
+ const ephemeralMachineIds = /* @__PURE__ */ new Map();
388
560
  /** Read Qoder's machine id or create the DSH-owned fallback. */
389
- function getMachineId(paths = defaultMachineIdPaths()) {
561
+ function getMachineId(paths = qoderMachineIdPaths()) {
390
562
  for (const path of paths) {
391
563
  if (!existsSync(path)) continue;
392
564
  try {
@@ -394,8 +566,8 @@ function getMachineId(paths = defaultMachineIdPaths()) {
394
566
  if (value) return value;
395
567
  } catch {}
396
568
  }
397
- const machineId = crypto.randomUUID();
398
569
  const savePath = paths.at(-1);
570
+ const machineId = (savePath === void 0 ? void 0 : ephemeralMachineIds.get(savePath)) ?? crypto.randomUUID();
399
571
  if (savePath !== void 0) try {
400
572
  mkdirSync(dirname(savePath), { recursive: true });
401
573
  writeFileSync(savePath, machineId, {
@@ -408,6 +580,7 @@ function getMachineId(paths = defaultMachineIdPaths()) {
408
580
  if (existing) return existing;
409
581
  } catch {}
410
582
  }
583
+ if (savePath !== void 0) ephemeralMachineIds.set(savePath, machineId);
411
584
  return machineId;
412
585
  }
413
586
  //#endregion
@@ -434,7 +607,12 @@ function redactString(value) {
434
607
  }
435
608
  /** Redact credentials and bound arbitrary provider values before logging them. */
436
609
  function redactLogValue(value, depth = 0) {
437
- if (typeof value === "string") return redactString(value);
610
+ if (typeof value === "string") {
611
+ if (depth < maxDepth && value.length <= 16 * 1024 && /^[\s]*[{"]/u.test(value)) try {
612
+ return redactString(JSON.stringify(redactLogValue(JSON.parse(value), depth + 1)));
613
+ } catch {}
614
+ return redactString(value);
615
+ }
438
616
  if (value === null || typeof value !== "object") return value;
439
617
  if (depth >= maxDepth) return "[TRUNCATED]";
440
618
  if (value instanceof Error) return {
@@ -442,6 +620,10 @@ function redactLogValue(value, depth = 0) {
442
620
  message: redactString(value.message),
443
621
  ..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {},
444
622
  ..."cause" in value ? { cause: redactLogValue(value.cause, depth + 1) } : {},
623
+ ..."upstreamCode" in value ? { upstreamCode: redactLogValue(value.upstreamCode, depth + 1) } : {},
624
+ ..."source" in value ? { source: value.source } : {},
625
+ ..."httpStatus" in value ? { httpStatus: value.httpStatus } : {},
626
+ ..."failure" in value ? { failure: redactLogValue(value.failure, depth + 1) } : {},
445
627
  ...value instanceof AggregateError ? { errors: redactLogValue(value.errors, depth + 1) } : {}
446
628
  };
447
629
  if (Array.isArray(value)) return value.slice(0, maxEntries).map((item) => redactLogValue(item, depth + 1));
@@ -483,12 +665,14 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
483
665
  6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
484
666
  XcW+ML9FoCI6AOvOzwIDAQAB
485
667
  -----END PUBLIC KEY-----`;
486
- const qoderIdeVersion = "1.1.60";
668
+ const qoderIdeVersion = "1.1.65";
487
669
  const defaultUserAgent = `qoder/${qoderIdeVersion}`;
488
670
  const qoderDataPolicy = "disagree";
489
671
  const qoderLoginVersion = "v2";
490
- const qoderMachineOs = process.platform === "win32" ? process.arch === "arm64" ? "aarch64_windows" : "x86_64_windows" : process.arch === "arm64" ? "aarch64_linux" : "x86_64_linux";
491
672
  const qoderMachineTypeMagic = "5";
673
+ function qoderMachineOs(platform = process.platform, arch = process.arch) {
674
+ return `${arch === "arm64" ? "aarch64" : arch === "x64" ? "x86_64" : arch}_${platform}`;
675
+ }
492
676
  function computeSigPath(urlStr) {
493
677
  let sigPath = new URL(urlStr).pathname;
494
678
  if (sigPath.startsWith("/algo")) sigPath = sigPath.substring(5);
@@ -514,9 +698,9 @@ function buildAuthHeaders(body, requestURL, creds) {
514
698
  const userInfo = {
515
699
  uid: creds.userID,
516
700
  security_oauth_token: creds.authToken,
517
- name: creds.name || "",
518
- aid: "",
519
- email: creds.email || ""
701
+ ...creds.organizationId === void 0 ? {} : { organization_id: creds.organizationId },
702
+ ...creds.organizationTags === void 0 ? {} : { organization_tags: creds.organizationTags },
703
+ ...creds.dataPolicyAgreed === void 0 ? {} : { data_policy_agreed: creds.dataPolicyAgreed }
520
704
  };
521
705
  const infoB64 = aesEncryptCBCBase64(JSON.stringify(userInfo), aesKey);
522
706
  const cosyKey = rsaEncryptBase64(aesKey);
@@ -532,9 +716,9 @@ function buildAuthHeaders(body, requestURL, creds) {
532
716
  const sigPath = computeSigPath(requestURL);
533
717
  const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${body ? Buffer.isBuffer(body) ? body.toString("utf8") : body : ""}\n${sigPath}`;
534
718
  const sig = crypto.createHash("md5").update(sigInput).digest("hex");
535
- const bodyHash = crypto.createHash("md5").update(body || "").digest("hex");
536
- const bodyLen = body ? (Buffer.isBuffer(body) ? body.length : Buffer.from(body).length).toString() : "0";
537
719
  const machineID = creds.machineID || getMachineId();
720
+ const isModelRequest = sigPath === "/api/v2/service/pro/sse/agent_chat_generation";
721
+ const machineHostname = hostname().replace(/[^\x20-\x7e]/gu, "").trim();
538
722
  return {
539
723
  Authorization: `Bearer COSY.${payloadB64}.${sig}`,
540
724
  "Cosy-Key": cosyKey,
@@ -544,17 +728,17 @@ function buildAuthHeaders(body, requestURL, creds) {
544
728
  "Cosy-Machineid": machineID,
545
729
  "Cosy-Machinetoken": machineID,
546
730
  "Cosy-Machinetype": qoderMachineTypeMagic,
547
- "Cosy-Machineos": qoderMachineOs,
731
+ "Cosy-Machineos": qoderMachineOs(),
732
+ ...isModelRequest && machineHostname ? { "Cosy-MachineHostname": machineHostname } : {},
548
733
  "Cosy-Clienttype": "5",
549
- "Cosy-Clientip": "127.0.0.1",
550
- "Cosy-Bodyhash": bodyHash,
551
- "Cosy-Bodylength": bodyLen,
552
- "Cosy-Sigpath": sigPath,
553
- "Cosy-Data-Policy": qoderDataPolicy,
554
- "Cosy-Organization-Id": "",
555
- "Cosy-Organization-Tags": "",
556
- "Login-Version": qoderLoginVersion,
557
- "X-Request-Id": crypto.randomUUID()
734
+ "Cosy-Business-Product": "cli",
735
+ "Cosy-Business-Type": "agent",
736
+ "Cosy-Scene": "assistant",
737
+ ...isModelRequest ? {} : { "Cosy-Clientip": machineID },
738
+ "Cosy-Data-Policy": creds.dataPolicyAgreed === true ? "agree" : qoderDataPolicy,
739
+ ...creds.organizationId ? { "Cosy-Organization-Id": creds.organizationId } : {},
740
+ ...creds.organizationTags?.length ? { "Cosy-Organization-Tags": creds.organizationTags.join(",") } : {},
741
+ "Login-Version": qoderLoginVersion
558
742
  };
559
743
  }
560
744
  const defaultResponseHeaderTimeoutMs = 6e4;
@@ -681,15 +865,12 @@ async function openApiJsonRequest(fetchImpl, options) {
681
865
  const headers = {
682
866
  accept: "application/json",
683
867
  "user-agent": options.userAgent ?? defaultUserAgent,
684
- "cosy-version": "1.0.1",
868
+ "cosy-version": qoderIdeVersion,
869
+ "cosy-machineos": qoderMachineOs(),
685
870
  "cosy-clienttype": "5",
686
871
  ...options.headers
687
872
  };
688
873
  if (options.token) headers.authorization = `Bearer ${options.token}`;
689
- if (options.machineId) {
690
- headers["Cosy-MachineToken"] = options.machineId;
691
- headers["Cosy-MachineType"] = "host";
692
- }
693
874
  let bodyText;
694
875
  if (options.body !== void 0) {
695
876
  headers["content-type"] = "application/json";
@@ -733,16 +914,28 @@ async function openApiJsonRequest(fetchImpl, options) {
733
914
  const expiryBufferMs = 300 * 1e3;
734
915
  const defaultExpiryMs = 1440 * 60 * 1e3;
735
916
  const defaultAuthTimeoutMs = 15e3;
917
+ const defaultOrganizationTagsTimeoutMs = 3e3;
918
+ /** Match qodercli's seconds/milliseconds compatibility, preferring absolute expiry. */
919
+ function jobTokenExpiry(data, now = Date.now()) {
920
+ const absolute = data.expires_at ?? data.expiresAt ?? data.expire_time ?? data.expireTime;
921
+ if (absolute !== void 0) {
922
+ const parsed = typeof absolute === "string" ? Date.parse(absolute) : absolute > 0xe8d4a51000 ? absolute : absolute * 1e3;
923
+ if (Number.isFinite(parsed)) return parsed;
924
+ }
925
+ const relative = data.expires_in;
926
+ if (typeof relative === "number" && Number.isFinite(relative) && relative > 0) return now + (relative > 86400 ? relative : relative * 1e3);
927
+ return now + defaultExpiryMs;
928
+ }
736
929
  function abortedError() {
737
930
  return new QoderLlmError("Qoder authentication was aborted.", "ABORTED");
738
931
  }
739
932
  async function waitForFlight(promise, signal) {
740
933
  if (signal === void 0) return promise;
741
- if (signal.aborted) throw abortedError();
742
934
  return new Promise((resolve, reject) => {
743
935
  const onAbort = () => reject(abortedError());
744
936
  signal.addEventListener("abort", onAbort, { once: true });
745
937
  promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
938
+ if (signal.aborted) onAbort();
746
939
  });
747
940
  }
748
941
  var QoderAuthService = class {
@@ -750,14 +943,16 @@ var QoderAuthService = class {
750
943
  inFlight = /* @__PURE__ */ new Map();
751
944
  fetchImpl;
752
945
  timeoutMs;
946
+ organizationTagsTimeoutMs;
753
947
  resolveMachineId;
754
948
  region;
755
949
  logger;
756
950
  constructor(options = {}) {
757
951
  this.fetchImpl = options.fetch ?? globalThis.fetch;
758
952
  this.timeoutMs = options.timeoutMs ?? defaultAuthTimeoutMs;
759
- this.resolveMachineId = options.resolveMachineId ?? getMachineId;
953
+ this.organizationTagsTimeoutMs = options.organizationTagsTimeoutMs ?? defaultOrganizationTagsTimeoutMs;
760
954
  this.region = options.region ?? "global";
955
+ this.resolveMachineId = options.resolveMachineId ?? (() => getMachineId(qoderMachineIdPaths(this.region)));
761
956
  this.logger = options.logger;
762
957
  }
763
958
  clear(pat) {
@@ -778,7 +973,7 @@ var QoderAuthService = class {
778
973
  created.waiters = 0;
779
974
  created.settled = false;
780
975
  created.timeout = setTimeout(() => controller.abort("authentication timeout"), this.timeoutMs);
781
- created.promise = this.exchangeAndResolve(pat, controller.signal).finally(() => {
976
+ created.promise = this.exchangeAndResolve(pat, controller.signal, () => clearTimeout(created.timeout)).finally(() => {
782
977
  created.settled = true;
783
978
  clearTimeout(created.timeout);
784
979
  if (this.inFlight.get(cacheKey) === created) this.inFlight.delete(cacheKey);
@@ -797,32 +992,45 @@ var QoderAuthService = class {
797
992
  }
798
993
  }
799
994
  }
800
- async exchangeAndResolve(pat, signal) {
801
- let jobToken;
802
- let expiresAt = Date.now() + defaultExpiryMs;
995
+ async exchangeAndResolve(pat, signal, onAuthenticated) {
996
+ const machineID = this.resolveMachineId().trim();
803
997
  const data = await openApiJsonRequest(this.fetchImpl, {
804
998
  url: getQoderExchangeUrl(this.region),
805
- body: { personal_token: pat },
999
+ body: {
1000
+ personal_token: pat,
1001
+ ...machineID && machineID !== "unknown" ? { machine_id: machineID } : {}
1002
+ },
806
1003
  signal,
807
1004
  timeoutMs: this.timeoutMs,
808
1005
  logger: this.logger,
809
1006
  operation: "Auth",
810
1007
  logCategory: "auth.exchange"
811
1008
  });
812
- if (!data.token) throw new QoderLlmError("Qoder PAT exchange returned no job token.", "AUTH");
813
- jobToken = data.token;
814
- if (data.expires_at) {
815
- const parsed = Date.parse(data.expires_at);
816
- if (!Number.isNaN(parsed)) expiresAt = parsed;
817
- } else if (typeof data.expires_in === "number" && data.expires_in > 0) expiresAt = Date.now() + data.expires_in;
1009
+ const jobToken = [
1010
+ data.token,
1011
+ data.device_token,
1012
+ data.access_token
1013
+ ].find((value) => typeof value === "string" && value.length > 0);
1014
+ if (!jobToken) throw new QoderLlmError("Qoder PAT exchange returned no job token.", "AUTH");
1015
+ const expiresAt = jobTokenExpiry(data);
818
1016
  const userInfo = await retryMetadataRead(signal, () => this.fetchUserInfo(jobToken, signal));
1017
+ if (signal.aborted) throw abortedError();
1018
+ onAuthenticated();
819
1019
  const creds = {
820
1020
  userID: userInfo.userID,
821
1021
  authToken: jobToken,
822
1022
  name: userInfo.name || "Qoder User",
823
1023
  email: userInfo.email,
824
- machineID: this.resolveMachineId()
1024
+ machineID,
1025
+ ...userInfo.organizationId === void 0 ? {} : { organizationId: userInfo.organizationId },
1026
+ ...userInfo.organizationTags === void 0 ? {} : { organizationTags: userInfo.organizationTags },
1027
+ ...userInfo.dataPolicyAgreed === void 0 ? {} : { dataPolicyAgreed: userInfo.dataPolicyAgreed }
825
1028
  };
1029
+ if (creds.organizationId && creds.organizationTags === void 0) {
1030
+ const tags = await this.fetchOrganizationTags(jobToken, creds.organizationId, signal);
1031
+ if (tags !== void 0) creds.organizationTags = tags;
1032
+ }
1033
+ if (signal.aborted) throw abortedError();
826
1034
  const cacheKey = `${this.region}:${opaqueCredentialKey(pat)}`;
827
1035
  this.cache.set(cacheKey, {
828
1036
  creds,
@@ -840,13 +1048,49 @@ var QoderAuthService = class {
840
1048
  operation: "UserInfo",
841
1049
  logCategory: "auth.user-info"
842
1050
  });
843
- if (!info.id) throw new QoderLlmError("Qoder identity lookup returned no user id.", "AUTH");
1051
+ const userID = [
1052
+ info.id,
1053
+ info.user_id,
1054
+ info.uid
1055
+ ].find((value) => typeof value === "string" && value.length > 0);
1056
+ const organizationId = [
1057
+ info.orgId,
1058
+ info.organization_id,
1059
+ info.organizationId,
1060
+ info.organization?.org_id,
1061
+ info.organization?.orgId,
1062
+ info.organization?.id
1063
+ ].find((value) => typeof value === "string" && value.length > 0);
1064
+ if (!userID) throw new QoderLlmError("Qoder identity lookup returned no user id.", "AUTH");
1065
+ const organizationTags = Array.isArray(info.organization_tags) ? info.organization_tags.filter((tag) => typeof tag === "string") : void 0;
844
1066
  return {
845
- userID: info.id,
1067
+ userID,
846
1068
  email: info.email ?? "",
847
- name: info.name ?? info.username ?? ""
1069
+ name: info.name ?? info.username ?? "",
1070
+ ...organizationId ? { organizationId } : {},
1071
+ ...organizationTags === void 0 ? {} : { organizationTags },
1072
+ ...typeof info.data_policy_agreed === "boolean" ? { dataPolicyAgreed: info.data_policy_agreed } : {}
848
1073
  };
849
1074
  }
1075
+ async fetchOrganizationTags(jobToken, organizationId, signal) {
1076
+ const timeout = AbortSignal.timeout(this.organizationTagsTimeoutMs);
1077
+ const lookupSignal = AbortSignal.any([signal, timeout]);
1078
+ try {
1079
+ const { tags } = await retryMetadataRead(lookupSignal, () => openApiJsonRequest(this.fetchImpl, {
1080
+ url: `${resolveQoderEndpoints(this.region).openApiUrl}/api/v1/organizations/${encodeURIComponent(organizationId)}/tags`,
1081
+ token: jobToken,
1082
+ signal: lookupSignal,
1083
+ timeoutMs: this.organizationTagsTimeoutMs,
1084
+ logger: this.logger,
1085
+ operation: "OrganizationTags"
1086
+ }));
1087
+ return Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [];
1088
+ } catch (error) {
1089
+ if (signal.aborted) throw error;
1090
+ this.logger?.warn?.("[Qoder Auth] Organization tags unavailable; retaining subscriber organization.");
1091
+ return;
1092
+ }
1093
+ }
850
1094
  };
851
1095
  //#endregion
852
1096
  //#region src/qoder/transport/catalog-reader.ts
@@ -900,14 +1144,19 @@ async function fetchQoderModels(credentials, options = {}) {
900
1144
  function unsupported(message) {
901
1145
  return new QoderLlmError(message, "UNSUPPORTED_CONTENT");
902
1146
  }
903
- function toolResultText(block) {
904
- let text = "";
905
- for (const nested of block.content) {
906
- if (nested.type === "image") continue;
907
- if (nested.type !== "text") throw unsupported(`Qoder tool results support text only; received nested ${String(nested.type)} content.`);
908
- text += nested.text;
909
- }
910
- return text;
1147
+ /** Keep multimodal tool results attached to their call. */
1148
+ async function translateToolResultContent(blocks, context) {
1149
+ const supportedBlocks = blocks.map((block) => {
1150
+ if (block.type === "text" || block.type === "image") return block;
1151
+ throw unsupported(`Qoder tool results support text and images only; received ${String(block.type)} content.`);
1152
+ });
1153
+ const images = supportedBlocks.filter((block) => block.type === "image");
1154
+ if (images.length === 0) return { content: supportedBlocks.map((block) => block.type === "text" ? block.text : "").join("") };
1155
+ if (context.attachments !== void 0) enforceImageLimits(images, context.attachments);
1156
+ return { content: await Promise.all(supportedBlocks.map((block) => block.type === "image" ? resolveImagePart(block, context) : {
1157
+ type: "text",
1158
+ text: block.text
1159
+ })) };
911
1160
  }
912
1161
  /** Reject a batch that exceeds the deployment image policy before any upload work starts. */
913
1162
  function enforceImageLimits(images, attachments) {
@@ -1000,23 +1249,21 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1000
1249
  output.push({
1001
1250
  role: "tool",
1002
1251
  tool_call_id: callId,
1003
- content: toolResultText(result)
1252
+ ...await translateToolResultContent(result.content, context)
1004
1253
  });
1005
- const images = result.content.filter((block) => block.type === "image");
1006
- if (images.length > 0) {
1007
- if (attachments !== void 0) enforceImageLimits(images, attachments);
1008
- output.push({
1009
- role: "user",
1010
- content: [{
1011
- type: "text",
1012
- text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1013
- }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1014
- });
1015
- }
1016
1254
  }
1017
1255
  if (nonToolBlocks.length === 0) continue;
1018
1256
  }
1019
1257
  const contentBlocks = toolResults.length > 0 ? nonToolBlocks : message.content;
1258
+ if (message.role === "tool") {
1259
+ const callId = consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length);
1260
+ output.push({
1261
+ role: "tool",
1262
+ tool_call_id: callId,
1263
+ ...await translateToolResultContent(contentBlocks, context)
1264
+ });
1265
+ continue;
1266
+ }
1020
1267
  let text = "";
1021
1268
  let reasoningText = "";
1022
1269
  const userContent = [];
@@ -1024,7 +1271,6 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1024
1271
  let hasImage = false;
1025
1272
  const toolCalls = [];
1026
1273
  for (const block of contentBlocks) {
1027
- if (message.role === "tool" && block.type !== "text" && block.type !== "image") throw unsupported(`Qoder tool results support text and images only; received ${block.type} content.`);
1028
1274
  if (block.type === "text") {
1029
1275
  text += block.text;
1030
1276
  if (message.role === "user" || toolResults.length > 0) userContent.push({
@@ -1034,7 +1280,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1034
1280
  continue;
1035
1281
  }
1036
1282
  if (block.type === "image") {
1037
- if (message.role !== "user" && message.role !== "tool" && toolResults.length === 0) throw unsupported("Qoder image content is valid only in user messages.");
1283
+ if (message.role !== "user" && toolResults.length === 0) throw unsupported("Qoder image content is valid only in user messages.");
1038
1284
  hasImage = true;
1039
1285
  pendingImages.push({
1040
1286
  slot: userContent.length,
@@ -1071,25 +1317,6 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1071
1317
  });
1072
1318
  continue;
1073
1319
  }
1074
- if (message.role === "tool") {
1075
- const callId = consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length);
1076
- output.push({
1077
- role: "tool",
1078
- tool_call_id: callId,
1079
- content: text
1080
- });
1081
- if (pendingImages.length > 0) {
1082
- if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
1083
- output.push({
1084
- role: "user",
1085
- content: [{
1086
- type: "text",
1087
- text: `[${pendingImages.length} image${pendingImages.length === 1 ? "" : "s"} returned by the previous tool call]`
1088
- }, ...await Promise.all(pendingImages.map((pending) => resolveImagePart(pending.block, context)))]
1089
- });
1090
- }
1091
- continue;
1092
- }
1093
1320
  if (pendingImages.length > 0) {
1094
1321
  if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
1095
1322
  await Promise.all(pendingImages.map(async (pending) => {
@@ -1167,10 +1394,10 @@ const injectedContextOpenings = [
1167
1394
  */
1168
1395
  const hostNotificationTemplate = /^(?:Agent|Background subagent) \S{3,} (?:sent a message:|finished\b)/;
1169
1396
  /**
1170
- * Text this transport synthesizes while translating a turn's own tool results.
1397
+ * Compatibility with image markers synthesized by older transport versions.
1171
1398
  *
1172
- * A tool result carrying images becomes a user-role message, so it must be
1173
- * recognized as part of the open record rather than as new subscriber input.
1399
+ * Current tool images stay in tool-role messages. Historical user-role markers
1400
+ * still belong to the open record rather than to new subscriber input.
1174
1401
  */
1175
1402
  const syntheticMarkerTemplate = /^\[\d+ images? returned by the previous tool call\]/;
1176
1403
  /** Wire text of one user message, used for injection classification and text extraction. */
@@ -1245,7 +1472,8 @@ var QoderTurnTracker = class {
1245
1472
  requestSetId: `qoder-request-${crypto.randomUUID()}`,
1246
1473
  chatRecordId: requestId,
1247
1474
  businessId: crypto.randomUUID(),
1248
- beginAt: now
1475
+ beginAt: now,
1476
+ businessStage: "start"
1249
1477
  };
1250
1478
  if (kind === "auxiliary") {
1251
1479
  if (this.sessions.has(sessionId)) this.sessions.get(sessionId).lastTouchedAt = now;
@@ -1253,7 +1481,8 @@ var QoderTurnTracker = class {
1253
1481
  requestSetId: `qoder-request-${crypto.randomUUID()}`,
1254
1482
  chatRecordId: requestId,
1255
1483
  businessId: crypto.randomUUID(),
1256
- beginAt: now
1484
+ beginAt: now,
1485
+ businessStage: "start"
1257
1486
  };
1258
1487
  }
1259
1488
  let state = this.sessions.get(sessionId);
@@ -1301,12 +1530,15 @@ var QoderTurnTracker = class {
1301
1530
  if (current === void 0) state.claimed.delete(fingerprint);
1302
1531
  else if (current.count < claimed) state.claimed.set(fingerprint, current.count);
1303
1532
  }
1533
+ let businessStage = "processing";
1304
1534
  if (opening !== void 0 && unclaimed > 0 || modelChanged) {
1535
+ businessStage = "start";
1305
1536
  state.turnCount += 1;
1306
1537
  state.currentTurnId = turnIdFor(sessionId, state.turnCount, opening ?? state.currentModel ?? "");
1307
1538
  state.currentBusinessId = crypto.randomUUID();
1308
1539
  state.turnOpenedAt = now;
1309
1540
  } else if (state.currentTurnId === "") {
1541
+ businessStage = "start";
1310
1542
  state.turnCount += 1;
1311
1543
  state.currentTurnId = turnIdFor(sessionId, state.turnCount, state.currentModel ?? "");
1312
1544
  state.currentBusinessId = crypto.randomUUID();
@@ -1316,7 +1548,8 @@ var QoderTurnTracker = class {
1316
1548
  requestSetId: state.currentTurnId,
1317
1549
  chatRecordId: requestId,
1318
1550
  businessId: state.currentBusinessId,
1319
- beginAt: state.turnOpenedAt
1551
+ beginAt: state.turnOpenedAt,
1552
+ businessStage
1320
1553
  };
1321
1554
  }
1322
1555
  /**
@@ -1476,7 +1709,10 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1476
1709
  chat_prompt: "",
1477
1710
  image_urls: null,
1478
1711
  aliyun_user_type: "",
1479
- system: "",
1712
+ system: messages.filter((message) => message.role === "system").flatMap((message) => typeof message.content === "string" && message.content.length > 0 ? [{
1713
+ type: "text",
1714
+ text: message.content
1715
+ }] : []),
1480
1716
  messages,
1481
1717
  tools,
1482
1718
  parameters: {
@@ -1507,9 +1743,9 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
1507
1743
  },
1508
1744
  business: {
1509
1745
  product: "cli",
1510
- version: "1.0.60",
1746
+ version: qoderIdeVersion,
1511
1747
  type: "agent",
1512
- stage: "start",
1748
+ stage: identity.businessStage,
1513
1749
  id: identity.businessId,
1514
1750
  name: lastUserText.substring(0, 30),
1515
1751
  begin_at: identity.beginAt
@@ -2044,7 +2280,7 @@ var QoderUsageReader = class {
2044
2280
  const [usage, plan, status] = await Promise.all([
2045
2281
  retryMetadataRead(signal, () => this.fetchUsage(creds.authToken, signal)),
2046
2282
  this.safeFetchPlan(creds.authToken, signal),
2047
- this.safeFetchStatus(creds.authToken, creds.machineID, signal)
2283
+ this.safeFetchStatus(creds.authToken, signal)
2048
2284
  ]);
2049
2285
  const accountInfo = {
2050
2286
  profile,
@@ -2070,6 +2306,11 @@ var QoderUsageReader = class {
2070
2306
  * `dedicatedResourcePackages`: those entries carry the campaign identifiers
2071
2307
  * that the normalized list deliberately discards, and this payload crosses
2072
2308
  * the seam into the browser.
2309
+ *
2310
+ * `addOnQuota` is the pool a plan holds separately from its own subscription
2311
+ * quota. A plan whose subscription quota is zero — Free in particular —
2312
+ * reports its whole balance there, so dropping the bucket left the card with
2313
+ * nothing to render.
2073
2314
  */
2074
2315
  async fetchUsage(jobToken, signal) {
2075
2316
  const data = await openApiJsonRequest(this.fetchImpl, {
@@ -2087,6 +2328,7 @@ var QoderUsageReader = class {
2087
2328
  return {
2088
2329
  userQuota: normalizeQuota(data.userQuota),
2089
2330
  orgResourcePackage: normalizeQuota(data.orgResourcePackage),
2331
+ addOnQuota: normalizeQuota(data.add_on_quota ?? data.addOnQuota),
2090
2332
  ...dedicatedResourcePackages !== void 0 ? { dedicatedResourcePackages } : {},
2091
2333
  totalUsagePercentage: typeof data.totalUsagePercentage === "number" ? data.totalUsagePercentage : void 0,
2092
2334
  isQuotaExceeded: typeof data.isQuotaExceeded === "boolean" ? data.isQuotaExceeded : false,
@@ -2114,11 +2356,10 @@ var QoderUsageReader = class {
2114
2356
  return;
2115
2357
  }
2116
2358
  }
2117
- async fetchStatus(jobToken, machineId, signal) {
2359
+ async fetchStatus(jobToken, signal) {
2118
2360
  return normalizeStatus(await openApiJsonRequest(this.fetchImpl, {
2119
2361
  url: getQoderUserStatusUrl(this.region),
2120
2362
  token: jobToken,
2121
- machineId,
2122
2363
  signal,
2123
2364
  timeoutMs: this.timeoutMs,
2124
2365
  logger: this.logger,
@@ -2126,9 +2367,9 @@ var QoderUsageReader = class {
2126
2367
  logCategory: "account.status"
2127
2368
  }));
2128
2369
  }
2129
- async safeFetchStatus(jobToken, machineId, signal) {
2370
+ async safeFetchStatus(jobToken, signal) {
2130
2371
  try {
2131
- return await this.fetchStatus(jobToken, machineId, signal);
2372
+ return await this.fetchStatus(jobToken, signal);
2132
2373
  } catch (error) {
2133
2374
  if (signal?.aborted) throw error;
2134
2375
  this.logger?.warn?.("[Qoder Status] Failed to load user status (degraded)", error instanceof Error ? error.message : error);
@@ -2148,23 +2389,27 @@ var QoderUsageReader = class {
2148
2389
  */
2149
2390
  const qoderCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
2150
2391
  const qoderStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2151
- function qoderEncodeBody(plaintext) {
2392
+ const qoderEncodeTable = /* @__PURE__ */ new Uint8Array(128);
2393
+ for (let i = 0; i < 64; i++) qoderEncodeTable[qoderStdAlphabet.charCodeAt(i)] = qoderCustomAlphabet.charCodeAt(i);
2394
+ qoderEncodeTable["=".charCodeAt(0)] = "$".charCodeAt(0);
2395
+ /** Encode directly into wire bytes without building per-character string ropes. */
2396
+ function qoderEncodeBodyBytes(plaintext) {
2152
2397
  const std = Buffer.isBuffer(plaintext) ? plaintext.toString("base64") : Buffer.from(plaintext).toString("base64");
2153
2398
  const n = std.length;
2154
2399
  const a = Math.floor(n / 3);
2155
- const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
2156
- let out = "";
2157
- for (let i = 0; i < n; i++) {
2158
- const c = rearranged[i];
2159
- if (c === "=") out += "$";
2160
- else {
2161
- const idx = qoderStdAlphabet.indexOf(c);
2162
- if (idx >= 0) out += qoderCustomAlphabet[idx];
2163
- else out += c;
2164
- }
2165
- }
2400
+ const out = Buffer.allocUnsafe(n);
2401
+ let offset = 0;
2402
+ const writeRange = (start, end) => {
2403
+ for (let i = start; i < end; i++) out[offset++] = qoderEncodeTable[std.charCodeAt(i)];
2404
+ };
2405
+ writeRange(n - a, n);
2406
+ writeRange(a, n - a);
2407
+ writeRange(0, a);
2166
2408
  return out;
2167
2409
  }
2410
+ function qoderEncodeBody(plaintext) {
2411
+ return qoderEncodeBodyBytes(plaintext).toString("latin1");
2412
+ }
2168
2413
  //#endregion
2169
2414
  //#region src/qoder/transport/search.ts
2170
2415
  /**
@@ -2335,163 +2580,6 @@ var QoderSearchClient = class {
2335
2580
  }
2336
2581
  };
2337
2582
  //#endregion
2338
- //#region src/qoder/transport/wire/thinking.ts
2339
- const thinkingTags = [{
2340
- open: "<thinking>",
2341
- close: "</thinking>"
2342
- }, {
2343
- open: "<think>",
2344
- close: "</think>"
2345
- }];
2346
- const allTags = thinkingTags.flatMap((tag) => [tag.open, tag.close]);
2347
- function trailingPrefixLength(text, candidates) {
2348
- let best = 0;
2349
- for (const candidate of candidates) {
2350
- const limit = Math.min(text.length, candidate.length - 1);
2351
- for (let length = limit; length > best; length--) if (text.endsWith(candidate.slice(0, length))) {
2352
- best = length;
2353
- break;
2354
- }
2355
- }
2356
- return best;
2357
- }
2358
- function earliestTag(text, kind) {
2359
- let found;
2360
- for (const tag of thinkingTags) {
2361
- const position = text.indexOf(tag[kind]);
2362
- if (position !== -1 && (found === void 0 || position < found.position)) found = {
2363
- position,
2364
- tag
2365
- };
2366
- }
2367
- return found;
2368
- }
2369
- function trimSeparator(text) {
2370
- if (text.startsWith("\n\n")) return text.slice(2);
2371
- if (text.startsWith("\n")) return text.slice(1);
2372
- return text;
2373
- }
2374
- var NativeReasoningStripper = class {
2375
- buffer = "";
2376
- push(chunk) {
2377
- this.buffer += chunk;
2378
- let output = "";
2379
- while (this.buffer) {
2380
- let firstPosition = -1;
2381
- let firstTag = "";
2382
- for (const tag of allTags) {
2383
- const position = this.buffer.indexOf(tag);
2384
- if (position !== -1 && (firstPosition === -1 || position < firstPosition)) {
2385
- firstPosition = position;
2386
- firstTag = tag;
2387
- }
2388
- }
2389
- if (firstPosition !== -1) {
2390
- output += this.buffer.slice(0, firstPosition);
2391
- this.buffer = this.buffer.slice(firstPosition + firstTag.length);
2392
- continue;
2393
- }
2394
- const held = trailingPrefixLength(this.buffer, allTags);
2395
- output += this.buffer.slice(0, this.buffer.length - held);
2396
- this.buffer = this.buffer.slice(this.buffer.length - held);
2397
- break;
2398
- }
2399
- return output;
2400
- }
2401
- finish() {
2402
- const output = allTags.some((tag) => tag.startsWith(this.buffer)) ? "" : this.buffer;
2403
- this.buffer = "";
2404
- return output;
2405
- }
2406
- };
2407
- var QoderThinkingParser = class {
2408
- buffer = "";
2409
- mode = "text";
2410
- closingTag = thinkingTags[0].close;
2411
- native = new NativeReasoningStripper();
2412
- pushReasoning(chunk) {
2413
- const text = this.native.push(chunk);
2414
- return text ? [{
2415
- type: "reasoning",
2416
- text
2417
- }] : [];
2418
- }
2419
- pushContent(chunk) {
2420
- const output = [];
2421
- const pendingReasoning = this.native.finish();
2422
- if (pendingReasoning) output.push({
2423
- type: "reasoning",
2424
- text: pendingReasoning
2425
- });
2426
- this.buffer += chunk;
2427
- while (this.buffer) {
2428
- if (this.mode === "reasoning") {
2429
- const closeAt = this.buffer.indexOf(this.closingTag);
2430
- if (closeAt !== -1) {
2431
- if (closeAt > 0) output.push({
2432
- type: "reasoning",
2433
- text: this.buffer.slice(0, closeAt)
2434
- });
2435
- this.buffer = trimSeparator(this.buffer.slice(closeAt + this.closingTag.length));
2436
- this.mode = "text";
2437
- continue;
2438
- }
2439
- const held = trailingPrefixLength(this.buffer, [this.closingTag]);
2440
- const safeLength = this.buffer.length - held;
2441
- if (safeLength > 0) output.push({
2442
- type: "reasoning",
2443
- text: this.buffer.slice(0, safeLength)
2444
- });
2445
- this.buffer = this.buffer.slice(safeLength);
2446
- break;
2447
- }
2448
- const opener = earliestTag(this.buffer, "open");
2449
- const closer = earliestTag(this.buffer, "close");
2450
- if (opener !== void 0 && (closer === void 0 || opener.position < closer.position)) {
2451
- if (opener.position > 0) output.push({
2452
- type: "text",
2453
- text: this.buffer.slice(0, opener.position)
2454
- });
2455
- this.buffer = this.buffer.slice(opener.position + opener.tag.open.length);
2456
- this.closingTag = opener.tag.close;
2457
- this.mode = "reasoning";
2458
- continue;
2459
- }
2460
- if (closer !== void 0) {
2461
- if (closer.position > 0) output.push({
2462
- type: "text",
2463
- text: this.buffer.slice(0, closer.position)
2464
- });
2465
- this.buffer = trimSeparator(this.buffer.slice(closer.position + closer.tag.close.length));
2466
- continue;
2467
- }
2468
- const held = trailingPrefixLength(this.buffer, allTags);
2469
- const safeLength = this.buffer.length - held;
2470
- if (safeLength > 0) output.push({
2471
- type: "text",
2472
- text: this.buffer.slice(0, safeLength)
2473
- });
2474
- this.buffer = this.buffer.slice(safeLength);
2475
- break;
2476
- }
2477
- return output;
2478
- }
2479
- finish() {
2480
- const output = [];
2481
- const pendingReasoning = this.native.finish();
2482
- if (pendingReasoning) output.push({
2483
- type: "reasoning",
2484
- text: pendingReasoning
2485
- });
2486
- if (this.buffer) output.push({
2487
- type: this.mode,
2488
- text: this.buffer
2489
- });
2490
- this.buffer = "";
2491
- return output;
2492
- }
2493
- };
2494
- //#endregion
2495
2583
  //#region src/qoder/transport/wire/sse.ts
2496
2584
  /** Decode Qoder's outer SSE envelope and inner model chunks. */
2497
2585
  const doneMarker = "[DONE]";
@@ -2499,31 +2587,7 @@ const defaultMaxSseBufferChars = 2 * 1024 * 1024;
2499
2587
  function malformed(message) {
2500
2588
  return new QoderLlmError(message, "MALFORMED_RESPONSE");
2501
2589
  }
2502
- function extractErrorDetail(envelope) {
2503
- const raw = envelope.body ?? envelope.message;
2504
- if (!raw) return void 0;
2505
- if (typeof raw !== "string") return JSON.stringify(raw);
2506
- const trimmed = raw.trim();
2507
- try {
2508
- const parsed = JSON.parse(trimmed);
2509
- if (parsed && typeof parsed === "object") {
2510
- let detailMsg;
2511
- if (typeof parsed.details === "string") try {
2512
- const inner = JSON.parse(parsed.details);
2513
- if (typeof inner?.error?.message === "string") detailMsg = inner.error.message.trim();
2514
- else if (typeof inner?.message === "string") detailMsg = inner.message.trim();
2515
- } catch {}
2516
- const msg = typeof parsed.message === "string" ? parsed.message.trim() : void 0;
2517
- const code = typeof parsed.code === "string" ? parsed.code.trim() : void 0;
2518
- const err = typeof parsed.error === "string" ? parsed.error.trim() : void 0;
2519
- const text = detailMsg || msg || err;
2520
- if (code && text) return `${code}: ${text}`;
2521
- return text || code || trimmed;
2522
- }
2523
- } catch {}
2524
- return trimmed;
2525
- }
2526
- function parseEnvelope(rawData) {
2590
+ function parseEnvelope(rawData, options) {
2527
2591
  let value;
2528
2592
  try {
2529
2593
  value = JSON.parse(rawData);
@@ -2534,13 +2598,13 @@ function parseEnvelope(rawData) {
2534
2598
  const envelope = value;
2535
2599
  if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
2536
2600
  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.");
2537
- if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) {
2538
- const detail = extractErrorDetail(envelope);
2539
- throw qoderHttpError(detail ? `Qoder service returned upstream error status ${envelope.statusCodeValue}: ${detail}` : `Qoder service returned upstream error status ${envelope.statusCodeValue}.`, {
2540
- status: envelope.statusCodeValue,
2541
- cause: envelope.body
2542
- });
2543
- }
2601
+ if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) throw qoderModelError(`Qoder service returned upstream error status ${envelope.statusCodeValue}`, {
2602
+ status: envelope.statusCodeValue,
2603
+ cause: envelope.body ?? envelope.message,
2604
+ source: "sse",
2605
+ headers: options.headers,
2606
+ httpStatus: options.httpStatus
2607
+ });
2544
2608
  if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
2545
2609
  return envelope;
2546
2610
  }
@@ -2587,7 +2651,6 @@ function carriesStreamDelta(delta) {
2587
2651
  async function* parseQoderSse(stream, options = {}) {
2588
2652
  const reader = stream.getReader();
2589
2653
  const decoder = new TextDecoder();
2590
- const thinkingParser = new QoderThinkingParser();
2591
2654
  const toolCalls = /* @__PURE__ */ new Map();
2592
2655
  let buffer = "";
2593
2656
  let sourceEnded = false;
@@ -2691,7 +2754,7 @@ async function* parseQoderSse(stream, options = {}) {
2691
2754
  sawDone = true;
2692
2755
  break;
2693
2756
  }
2694
- const envelope = parseEnvelope(rawData);
2757
+ const envelope = parseEnvelope(rawData, options);
2695
2758
  const body = typeof envelope.body === "string" ? envelope.body.trim() : "";
2696
2759
  if (!body) continue;
2697
2760
  if (body === doneMarker) {
@@ -2708,8 +2771,14 @@ async function* parseQoderSse(stream, options = {}) {
2708
2771
  } else if (carriesStreamDelta(choice.delta)) sawFinishReason = false;
2709
2772
  const delta = choice.delta;
2710
2773
  if (!delta) continue;
2711
- 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;
2712
- if (typeof delta.content === "string" && delta.content) for (const segment of thinkingParser.pushContent(delta.content)) for (const chunk of appendSegment(segment)) yield chunk;
2774
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content) for (const chunk of appendSegment({
2775
+ type: "reasoning",
2776
+ text: delta.reasoning_content
2777
+ })) yield chunk;
2778
+ if (typeof delta.content === "string" && delta.content) for (const chunk of appendSegment({
2779
+ type: "text",
2780
+ text: delta.content
2781
+ })) yield chunk;
2713
2782
  if (delta.tool_calls !== void 0) {
2714
2783
  if (!Array.isArray(delta.tool_calls)) throw malformed("Qoder tool-call delta is not an array.");
2715
2784
  for (const rawCall of delta.tool_calls) {
@@ -2776,7 +2845,6 @@ async function* parseQoderSse(stream, options = {}) {
2776
2845
  reader.releaseLock();
2777
2846
  }
2778
2847
  if (!sawDone && !sawFinishReason) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
2779
- for (const segment of thinkingParser.finish()) for (const chunk of appendSegment(segment)) yield chunk;
2780
2848
  for (const chunk of closeTextual()) yield chunk;
2781
2849
  for (const [, state] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
2782
2850
  if (!state.id || !state.name) throw malformed("Qoder completed an unidentifiable tool call.");
@@ -2836,8 +2904,8 @@ function aborted$1(message) {
2836
2904
  }
2837
2905
  async function* streamQoderChat(options, model, credentials, messages, dependencies) {
2838
2906
  const request = await buildQoderRequestBody(options, credentials.userID, messages, model);
2839
- const encodedBody = qoderEncodeBody(JSON.stringify(request));
2840
- const encodedBytes = Buffer.from(encodedBody, "utf8");
2907
+ request.session_type = dependencies.region === "china" ? "qoderclicn" : "qodercli";
2908
+ const encodedBytes = qoderEncodeBodyBytes(JSON.stringify(request));
2841
2909
  const chatUrl = getQoderChatUrl(dependencies.region);
2842
2910
  const requestController = new AbortController();
2843
2911
  let headerTimedOut = false;
@@ -2849,7 +2917,7 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2849
2917
  let lastActivityAt = startedAt;
2850
2918
  const onCallerAbort = () => requestController.abort(options.signal?.reason);
2851
2919
  options.signal?.addEventListener("abort", onCallerAbort, { once: true });
2852
- const headerTimer = setTimeout(() => {
2920
+ let headerTimer = setTimeout(() => {
2853
2921
  headerTimedOut = true;
2854
2922
  requestController.abort("response header timeout");
2855
2923
  }, dependencies.responseHeaderTimeoutMs);
@@ -2872,58 +2940,72 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
2872
2940
  contextTier: markedTiers.length === 1 ? markedTiers[0][0] : null,
2873
2941
  context_length: request.parameters.context_length ?? null
2874
2942
  }));
2875
- const response = await dependencies.fetch(chatUrl, {
2876
- method: "POST",
2877
- headers: {
2878
- "content-type": "application/json",
2879
- "accept": "text/event-stream",
2880
- "cache-control": "no-cache",
2881
- "accept-encoding": "identity",
2882
- "x-model-key": options.model || "cmodel",
2883
- "x-model-source": model?.source || "system",
2884
- ...attributionHeaders(),
2885
- ...buildAuthHeaders(encodedBytes, chatUrl, credentials)
2886
- },
2887
- body: encodedBytes,
2888
- signal: requestController.signal
2889
- });
2890
- clearTimeout(headerTimer);
2891
- reqId = qoderRequestId(response.headers);
2892
- dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
2893
- region: dependencies.region,
2894
- status: response.status,
2895
- durationMs: Math.round(performance.now() - startedAt),
2896
- ...reqId === void 0 ? {} : { requestId: reqId }
2897
- });
2898
- resetIdleTimer();
2899
- if (!response.ok) {
2943
+ let response;
2944
+ let activeCredentials = credentials;
2945
+ for (let attempt = 0; attempt < 2; attempt++) {
2946
+ if (options.signal?.aborted) throw aborted$1("Request was aborted.");
2947
+ if (attempt > 0) headerTimer = setTimeout(() => {
2948
+ headerTimedOut = true;
2949
+ requestController.abort("response header timeout");
2950
+ }, dependencies.responseHeaderTimeoutMs);
2951
+ response = await dependencies.fetch(chatUrl, {
2952
+ method: "POST",
2953
+ headers: {
2954
+ "content-type": "application/json",
2955
+ "accept": "text/event-stream",
2956
+ "cache-control": "no-cache",
2957
+ "connection": "keep-alive",
2958
+ "accept-encoding": "identity",
2959
+ "x-model-key": options.model || "cmodel",
2960
+ "x-model-source": model?.source || "system",
2961
+ ...attributionHeaders(),
2962
+ ...buildAuthHeaders(encodedBytes, chatUrl, activeCredentials)
2963
+ },
2964
+ body: encodedBytes,
2965
+ signal: requestController.signal
2966
+ });
2967
+ clearTimeout(headerTimer);
2968
+ reqId = qoderRequestId(response.headers);
2969
+ dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
2970
+ region: dependencies.region,
2971
+ status: response.status,
2972
+ attempt: attempt + 1,
2973
+ durationMs: Math.round(performance.now() - startedAt),
2974
+ ...reqId === void 0 ? {} : { requestId: reqId }
2975
+ });
2976
+ resetIdleTimer();
2977
+ if (response.ok) break;
2900
2978
  let bodyText = "";
2901
2979
  try {
2902
- bodyText = (await response.text()).trim();
2903
- } catch {}
2904
- let detail = "";
2905
- if (bodyText) try {
2906
- const parsed = JSON.parse(bodyText);
2907
- const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message.trim() : void 0;
2908
- const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : void 0;
2909
- if (code && msg) detail = `: ${code}: ${msg}`;
2910
- else if (msg) detail = `: ${msg}`;
2911
- else detail = `: ${bodyText.slice(0, 300)}`;
2912
- } catch {
2913
- detail = `: ${bodyText.slice(0, 300)}`;
2980
+ bodyText = (await readLimitedText(response, defaultMaxErrorBytes, "Qoder model error response")).trim();
2981
+ } catch (error) {
2982
+ if (requestController.signal.aborted) throw error;
2914
2983
  }
2915
- throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}${detail}.`, {
2984
+ const failure = qoderModelError(`Qoder upstream service returned HTTP ${response.status}`, {
2916
2985
  status: response.status,
2917
2986
  headers: response.headers,
2918
- cause: bodyText || void 0
2987
+ cause: bodyText || void 0,
2988
+ source: "http"
2919
2989
  });
2990
+ if (attempt === 0 && dependencies.refreshCredentials && canRefreshModelCredentials(failure)) {
2991
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
2992
+ activeCredentials = await dependencies.refreshCredentials(activeCredentials, options.signal);
2993
+ if (activeCredentials.userID !== credentials.userID) throw new QoderLlmError("Qoder subscriber identity changed during authentication recovery.", "AUTH");
2994
+ continue;
2995
+ }
2996
+ throw failure;
2920
2997
  }
2998
+ if (!response) throw new QoderLlmError("Qoder response is missing.", "EMPTY_RESPONSE");
2921
2999
  if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
2922
3000
  let firstChunkDurationMs;
2923
3001
  let tokenUsage;
2924
3002
  let finishReason;
2925
3003
  const streamStartedAt = performance.now();
2926
- for await (const chunk of parseQoderSse(response.body, { onActivity: resetIdleTimer })) {
3004
+ for await (const chunk of parseQoderSse(response.body, {
3005
+ onActivity: resetIdleTimer,
3006
+ headers: response.headers,
3007
+ httpStatus: response.status
3008
+ })) {
2927
3009
  chunkCount++;
2928
3010
  if (firstChunkDurationMs === void 0) {
2929
3011
  firstChunkDurationMs = Math.round(performance.now() - startedAt);
@@ -3110,7 +3192,13 @@ var DefaultQoderTransport = class {
3110
3192
  logger: this.logger,
3111
3193
  region: this.region,
3112
3194
  responseHeaderTimeoutMs: this.responseHeaderTimeoutMs,
3113
- streamIdleTimeoutMs: this.streamIdleTimeoutMs
3195
+ streamIdleTimeoutMs: this.streamIdleTimeoutMs,
3196
+ refreshCredentials: async (rejected, signal) => {
3197
+ const current = await this.auth.getCredentials(pat, signal);
3198
+ if (current.authToken !== rejected.authToken) return current;
3199
+ this.auth.clear(pat);
3200
+ return this.auth.getCredentials(pat, signal);
3201
+ }
3114
3202
  });
3115
3203
  }
3116
3204
  };
@@ -3180,6 +3268,149 @@ function modelsFor(config, region) {
3180
3268
  return resolveModels(config.modelsByRegion?.[region]);
3181
3269
  }
3182
3270
  //#endregion
3271
+ //#region src/dsh/catalog-lifecycle.ts
3272
+ /** Region-scoped Qoder model discovery, effective catalogs, and advisory persistence. */
3273
+ var QoderCatalogLifecycle = class {
3274
+ options;
3275
+ catalogs = {
3276
+ global: {
3277
+ models: [],
3278
+ generation: 0,
3279
+ expiresAt: 0,
3280
+ dirty: false,
3281
+ retryRequested: false
3282
+ },
3283
+ china: {
3284
+ models: [],
3285
+ generation: 0,
3286
+ expiresAt: 0,
3287
+ dirty: false,
3288
+ retryRequested: false
3289
+ }
3290
+ };
3291
+ settings;
3292
+ disposed = false;
3293
+ activeTransport;
3294
+ activeRegion;
3295
+ constructor(options) {
3296
+ this.options = options;
3297
+ }
3298
+ /** Observe every transport replacement, including switching away and back. */
3299
+ observeTransport() {
3300
+ const transport = this.options.resolveTransport();
3301
+ const region = this.options.region();
3302
+ if (transport === this.activeTransport && region === this.activeRegion) return;
3303
+ if (this.activeRegion !== void 0) {
3304
+ const previous = this.catalogs[this.activeRegion];
3305
+ previous.generation++;
3306
+ previous.inflight = void 0;
3307
+ }
3308
+ this.activeTransport = transport;
3309
+ this.activeRegion = region;
3310
+ const state = this.catalogs[region];
3311
+ state.generation++;
3312
+ state.expiresAt = 0;
3313
+ state.inflight = void 0;
3314
+ }
3315
+ models() {
3316
+ this.observeTransport();
3317
+ const region = this.options.region();
3318
+ return mergeQoderDiscoveryMetadata(this.options.configuredModels(region), this.catalogs[region].models);
3319
+ }
3320
+ /** Automatic reads join the newest discovery and never wait for settings writes. */
3321
+ async refresh() {
3322
+ while (!this.disposed) {
3323
+ this.observeTransport();
3324
+ const region = this.options.region();
3325
+ const state = this.catalogs[region];
3326
+ const transport = this.activeTransport;
3327
+ const pending = state.inflight ?? (Date.now() >= state.expiresAt ? this.discover() : void 0);
3328
+ await pending?.catch(() => {});
3329
+ this.observeTransport();
3330
+ if (this.activeTransport !== transport || this.activeRegion !== region) continue;
3331
+ if (state.inflight && state.inflight !== pending) continue;
3332
+ return;
3333
+ }
3334
+ }
3335
+ /** Explicit discovery supersedes older work as soon as it starts, even if it fails. */
3336
+ discover(signal, requestTransport) {
3337
+ if (this.disposed || signal?.aborted) return Promise.reject(new QoderLlmError("Qoder model discovery was aborted.", "ABORTED"));
3338
+ this.observeTransport();
3339
+ const region = this.options.region();
3340
+ const state = this.catalogs[region];
3341
+ const transport = this.activeTransport;
3342
+ const generation = ++state.generation;
3343
+ const pending = Promise.resolve().then(() => (requestTransport ?? transport).discoverModels(signal)).then((models) => {
3344
+ this.observeTransport();
3345
+ if (!this.disposed && this.activeTransport === transport && this.activeRegion === region && state.generation === generation) {
3346
+ state.models = models.map(cloneCatalogModel);
3347
+ state.expiresAt = Date.now() + 300 * 1e3;
3348
+ state.dirty = true;
3349
+ this.schedulePersistence(region);
3350
+ try {
3351
+ Promise.resolve(this.options.onDiscovered?.(transport, state.models)).catch(() => {});
3352
+ } catch {}
3353
+ }
3354
+ return models;
3355
+ }).finally(() => {
3356
+ if (state.inflight === pending) state.inflight = void 0;
3357
+ });
3358
+ state.inflight = pending;
3359
+ return pending;
3360
+ }
3361
+ bindSettings(settings) {
3362
+ this.settings = settings;
3363
+ this.retryPersistence();
3364
+ }
3365
+ /** Called by settings changes/reattachment; no timer retries or fallback writes. */
3366
+ retryPersistence() {
3367
+ for (const region of ["global", "china"]) {
3368
+ const state = this.catalogs[region];
3369
+ if (state.models.length === 0) continue;
3370
+ state.dirty = true;
3371
+ this.schedulePersistence(region);
3372
+ }
3373
+ }
3374
+ dispose() {
3375
+ this.disposed = true;
3376
+ this.settings = void 0;
3377
+ for (const state of Object.values(this.catalogs)) state.generation++;
3378
+ }
3379
+ schedulePersistence(region) {
3380
+ const state = this.catalogs[region];
3381
+ const settings = this.settings;
3382
+ if (this.disposed || !settings || !state.dirty) return;
3383
+ if (state.writing) {
3384
+ state.retryRequested = true;
3385
+ return;
3386
+ }
3387
+ state.retryRequested = false;
3388
+ const writing = Promise.resolve().then(() => this.persist(region, settings)).catch((error) => {
3389
+ this.options.onPersistenceError?.(error);
3390
+ }).finally(() => {
3391
+ if (state.writing === writing) state.writing = void 0;
3392
+ if (this.settings !== settings || state.retryRequested) this.schedulePersistence(region);
3393
+ });
3394
+ state.writing = writing;
3395
+ }
3396
+ async persist(region, settings) {
3397
+ const state = this.catalogs[region];
3398
+ while (!this.disposed && this.settings === settings && state.dirty) {
3399
+ const snapshot = settings.read(region);
3400
+ if (!snapshot) return;
3401
+ const enriched = modelsFor(Config({ modelsByRegion: { [region]: mergeQoderDiscoveryMetadata(snapshot.models, state.models) } }), region);
3402
+ state.dirty = false;
3403
+ if (hasSameQoderDiscoveryMetadata(snapshot.models, enriched)) continue;
3404
+ try {
3405
+ await settings.write(region, enriched, snapshot.revision);
3406
+ } catch (error) {
3407
+ state.dirty = true;
3408
+ if (!(error instanceof SettingsConflictError)) throw error;
3409
+ }
3410
+ }
3411
+ }
3412
+ };
3413
+ //#endregion
3183
3414
  //#region src/dsh/provider.ts
3184
3415
  /** DSH model provider route, matching the plugin package name. */
3185
3416
  const QODER_PROVIDER_ID = "dsh-provider-qoder";
@@ -3201,12 +3432,12 @@ var QoderAdapter = class extends LlmAdapter {
3201
3432
  catalogModels;
3202
3433
  providerId;
3203
3434
  providerName;
3204
- onModelsDiscovered;
3205
- discoveries = /* @__PURE__ */ new WeakMap();
3435
+ catalog;
3206
3436
  historyRegion;
3207
3437
  historyRegionChanged = false;
3208
3438
  region;
3209
3439
  sessionTiers = /* @__PURE__ */ new Map();
3440
+ tierListeners = /* @__PURE__ */ new Set();
3210
3441
  sessions;
3211
3442
  agents;
3212
3443
  constructor(options) {
@@ -3215,8 +3446,13 @@ var QoderAdapter = class extends LlmAdapter {
3215
3446
  this.catalogModels = options.models && options.models.length > 0 ? options.models : defaultModels;
3216
3447
  this.providerId = options.providerId ?? "dsh-provider-qoder";
3217
3448
  this.providerName = options.providerName ?? "Qoder";
3218
- this.onModelsDiscovered = options.onModelsDiscovered;
3219
3449
  this.region = options.region ?? (() => "global");
3450
+ this.catalog = options.catalog ?? new QoderCatalogLifecycle({
3451
+ resolveTransport: this.resolveTransport,
3452
+ region: this.region,
3453
+ configuredModels: () => this.catalogModels,
3454
+ onDiscovered: options.onModelsDiscovered
3455
+ });
3220
3456
  this.historyRegion = this.region();
3221
3457
  this.sessions = options.sessions;
3222
3458
  this.agents = options.agents;
@@ -3229,6 +3465,31 @@ var QoderAdapter = class extends LlmAdapter {
3229
3465
  sessionId,
3230
3466
  modelId
3231
3467
  ]), tierKey);
3468
+ for (const listener of this.tierListeners) try {
3469
+ listener({
3470
+ region,
3471
+ sessionId,
3472
+ modelId
3473
+ });
3474
+ } catch {}
3475
+ }
3476
+ subscribeSessionTiers(listener) {
3477
+ this.tierListeners.add(listener);
3478
+ return () => {
3479
+ this.tierListeners.delete(listener);
3480
+ };
3481
+ }
3482
+ readSessionTier(scope) {
3483
+ const { region, sessionId, modelId } = scope;
3484
+ if (region !== this.region() || !sessionId || this.sessions && !this.sessions.get(sessionId)) throw new QoderLlmError("Invalid session context tier scope.", "INVALID_REQUEST");
3485
+ const model = this.resolveEffectiveModelForSession(modelId, sessionId);
3486
+ const tier = model && resolveContextTier(model, model.contextTier);
3487
+ if (!tier) throw new QoderLlmError("Model has no context tiers.", "INVALID_REQUEST");
3488
+ return {
3489
+ ...scope,
3490
+ tierKey: tier.key,
3491
+ tokenCount: tier.tokenCount
3492
+ };
3232
3493
  }
3233
3494
  getSessionTier(sessionId, modelId) {
3234
3495
  return this.sessionTiers.get(JSON.stringify([
@@ -3265,52 +3526,17 @@ var QoderAdapter = class extends LlmAdapter {
3265
3526
  name: this.providerName
3266
3527
  };
3267
3528
  }
3268
- /** Refresh advertised metadata on catalog reads, sharing a five-minute transport-local cache. */
3269
3529
  async listModels(provider) {
3270
- const transport = this.resolveTransport();
3271
- let cached = this.discoveries.get(transport);
3272
- if (cached === void 0) {
3273
- cached = { expiresAt: 0 };
3274
- this.discoveries.set(transport, cached);
3275
- }
3276
- const entry = cached;
3277
- if (entry.inflight === void 0 && Date.now() >= entry.expiresAt) entry.inflight = Promise.resolve().then(() => transport.discoverModels()).then((models) => {
3278
- if (this.discoveries.get(transport) !== entry || this.resolveTransport() !== transport) return;
3279
- entry.models = models;
3280
- entry.expiresAt = Date.now() + 300 * 1e3;
3281
- this.publishDiscoveredModels(transport, models);
3282
- }).catch(() => {}).finally(() => {
3283
- entry.inflight = void 0;
3284
- });
3285
- await entry.inflight;
3286
- if (this.resolveTransport() !== transport) return this.listModels(provider);
3530
+ await this.catalog.refresh();
3287
3531
  return this.effectiveModels().map((model) => modelInfo(provider, model));
3288
3532
  }
3289
- /**
3290
- * Announce one accepted discovery without joining the catalog read.
3291
- *
3292
- * Persisting metadata can queue behind unrelated settings writes, so awaiting
3293
- * it here would stall every `listModels` call on settings storage.
3294
- */
3295
- publishDiscoveredModels(transport, models) {
3296
- try {
3297
- Promise.resolve(this.onModelsDiscovered?.(transport, models)).catch(() => {});
3298
- } catch {}
3299
- }
3300
3533
  effectiveModels() {
3301
- return mergeQoderDiscoveryMetadata(this.catalogModels, this.discoveries.get(this.resolveTransport())?.models ?? []);
3534
+ return this.catalog.models();
3302
3535
  }
3303
3536
  replaceModels(models) {
3304
3537
  this.observeRegion();
3305
3538
  this.catalogModels = models;
3306
3539
  }
3307
- /** Publish an explicit discovery without letting older in-flight reads overwrite it. */
3308
- updateDiscoveredModels(transport, models) {
3309
- this.discoveries.set(transport, {
3310
- models,
3311
- expiresAt: Date.now() + 300 * 1e3
3312
- });
3313
- }
3314
3540
  resolveModel(provider, modelId, signal) {
3315
3541
  if (signal?.aborted) return Promise.reject(new QoderLlmError("Qoder model resolution was aborted.", "ABORTED"));
3316
3542
  const sessionId = this.agents?.currentInitiator()?.session?.id;
@@ -3469,7 +3695,9 @@ const qoderRpcApiPath = `/api/qoder-subscription`;
3469
3695
  const qoderRpcEndpoints = [
3470
3696
  "account",
3471
3697
  "models",
3472
- "sessionTier"
3698
+ "sessionTier",
3699
+ "readSessionTier",
3700
+ "sessionTierEvents"
3473
3701
  ];
3474
3702
  /** Absolute Fetch-route path of one endpoint. */
3475
3703
  function qoderRpcPath(endpoint) {
@@ -3492,16 +3720,18 @@ function isQoderRpcEndpoint(value) {
3492
3720
  * @returns disposer removing every registered route.
3493
3721
  * @throws when Connection exposes no exact Fetch registry.
3494
3722
  */
3495
- function registerQoderRpc(ctx, handler) {
3723
+ function registerQoderRpc(ctx, handler, subscribeTiers) {
3496
3724
  const registry = ctx.connection?.fetch;
3497
3725
  if (typeof registry?.register !== "function") throw new Error("provider-qoder: connection exposes no exact Fetch route registry");
3726
+ const streams = /* @__PURE__ */ new Set();
3498
3727
  const disposers = qoderRpcEndpoints.map((endpoint) => registry.register({
3499
3728
  path: qoderRpcPath(endpoint),
3500
3729
  methods: ["POST"],
3501
3730
  requestBody: "buffered",
3502
- fetch: (request) => handleQoderRpcRequest(endpoint, handler, request)
3731
+ fetch: (request) => endpoint === "sessionTierEvents" && subscribeTiers ? Promise.resolve(sessionTierEvents(request, subscribeTiers, streams)) : handleQoderRpcRequest(endpoint, handler, request)
3503
3732
  }));
3504
3733
  return () => {
3734
+ for (const close of streams) close();
3505
3735
  for (const dispose of disposers) dispose();
3506
3736
  };
3507
3737
  }
@@ -3541,6 +3771,43 @@ async function handleQoderRpcRequest(endpoint, handler, request) {
3541
3771
  return new Response(`handler failure: ${String(error)}`, { status: 500 });
3542
3772
  }
3543
3773
  }
3774
+ /** Authenticated change stream; reconnecting clients reread on the initial ready item. */
3775
+ function sessionTierEvents(request, subscribe, streams) {
3776
+ let close = () => {};
3777
+ const encoder = new TextEncoder();
3778
+ const body = new ReadableStream({
3779
+ start(controller) {
3780
+ let ended = false;
3781
+ let unsubscribe = () => {};
3782
+ const send = (value) => {
3783
+ if (!ended) controller.enqueue(encoder.encode(JSON.stringify(value) + "\n"));
3784
+ };
3785
+ close = () => {
3786
+ if (ended) return;
3787
+ ended = true;
3788
+ unsubscribe();
3789
+ request.signal.removeEventListener("abort", close);
3790
+ streams.delete(close);
3791
+ try {
3792
+ controller.close();
3793
+ } catch {}
3794
+ };
3795
+ unsubscribe = subscribe((scope) => send(scope));
3796
+ streams.add(close);
3797
+ request.signal.addEventListener("abort", close, { once: true });
3798
+ if (request.signal.aborted) close();
3799
+ else send({ ready: true });
3800
+ },
3801
+ cancel() {
3802
+ close();
3803
+ }
3804
+ });
3805
+ return new Response(body, { headers: {
3806
+ "content-type": "application/x-ndjson",
3807
+ "cache-control": "no-store",
3808
+ "x-accel-buffering": "no"
3809
+ } });
3810
+ }
3544
3811
  //#endregion
3545
3812
  //#region src/dsh/plugin.ts
3546
3813
  const name = "provider-qoder";
@@ -3604,11 +3871,6 @@ function apply(ctx, input = {}) {
3604
3871
  webSearchMode: config.webSearchMode ?? "auto"
3605
3872
  };
3606
3873
  let current = () => baseConfig;
3607
- let persistDiscoveredModels = async (_region) => {};
3608
- const discoveredCatalogs = {
3609
- global: [],
3610
- china: []
3611
- };
3612
3874
  const createTransport = (region, streamIdleTimeoutMs, responseHeaderTimeoutMs, preserveThinking, resolvePat = () => resolveManagedQoderPat(ctx.credentials)) => createQoderTransport({
3613
3875
  region,
3614
3876
  resolvePat,
@@ -3623,7 +3885,7 @@ function apply(ctx, input = {}) {
3623
3885
  const region = value.region ?? "global";
3624
3886
  return {
3625
3887
  region,
3626
- models: mergeQoderDiscoveryMetadata(modelsFor(value, region), discoveredCatalogs[region]),
3888
+ models: modelsFor(value, region),
3627
3889
  streamIdleTimeoutMs: value.streamIdleTimeoutMs ?? 3e5,
3628
3890
  responseHeaderTimeoutMs: value.responseHeaderTimeoutMs ?? 6e4,
3629
3891
  preserveThinking: value.preserveThinking ?? true,
@@ -3638,23 +3900,22 @@ function apply(ctx, input = {}) {
3638
3900
  responseHeaderTimeoutMs: initial.responseHeaderTimeoutMs,
3639
3901
  preserveThinking: initial.preserveThinking
3640
3902
  };
3903
+ const catalog = new QoderCatalogLifecycle({
3904
+ resolveTransport: () => activeTransport,
3905
+ region: () => activeTransportConfig.region,
3906
+ configuredModels: (region) => modelsFor(current(), region),
3907
+ onDiscovered: () => registration.replace([providerQoder]),
3908
+ onPersistenceError: (error) => logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error))
3909
+ });
3910
+ ctx.effect(() => () => catalog.dispose(), "provider-qoder: catalog lifecycle");
3641
3911
  const adapter = new QoderAdapter({
3642
3912
  resolveTransport: () => activeTransport,
3643
- models: initial.models,
3913
+ catalog,
3644
3914
  region: () => activeTransportConfig.region,
3645
3915
  providerId: providerQoder,
3646
3916
  providerName: "Qoder",
3647
3917
  sessions: ctx.get("sessions"),
3648
- agents: ctx.get("agents"),
3649
- onModelsDiscovered: (transport, models) => {
3650
- if (transport !== activeTransport || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3651
- const region = activeTransportConfig.region;
3652
- discoveredCatalogs[region] = models;
3653
- refreshAdapter();
3654
- persistDiscoveredModels(region).catch((error) => {
3655
- logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error));
3656
- });
3657
- }
3918
+ agents: ctx.get("agents")
3658
3919
  });
3659
3920
  const registration = ctx.llm.registerAdapter([providerQoder], adapter);
3660
3921
  const refreshAdapter = () => {
@@ -3668,6 +3929,7 @@ function apply(ctx, input = {}) {
3668
3929
  preserveThinking: next.preserveThinking
3669
3930
  };
3670
3931
  }
3932
+ catalog.observeTransport();
3671
3933
  adapter.replaceModels(next.models);
3672
3934
  registration.replace([providerQoder]);
3673
3935
  };
@@ -3678,52 +3940,35 @@ function apply(ctx, input = {}) {
3678
3940
  modelsFor(value, "china");
3679
3941
  } });
3680
3942
  current = () => scope.get();
3681
- let bindingActive = true;
3682
- const persistCatalog = async (region) => {
3683
- while (bindingActive && ctx.fiber.state !== fiberUnloading && ctx.fiber.state !== fiberDisposed) {
3684
- if (!settingsCtx.settings.writable || discoveredCatalogs[region].length === 0) return;
3943
+ catalog.bindSettings({
3944
+ read: (region) => {
3945
+ if (!settingsCtx.settings.writable) return void 0;
3685
3946
  const snapshot = settingsCtx.settings.describe().find((section) => section.ns === namespace);
3686
- if (snapshot === void 0) return;
3687
- const selected = modelsFor(snapshot.value, region);
3688
- const enriched = modelsFor(Config({ modelsByRegion: { [region]: mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]) } }), region);
3689
- if (hasSameQoderDiscoveryMetadata(selected, enriched)) return;
3690
- try {
3691
- await settingsCtx.settings.update(namespace, { modelsByRegion: { [region]: enriched } }, snapshot.revision);
3692
- return;
3693
- } catch (error) {
3694
- if (!(error instanceof SettingsConflictError)) throw error;
3695
- }
3696
- }
3697
- };
3698
- persistDiscoveredModels = persistCatalog;
3947
+ return snapshot === void 0 ? void 0 : {
3948
+ models: modelsFor(snapshot.value, region),
3949
+ revision: snapshot.revision
3950
+ };
3951
+ },
3952
+ write: (region, models, revision) => settingsCtx.settings.update(namespace, { modelsByRegion: { [region]: models } }, revision)
3953
+ });
3699
3954
  refreshAdapter();
3700
- scope.watch(async () => {
3701
- if (!bindingActive || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3955
+ scope.watch(() => {
3956
+ if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3702
3957
  refreshAdapter();
3703
- await persistCatalog(scope.get().region ?? "global");
3958
+ catalog.retryPersistence();
3704
3959
  });
3705
- for (const region of ["global", "china"]) {
3706
- if (discoveredCatalogs[region].length === 0) continue;
3707
- persistCatalog(region).catch((error) => logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error)));
3708
- }
3709
3960
  settingsCtx.effect(() => () => {
3710
- bindingActive = false;
3961
+ catalog.bindSettings(void 0);
3711
3962
  if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
3712
- persistDiscoveredModels = async () => {};
3713
3963
  current = () => baseConfig;
3714
3964
  refreshAdapter();
3715
3965
  });
3716
3966
  });
3717
3967
  const discoverModels = async (signal, suppliedPat) => {
3718
3968
  const snapshot = resolveConfig();
3719
- const snapshotTransport = activeTransport;
3720
3969
  const normalizedPat = suppliedPat?.trim();
3721
- const models = await (normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.responseHeaderTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) : snapshotTransport).discoverModels(signal);
3722
- adapter.updateDiscoveredModels(snapshotTransport, models);
3723
- discoveredCatalogs[snapshot.region] = models;
3724
- refreshAdapter();
3725
- await persistDiscoveredModels(snapshot.region);
3726
- return models;
3970
+ const transport = normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.responseHeaderTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) : activeTransport;
3971
+ return catalog.discover(signal, transport);
3727
3972
  };
3728
3973
  ctx.llm.registerModelDiscovery(settingsNamespace, async (request, signal) => {
3729
3974
  if (request.provider !== void 0 && request.provider !== providerQoder) throw new QoderLlmError(`Qoder discovery does not own provider "${request.provider}".`, "INVALID_PROVIDER");
@@ -3738,13 +3983,17 @@ function apply(ctx, input = {}) {
3738
3983
  if (!isQoderRpcEndpoint(endpoint)) return publicError("UNKNOWN_ENDPOINT", `Unknown endpoint: ${endpoint}`);
3739
3984
  if (signal.aborted) return publicError("ABORTED", "Request aborted");
3740
3985
  if (endpoint === "models") return await executeRpc("discover Qoder models", () => discoverModels(signal), signal, logger);
3741
- if (endpoint === "sessionTier") {
3986
+ if (endpoint === "sessionTier" || endpoint === "readSessionTier") {
3742
3987
  const data = payload;
3743
- if (typeof data?.sessionId === "string" && typeof data?.modelId === "string" && typeof data?.tierKey === "string" && (data.region === "global" || data.region === "china")) {
3988
+ if (typeof data?.sessionId === "string" && typeof data?.modelId === "string" && (endpoint === "readSessionTier" || typeof data?.tierKey === "string") && (data.region === "global" || data.region === "china")) {
3744
3989
  const { sessionId, modelId, tierKey, region } = data;
3745
- return await executeRpc("select session context tier", async () => {
3746
- adapter.setSessionTier(sessionId, modelId, tierKey, region);
3747
- return { success: true };
3990
+ return await executeRpc(endpoint === "sessionTier" ? "select session context tier" : "read session context tier", async () => {
3991
+ if (endpoint === "sessionTier") adapter.setSessionTier(sessionId, modelId, tierKey, region);
3992
+ return adapter.readSessionTier({
3993
+ sessionId,
3994
+ modelId,
3995
+ region
3996
+ });
3748
3997
  }, signal, logger);
3749
3998
  }
3750
3999
  return publicError("INTERNAL", "Invalid sessionTier payload");
@@ -3759,7 +4008,7 @@ function apply(ctx, input = {}) {
3759
4008
  return outcome;
3760
4009
  };
3761
4010
  try {
3762
- ctx.effect(() => registerQoderRpc(ctx, handler), "provider-qoder: settings RPC routes");
4011
+ ctx.effect(() => registerQoderRpc(ctx, handler, (listener) => adapter.subscribeSessionTiers(listener)), "provider-qoder: settings RPC routes");
3763
4012
  } catch (error) {
3764
4013
  logger?.error?.("[Qoder RPC] Failed to register the settings RPC routes", logError(error));
3765
4014
  }