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