dsh-provider-qoder 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/README_CN.md +9 -8
- package/cordis.patch.yml +6 -1
- package/lib/client.js +421 -184
- package/lib/index.d.ts +116 -3
- package/lib/index.js +1125 -156
- package/package.json +22 -15
package/lib/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import crypto, { createHash } from "node:crypto";
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
8
|
+
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
7
9
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
8
10
|
//#region src/qoder/catalog.ts
|
|
9
11
|
const discoveredMetadataKeys = [
|
|
@@ -234,6 +236,12 @@ function getQoderUserStatusUrl(region = "global") {
|
|
|
234
236
|
const { openApiUrl } = resolveQoderEndpoints(region);
|
|
235
237
|
return `${openApiUrl}/api/v3/user/status`;
|
|
236
238
|
}
|
|
239
|
+
/** Path of the center web search route. */
|
|
240
|
+
const qoderWebSearchPath = "/api/v1/webSearch/oneSearch";
|
|
241
|
+
function getQoderWebSearchUrl(region = "global") {
|
|
242
|
+
const { centerUrl } = resolveQoderEndpoints(region);
|
|
243
|
+
return `${centerUrl.replace(/\/+$/u, "")}/algo${qoderWebSearchPath}?Encode=1`;
|
|
244
|
+
}
|
|
237
245
|
//#endregion
|
|
238
246
|
//#region src/qoder/errors.ts
|
|
239
247
|
/**
|
|
@@ -254,7 +262,8 @@ function qoderHttpError(message, response) {
|
|
|
254
262
|
return new QoderLlmError(message, code, {
|
|
255
263
|
status,
|
|
256
264
|
...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
|
|
257
|
-
...requestId === void 0 ? {} : { requestId }
|
|
265
|
+
...requestId === void 0 ? {} : { requestId },
|
|
266
|
+
...response.cause === void 0 ? {} : { cause: response.cause }
|
|
258
267
|
});
|
|
259
268
|
}
|
|
260
269
|
function qoderRequestId(headers) {
|
|
@@ -333,7 +342,9 @@ function redactLogValue(value, depth = 0) {
|
|
|
333
342
|
if (value instanceof Error) return {
|
|
334
343
|
name: value.name,
|
|
335
344
|
message: redactString(value.message),
|
|
336
|
-
..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {}
|
|
345
|
+
..."code" in value ? { code: redactLogValue(value.code, depth + 1) } : {},
|
|
346
|
+
..."cause" in value ? { cause: redactLogValue(value.cause, depth + 1) } : {},
|
|
347
|
+
...value instanceof AggregateError ? { errors: redactLogValue(value.errors, depth + 1) } : {}
|
|
337
348
|
};
|
|
338
349
|
if (Array.isArray(value)) return value.slice(0, maxEntries).map((item) => redactLogValue(item, depth + 1));
|
|
339
350
|
return Object.fromEntries(Object.entries(value).slice(0, maxEntries).map(([key, item]) => {
|
|
@@ -374,7 +385,7 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
|
374
385
|
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
375
386
|
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
376
387
|
-----END PUBLIC KEY-----`;
|
|
377
|
-
const qoderIdeVersion = "1.1.
|
|
388
|
+
const qoderIdeVersion = "1.1.60";
|
|
378
389
|
const defaultUserAgent = `qoder/${qoderIdeVersion}`;
|
|
379
390
|
const qoderDataPolicy = "disagree";
|
|
380
391
|
const qoderLoginVersion = "v2";
|
|
@@ -448,6 +459,7 @@ function buildAuthHeaders(body, requestURL, creds) {
|
|
|
448
459
|
"X-Request-Id": crypto.randomUUID()
|
|
449
460
|
};
|
|
450
461
|
}
|
|
462
|
+
const defaultResponseHeaderTimeoutMs = 6e4;
|
|
451
463
|
const defaultMaxJsonBytes = 2 * 1024 * 1024;
|
|
452
464
|
const defaultMaxErrorBytes = 16 * 1024;
|
|
453
465
|
const metadataRetryBaseDelayMs = 200;
|
|
@@ -799,35 +811,6 @@ function toolResultText(block) {
|
|
|
799
811
|
}
|
|
800
812
|
return text;
|
|
801
813
|
}
|
|
802
|
-
/**
|
|
803
|
-
* Check message shapes without performing any provider I/O.
|
|
804
|
-
*
|
|
805
|
-
* Callers run this before resolving credentials so an invalid request never
|
|
806
|
-
* consumes a Qoder subscription.
|
|
807
|
-
*/
|
|
808
|
-
function validateMessageShapes(messages) {
|
|
809
|
-
for (const message of messages) {
|
|
810
|
-
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
811
|
-
if (toolResults.length > 0) {
|
|
812
|
-
if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
|
|
813
|
-
for (const result of toolResults) toolResultText(result);
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
for (const block of message.content) {
|
|
817
|
-
if (block.type === "text") continue;
|
|
818
|
-
if (block.type === "image") {
|
|
819
|
-
if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
if (block.type === "tool-call") {
|
|
823
|
-
if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
|
|
824
|
-
continue;
|
|
825
|
-
}
|
|
826
|
-
if (block.type === "reasoning") continue;
|
|
827
|
-
throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
814
|
/** Reject a batch that exceeds the deployment image policy before any upload work starts. */
|
|
832
815
|
function enforceImageLimits(images, attachments) {
|
|
833
816
|
const limits = attachments.imageLimits;
|
|
@@ -842,10 +825,15 @@ async function resolveImagePart(block, context) {
|
|
|
842
825
|
let image;
|
|
843
826
|
try {
|
|
844
827
|
const limits = attachments.imageLimits;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
828
|
+
const { width, height } = block.attachment;
|
|
829
|
+
const scale = Math.min(1, Math.sqrt(limits.maxImagePixels / (width * height)), limits.maxImageDimension / width, limits.maxImageDimension / height);
|
|
830
|
+
const target = {
|
|
831
|
+
width: Math.max(1, Math.floor(width * scale)),
|
|
832
|
+
height: Math.max(1, Math.floor(height * scale)),
|
|
833
|
+
maxBytes: limits.maxImageBytes,
|
|
834
|
+
maxPixels: limits.maxImagePixels
|
|
835
|
+
};
|
|
836
|
+
image = await attachments.readImageRequest(block.attachment, target, signal);
|
|
849
837
|
} catch (error) {
|
|
850
838
|
if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
|
|
851
839
|
if (error instanceof QoderLlmError) throw error;
|
|
@@ -870,8 +858,29 @@ function translateTools(tools) {
|
|
|
870
858
|
}
|
|
871
859
|
}));
|
|
872
860
|
}
|
|
861
|
+
/** Read a named property from an untyped object, returning it only if present. */
|
|
862
|
+
function prop(obj, key) {
|
|
863
|
+
return obj !== null && obj !== void 0 && typeof obj === "object" ? obj[key] : void 0;
|
|
864
|
+
}
|
|
865
|
+
/** Coerce a candidate value to a non-empty string tool call id, or `undefined`. */
|
|
866
|
+
function asCallId(value) {
|
|
867
|
+
if (typeof value !== "string") return void 0;
|
|
868
|
+
const trimmed = value.trim();
|
|
869
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
870
|
+
}
|
|
871
|
+
function extractToolCallId(message, result) {
|
|
872
|
+
return asCallId(prop(result, "toolCallId")) ?? asCallId(prop(message, "toolCallId")) ?? asCallId(prop(message, "tool_call_id")) ?? asCallId(prop(message.source, "kind") === "tool" ? prop(message.source, "callId") : void 0);
|
|
873
|
+
}
|
|
874
|
+
function consumeToolCallId(message, result, pendingIds, fallbackIndex) {
|
|
875
|
+
const explicit = extractToolCallId(message, result);
|
|
876
|
+
if (explicit !== void 0) {
|
|
877
|
+
const idx = pendingIds.indexOf(explicit);
|
|
878
|
+
if (idx !== -1) pendingIds.splice(idx, 1);
|
|
879
|
+
return explicit;
|
|
880
|
+
}
|
|
881
|
+
return pendingIds.shift() ?? `call_unknown_${fallbackIndex}`;
|
|
882
|
+
}
|
|
873
883
|
async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
|
|
874
|
-
validateMessageShapes(messages);
|
|
875
884
|
const context = {
|
|
876
885
|
attachments,
|
|
877
886
|
signal,
|
|
@@ -880,18 +889,20 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
880
889
|
};
|
|
881
890
|
const preserveThinking = pipeline?.preserveThinking ?? true;
|
|
882
891
|
const output = [];
|
|
892
|
+
let lastAssistantToolCallIds = [];
|
|
883
893
|
if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
|
|
884
894
|
role: "system",
|
|
885
895
|
content: systemPrompt
|
|
886
896
|
});
|
|
887
897
|
for (const message of messages) {
|
|
888
898
|
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
899
|
+
const nonToolBlocks = message.content.filter((block) => block.type !== "tool-result");
|
|
889
900
|
if (toolResults.length > 0) {
|
|
890
|
-
if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
|
|
891
901
|
for (const result of toolResults) {
|
|
902
|
+
const callId = consumeToolCallId(message, result, lastAssistantToolCallIds, output.length);
|
|
892
903
|
output.push({
|
|
893
904
|
role: "tool",
|
|
894
|
-
tool_call_id:
|
|
905
|
+
tool_call_id: callId,
|
|
895
906
|
content: toolResultText(result)
|
|
896
907
|
});
|
|
897
908
|
const images = result.content.filter((block) => block.type === "image");
|
|
@@ -906,24 +917,27 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
906
917
|
});
|
|
907
918
|
}
|
|
908
919
|
}
|
|
909
|
-
continue;
|
|
920
|
+
if (nonToolBlocks.length === 0) continue;
|
|
910
921
|
}
|
|
922
|
+
const contentBlocks = toolResults.length > 0 ? nonToolBlocks : message.content;
|
|
911
923
|
let text = "";
|
|
912
924
|
let reasoningText = "";
|
|
913
925
|
const userContent = [];
|
|
914
926
|
const pendingImages = [];
|
|
915
927
|
let hasImage = false;
|
|
916
928
|
const toolCalls = [];
|
|
917
|
-
for (const block of
|
|
929
|
+
for (const block of contentBlocks) {
|
|
930
|
+
if (message.role === "tool" && block.type !== "text" && block.type !== "image") throw unsupported(`Qoder tool results support text and images only; received ${block.type} content.`);
|
|
918
931
|
if (block.type === "text") {
|
|
919
932
|
text += block.text;
|
|
920
|
-
if (message.role === "user") userContent.push({
|
|
933
|
+
if (message.role === "user" || toolResults.length > 0) userContent.push({
|
|
921
934
|
type: "text",
|
|
922
935
|
text: block.text
|
|
923
936
|
});
|
|
924
937
|
continue;
|
|
925
938
|
}
|
|
926
939
|
if (block.type === "image") {
|
|
940
|
+
if (message.role !== "user" && message.role !== "tool" && toolResults.length === 0) throw unsupported("Qoder image content is valid only in user messages.");
|
|
927
941
|
hasImage = true;
|
|
928
942
|
pendingImages.push({
|
|
929
943
|
slot: userContent.length,
|
|
@@ -951,6 +965,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
951
965
|
if (message.role === "assistant") {
|
|
952
966
|
const hasReasoning = preserveThinking && reasoningText.length > 0;
|
|
953
967
|
if (!text && toolCalls.length === 0 && !hasReasoning) continue;
|
|
968
|
+
if (toolCalls.length > 0) lastAssistantToolCallIds = toolCalls.map((tc) => tc.id);
|
|
954
969
|
output.push({
|
|
955
970
|
role: "assistant",
|
|
956
971
|
content: text || " ",
|
|
@@ -959,20 +974,317 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
959
974
|
});
|
|
960
975
|
continue;
|
|
961
976
|
}
|
|
977
|
+
if (message.role === "tool") {
|
|
978
|
+
const callId = consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length);
|
|
979
|
+
output.push({
|
|
980
|
+
role: "tool",
|
|
981
|
+
tool_call_id: callId,
|
|
982
|
+
content: text
|
|
983
|
+
});
|
|
984
|
+
if (pendingImages.length > 0) {
|
|
985
|
+
if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
|
|
986
|
+
output.push({
|
|
987
|
+
role: "user",
|
|
988
|
+
content: [{
|
|
989
|
+
type: "text",
|
|
990
|
+
text: `[${pendingImages.length} image${pendingImages.length === 1 ? "" : "s"} returned by the previous tool call]`
|
|
991
|
+
}, ...await Promise.all(pendingImages.map((pending) => resolveImagePart(pending.block, context)))]
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
962
996
|
if (pendingImages.length > 0) {
|
|
963
997
|
if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
|
|
964
998
|
await Promise.all(pendingImages.map(async (pending) => {
|
|
965
999
|
userContent[pending.slot] = await resolveImagePart(pending.block, context);
|
|
966
1000
|
}));
|
|
967
1001
|
}
|
|
1002
|
+
if (toolResults.length > 0 && !hasImage && text.trim().length === 0) continue;
|
|
1003
|
+
const wireRole = toolResults.length > 0 ? "user" : message.role;
|
|
1004
|
+
const wireToolCallId = wireRole === "tool" ? consumeToolCallId(message, void 0, lastAssistantToolCallIds, output.length) : void 0;
|
|
968
1005
|
output.push({
|
|
969
|
-
role:
|
|
970
|
-
content: hasImage ? userContent.filter((part) => part !== void 0) : text
|
|
1006
|
+
role: wireRole,
|
|
1007
|
+
content: hasImage ? userContent.filter((part) => part !== void 0) : text,
|
|
1008
|
+
...wireToolCallId ? { tool_call_id: wireToolCallId } : {}
|
|
971
1009
|
});
|
|
972
1010
|
}
|
|
973
1011
|
return output;
|
|
974
1012
|
}
|
|
975
1013
|
//#endregion
|
|
1014
|
+
//#region src/qoder/transport/wire/turn-identity.ts
|
|
1015
|
+
/**
|
|
1016
|
+
* Per-turn Qoder request identity.
|
|
1017
|
+
*
|
|
1018
|
+
* A DSH turn issues one model request per reasoning step, so a single
|
|
1019
|
+
* subscriber prompt becomes dozens of consecutive Qoder requests. Qoder's
|
|
1020
|
+
* Credits panel aggregates consumption per agent run — the `business.id` a
|
|
1021
|
+
* request reports — so every step of one turn must carry the same run identity
|
|
1022
|
+
* while the next subscriber prompt opens a new one.
|
|
1023
|
+
*
|
|
1024
|
+
* The adapter never sees a turn number, and the host appends messages to a
|
|
1025
|
+
* turn's history while it runs — its own context, notifications, delegated
|
|
1026
|
+
* agents' messages — so the boundary is derived from the history here. A turn is
|
|
1027
|
+
* the multiset of user-role messages it has accounted for: a subscriber message
|
|
1028
|
+
* the run has not seen opens the next turn, while host-appended context,
|
|
1029
|
+
* transport-synthesized markers, and anything an auxiliary call carries continue
|
|
1030
|
+
* the open record.
|
|
1031
|
+
*
|
|
1032
|
+
* Known limit: text identifies a message because the host's message ids are not
|
|
1033
|
+
* part of the wire shape. A subscriber who sends the same words twice with the
|
|
1034
|
+
* first copy already gone from history — compaction replaced that span — shares
|
|
1035
|
+
* the open record instead of opening a new one. Carrying the host's message
|
|
1036
|
+
* identity through translation is the fix, and needs the host to expose it on
|
|
1037
|
+
* the request the adapter receives.
|
|
1038
|
+
*
|
|
1039
|
+
* @module dsh-provider-qoder/qoder/transport/wire/turn-identity
|
|
1040
|
+
*/
|
|
1041
|
+
const defaultMaxSessions = 64;
|
|
1042
|
+
/** A conversation untouched for this long cannot still be mid-turn. */
|
|
1043
|
+
const defaultIdleSessionTtlMs = 3600 * 1e3;
|
|
1044
|
+
/**
|
|
1045
|
+
* Openings of everything the host appends while a turn runs.
|
|
1046
|
+
*
|
|
1047
|
+
* One subscriber prompt is what the subscriber perceives as one turn, so every
|
|
1048
|
+
* message the harness adds around that prompt — its own context, notifications,
|
|
1049
|
+
* and delegated agents' messages — must stay inside the same record. Only a
|
|
1050
|
+
* message outside this list is treated as a new prompt. The list is
|
|
1051
|
+
* evidence-based: every entry was observed in live DSH transcripts, and an
|
|
1052
|
+
* offline audit replays real sessions to keep it aligned. An unknown future
|
|
1053
|
+
* wrapper therefore opens one extra record instead of merging two turns.
|
|
1054
|
+
*/
|
|
1055
|
+
const injectedContextOpenings = [
|
|
1056
|
+
"<system-reminder>",
|
|
1057
|
+
"<openviking-context",
|
|
1058
|
+
"<goal_complete>",
|
|
1059
|
+
"Current runtime context. This snapshot supersedes earlier runtime-context",
|
|
1060
|
+
"This is an automatically generated checkpoint condensing",
|
|
1061
|
+
"[model changed:",
|
|
1062
|
+
"background job ",
|
|
1063
|
+
"The approval policy changed"
|
|
1064
|
+
];
|
|
1065
|
+
/**
|
|
1066
|
+
* Host messages that name their sender.
|
|
1067
|
+
*
|
|
1068
|
+
* A delegated agent's message and a settled background subagent both start with
|
|
1069
|
+
* a word a subscriber may legitimately use, so they are matched on the host's
|
|
1070
|
+
* own template — the sender's identity then a fixed phrase — instead of on a
|
|
1071
|
+
* bare prefix that would swallow real prompts.
|
|
1072
|
+
*/
|
|
1073
|
+
const hostNotificationTemplate = /^(?:Agent|Background subagent) \S{3,} (?:sent a message:|finished\b)/;
|
|
1074
|
+
/**
|
|
1075
|
+
* Text this transport synthesizes while translating a turn's own tool results.
|
|
1076
|
+
*
|
|
1077
|
+
* A tool result carrying images becomes a user-role message, so it must be
|
|
1078
|
+
* recognized as part of the open record rather than as new subscriber input.
|
|
1079
|
+
*/
|
|
1080
|
+
const syntheticMarkerTemplate = /^\[\d+ images? returned by the previous tool call\]/;
|
|
1081
|
+
/** Wire text of one user message, used for injection classification and text extraction. */
|
|
1082
|
+
function wireMessageText(message) {
|
|
1083
|
+
const content = message.content;
|
|
1084
|
+
if (typeof content === "string") return content;
|
|
1085
|
+
if (content === null) return "";
|
|
1086
|
+
return content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
1087
|
+
}
|
|
1088
|
+
/** Classify one user-role wire message. */
|
|
1089
|
+
function qoderMessageOrigin(message) {
|
|
1090
|
+
const text = wireMessageText(message).trimStart();
|
|
1091
|
+
if (syntheticMarkerTemplate.test(text)) return "synthetic";
|
|
1092
|
+
if (hostNotificationTemplate.test(text)) return "injected";
|
|
1093
|
+
return injectedContextOpenings.some((opening) => text.startsWith(opening)) ? "injected" : "prompt";
|
|
1094
|
+
}
|
|
1095
|
+
/** Stable record id of one session turn. */
|
|
1096
|
+
function turnIdFor(sessionId, turnCount, anchor) {
|
|
1097
|
+
return crypto.createHash("sha256").update("qoder-turn").update("\0").update(sessionId).update("\0").update(String(turnCount)).update("\0").update(anchor).digest("hex").slice(0, 16);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Fingerprint of one message, used to count how often a turn has seen it.
|
|
1101
|
+
*
|
|
1102
|
+
* Text identifies a message, never an image URL: a published URL is re-signed as
|
|
1103
|
+
* its cache entry expires, so a rotating URL would make the turn's own prompt
|
|
1104
|
+
* look like new input and split the record. Image count still separates a
|
|
1105
|
+
* message that carries images from one that does not.
|
|
1106
|
+
*/
|
|
1107
|
+
function messageFingerprint(message) {
|
|
1108
|
+
const hash = crypto.createHash("sha256");
|
|
1109
|
+
hash.update(message.role);
|
|
1110
|
+
hash.update("\0");
|
|
1111
|
+
const content = message.content;
|
|
1112
|
+
if (typeof content === "string") hash.update(content);
|
|
1113
|
+
else if (content !== null) {
|
|
1114
|
+
let images = 0;
|
|
1115
|
+
for (const part of content) if (part.type === "text") {
|
|
1116
|
+
hash.update("\0text\0");
|
|
1117
|
+
hash.update(part.text);
|
|
1118
|
+
} else images += 1;
|
|
1119
|
+
hash.update(`\0images=${images}`);
|
|
1120
|
+
}
|
|
1121
|
+
return hash.digest("hex").slice(0, 16);
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Resolves the record identity of the turn one request belongs to.
|
|
1125
|
+
*
|
|
1126
|
+
* One tracker instance owns its sessions, so tests and transports stay
|
|
1127
|
+
* independent. State is created lazily per session and bounded by
|
|
1128
|
+
* `maxSessions`.
|
|
1129
|
+
*/
|
|
1130
|
+
var QoderTurnTracker = class {
|
|
1131
|
+
maxSessions;
|
|
1132
|
+
idleSessionTtlMs;
|
|
1133
|
+
now;
|
|
1134
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1135
|
+
constructor(options = {}) {
|
|
1136
|
+
this.maxSessions = options.maxSessions ?? defaultMaxSessions;
|
|
1137
|
+
this.idleSessionTtlMs = options.idleSessionTtlMs ?? defaultIdleSessionTtlMs;
|
|
1138
|
+
this.now = options.now ?? Date.now;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Resolves the full wire identity for one Qoder request.
|
|
1142
|
+
*
|
|
1143
|
+
* Coordinates `request_set_id`, `chat_record_id`, and `business` metadata so
|
|
1144
|
+
* that requests within one subscriber turn share a single agent run and
|
|
1145
|
+
* consumption record.
|
|
1146
|
+
*/
|
|
1147
|
+
resolveTurnIdentity(sessionId, messages, kind = "conversation", requestId = crypto.randomUUID(), modelKey) {
|
|
1148
|
+
const now = this.now();
|
|
1149
|
+
if (sessionId === void 0 || sessionId.length === 0) return {
|
|
1150
|
+
requestSetId: `qoder-request-${crypto.randomUUID()}`,
|
|
1151
|
+
chatRecordId: requestId,
|
|
1152
|
+
businessId: crypto.randomUUID(),
|
|
1153
|
+
beginAt: now
|
|
1154
|
+
};
|
|
1155
|
+
if (kind === "auxiliary") {
|
|
1156
|
+
if (this.sessions.has(sessionId)) this.sessions.get(sessionId).lastTouchedAt = now;
|
|
1157
|
+
return {
|
|
1158
|
+
requestSetId: `qoder-request-${crypto.randomUUID()}`,
|
|
1159
|
+
chatRecordId: requestId,
|
|
1160
|
+
businessId: crypto.randomUUID(),
|
|
1161
|
+
beginAt: now
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
let state = this.sessions.get(sessionId);
|
|
1165
|
+
if (state === void 0) {
|
|
1166
|
+
state = {
|
|
1167
|
+
claimed: /* @__PURE__ */ new Map(),
|
|
1168
|
+
currentTurnId: "",
|
|
1169
|
+
currentBusinessId: "",
|
|
1170
|
+
turnOpenedAt: 0,
|
|
1171
|
+
lastTouchedAt: now,
|
|
1172
|
+
turnCount: 0,
|
|
1173
|
+
currentModel: modelKey
|
|
1174
|
+
};
|
|
1175
|
+
this.claimSession(sessionId, state);
|
|
1176
|
+
} else {
|
|
1177
|
+
state.lastTouchedAt = now;
|
|
1178
|
+
this.sessions.delete(sessionId);
|
|
1179
|
+
this.sessions.set(sessionId, state);
|
|
1180
|
+
}
|
|
1181
|
+
const modelChanged = state.currentModel !== void 0 && modelKey !== void 0 && state.currentModel !== modelKey;
|
|
1182
|
+
if (modelKey !== void 0) state.currentModel = modelKey;
|
|
1183
|
+
const present = /* @__PURE__ */ new Map();
|
|
1184
|
+
for (const message of messages) {
|
|
1185
|
+
if (message.role !== "user") continue;
|
|
1186
|
+
const fingerprint = messageFingerprint(message);
|
|
1187
|
+
const entry = present.get(fingerprint);
|
|
1188
|
+
if (entry === void 0) present.set(fingerprint, {
|
|
1189
|
+
count: 1,
|
|
1190
|
+
host: qoderMessageOrigin(message)
|
|
1191
|
+
});
|
|
1192
|
+
else entry.count += 1;
|
|
1193
|
+
}
|
|
1194
|
+
let opening;
|
|
1195
|
+
let unclaimed = 0;
|
|
1196
|
+
for (const [fingerprint, entry] of present) {
|
|
1197
|
+
const claimed = state.claimed.get(fingerprint) ?? 0;
|
|
1198
|
+
if (entry.count <= claimed) continue;
|
|
1199
|
+
state.claimed.set(fingerprint, entry.count);
|
|
1200
|
+
if (entry.host === "injected" || entry.host === "synthetic") continue;
|
|
1201
|
+
unclaimed += entry.count - claimed;
|
|
1202
|
+
opening = fingerprint;
|
|
1203
|
+
}
|
|
1204
|
+
for (const [fingerprint, claimed] of state.claimed) {
|
|
1205
|
+
const current = present.get(fingerprint);
|
|
1206
|
+
if (current === void 0) state.claimed.delete(fingerprint);
|
|
1207
|
+
else if (current.count < claimed) state.claimed.set(fingerprint, current.count);
|
|
1208
|
+
}
|
|
1209
|
+
if (opening !== void 0 && unclaimed > 0 || modelChanged) {
|
|
1210
|
+
state.turnCount += 1;
|
|
1211
|
+
state.currentTurnId = turnIdFor(sessionId, state.turnCount, opening ?? state.currentModel ?? "");
|
|
1212
|
+
state.currentBusinessId = crypto.randomUUID();
|
|
1213
|
+
state.turnOpenedAt = now;
|
|
1214
|
+
} else if (state.currentTurnId === "") {
|
|
1215
|
+
state.turnCount += 1;
|
|
1216
|
+
state.currentTurnId = turnIdFor(sessionId, state.turnCount, state.currentModel ?? "");
|
|
1217
|
+
state.currentBusinessId = crypto.randomUUID();
|
|
1218
|
+
state.turnOpenedAt = now;
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
requestSetId: state.currentTurnId,
|
|
1222
|
+
chatRecordId: requestId,
|
|
1223
|
+
businessId: state.currentBusinessId,
|
|
1224
|
+
beginAt: state.turnOpenedAt
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Record id shared by every step of `sessionId`'s current turn.
|
|
1229
|
+
*
|
|
1230
|
+
* `null` means the request carries no session identity, so no conversation
|
|
1231
|
+
* identity can be derived and the caller must fall back to a per-request id.
|
|
1232
|
+
*/
|
|
1233
|
+
resolveTurnRecordId(sessionId, messages, kind = "conversation") {
|
|
1234
|
+
if (sessionId === void 0 || sessionId.length === 0) return null;
|
|
1235
|
+
return this.resolveTurnIdentity(sessionId, messages, kind).requestSetId;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Agent-run identity the Qoder service reports consumption against.
|
|
1239
|
+
*
|
|
1240
|
+
* The official client stamps one `business.id` per agent run and keeps it for
|
|
1241
|
+
* every request that run makes, which is what makes one subscriber turn one
|
|
1242
|
+
* consumption record. A request outside any conversation still gets its own.
|
|
1243
|
+
*/
|
|
1244
|
+
resolveBusinessId(sessionId, messages, kind = "conversation") {
|
|
1245
|
+
const identity = this.resolveTurnIdentity(sessionId, messages, kind);
|
|
1246
|
+
return {
|
|
1247
|
+
businessId: identity.businessId,
|
|
1248
|
+
beginAt: identity.beginAt
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/** Forget one session, e.g. when its transport is disposed. */
|
|
1252
|
+
forget(sessionId) {
|
|
1253
|
+
this.sessions.delete(sessionId);
|
|
1254
|
+
}
|
|
1255
|
+
/** Forget every session. */
|
|
1256
|
+
clear() {
|
|
1257
|
+
this.sessions.clear();
|
|
1258
|
+
}
|
|
1259
|
+
claimSession(sessionId, state) {
|
|
1260
|
+
this.sessions.set(sessionId, state);
|
|
1261
|
+
this.evict();
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Keep the recorded sessions bounded.
|
|
1265
|
+
*
|
|
1266
|
+
* A conversation untouched for longer than the idle TTL cannot still be
|
|
1267
|
+
* running a turn, so it is dropped first and dropping it cannot split a
|
|
1268
|
+
* record. Sessions are ordered by last touch, which makes that idle set the
|
|
1269
|
+
* prefix of the map, so in practice this deletes from the coldest end; the TTL
|
|
1270
|
+
* only changes the outcome if the clock moves backwards. Once every recorded
|
|
1271
|
+
* conversation is more recent than the TTL, the ceiling drops the coldest one,
|
|
1272
|
+
* which can split the turn that conversation was running.
|
|
1273
|
+
*/
|
|
1274
|
+
evict() {
|
|
1275
|
+
const idleBefore = this.now() - this.idleSessionTtlMs;
|
|
1276
|
+
for (const [id, state] of this.sessions) {
|
|
1277
|
+
if (this.sessions.size <= this.maxSessions) return;
|
|
1278
|
+
if (state.lastTouchedAt <= idleBefore) this.sessions.delete(id);
|
|
1279
|
+
}
|
|
1280
|
+
while (this.sessions.size > this.maxSessions) {
|
|
1281
|
+
const oldest = this.sessions.keys().next();
|
|
1282
|
+
if (oldest.done === true) return;
|
|
1283
|
+
this.sessions.delete(oldest.value);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
//#endregion
|
|
976
1288
|
//#region src/qoder/transport/wire/serialize.ts
|
|
977
1289
|
/** Build the minimum qodercli request envelope for a validated DSH request. */
|
|
978
1290
|
function stableHash(prefix, ...inputs) {
|
|
@@ -984,18 +1296,25 @@ function stableHash(prefix, ...inputs) {
|
|
|
984
1296
|
}
|
|
985
1297
|
return hash.digest("hex").slice(0, 16);
|
|
986
1298
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Turn identity shared by every request the process serves.
|
|
1301
|
+
*
|
|
1302
|
+
* Qoder's Credits panel aggregates consumption per agent run — the
|
|
1303
|
+
* `business.id` a request reports — so every step of one turn must carry the
|
|
1304
|
+
* same run identity while the next subscriber prompt opens a new one. The
|
|
1305
|
+
* tracker owns that boundary.
|
|
1306
|
+
*/
|
|
1307
|
+
const qoderTurnTracker = new QoderTurnTracker();
|
|
1308
|
+
/**
|
|
1309
|
+
* Whether this request serves the subscriber's turn or the host's bookkeeping.
|
|
1310
|
+
*
|
|
1311
|
+
* The host marks an auxiliary call through `GenerateOptions.purpose`, and those
|
|
1312
|
+
* calls carry a message list of their own under the same session identity. They
|
|
1313
|
+
* must never move the open turn's boundary, or one subscriber prompt would
|
|
1314
|
+
* report two consumption records.
|
|
1315
|
+
*/
|
|
1316
|
+
function callKind(options) {
|
|
1317
|
+
return options.purpose === void 0 ? "conversation" : "auxiliary";
|
|
999
1318
|
}
|
|
1000
1319
|
/**
|
|
1001
1320
|
* Reject an unusable request before any credential resolution or provider I/O.
|
|
@@ -1010,7 +1329,6 @@ function validateQoderRequestShape(options, model) {
|
|
|
1010
1329
|
if (!model?.reasoningEfforts?.some((candidate) => candidate.id === effort)) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise reasoning effort "${effort}".`, "UNSUPPORTED_REASONING_EFFORT");
|
|
1011
1330
|
}
|
|
1012
1331
|
if (options.messages.some((message) => contentHasImage(message.content)) && model?.supportsImages !== true) throw new QoderLlmError(`Qoder model "${options.model}" does not advertise image input.`, "UNSUPPORTED_CONTENT");
|
|
1013
|
-
validateMessageShapes(options.messages);
|
|
1014
1332
|
}
|
|
1015
1333
|
/** Translate a request whose shape has already been validated. */
|
|
1016
1334
|
function translateQoderMessages(options, attachments, pipeline) {
|
|
@@ -1020,7 +1338,7 @@ async function validateQoderRequest(options, model, attachments) {
|
|
|
1020
1338
|
validateQoderRequestShape(options, model);
|
|
1021
1339
|
return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
|
|
1022
1340
|
}
|
|
1023
|
-
async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
|
|
1341
|
+
async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments, tracker) {
|
|
1024
1342
|
if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
|
|
1025
1343
|
const modelKey = options.model || "cmodel";
|
|
1026
1344
|
const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
|
|
@@ -1035,17 +1353,18 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
|
|
|
1035
1353
|
}]));
|
|
1036
1354
|
let lastUserText = "";
|
|
1037
1355
|
for (let index = messages.length - 1; index >= 0; index--) if (messages[index].role === "user") {
|
|
1038
|
-
|
|
1039
|
-
lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
|
|
1356
|
+
lastUserText = wireMessageText(messages[index]);
|
|
1040
1357
|
break;
|
|
1041
1358
|
}
|
|
1042
|
-
const stablePart = stableHash("qoder-session", userId
|
|
1359
|
+
const stablePart = stableHash("qoder-session", userId);
|
|
1043
1360
|
const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
|
|
1044
|
-
const
|
|
1361
|
+
const requestId = crypto.randomUUID();
|
|
1362
|
+
const sessionIdParam = options.sessionId === void 0 ? void 0 : String(options.sessionId);
|
|
1363
|
+
const identity = (tracker ?? qoderTurnTracker).resolveTurnIdentity(sessionIdParam, messages, callKind(options), requestId, modelKey);
|
|
1045
1364
|
return {
|
|
1046
|
-
request_id:
|
|
1047
|
-
request_set_id:
|
|
1048
|
-
chat_record_id:
|
|
1365
|
+
request_id: requestId,
|
|
1366
|
+
request_set_id: identity.requestSetId,
|
|
1367
|
+
chat_record_id: identity.chatRecordId,
|
|
1049
1368
|
session_id: sessionId,
|
|
1050
1369
|
stream: true,
|
|
1051
1370
|
chat_task: "FREE_INPUT",
|
|
@@ -1090,12 +1409,12 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
|
|
|
1090
1409
|
},
|
|
1091
1410
|
business: {
|
|
1092
1411
|
product: "cli",
|
|
1093
|
-
version: "1.0.
|
|
1412
|
+
version: "1.0.60",
|
|
1094
1413
|
type: "agent",
|
|
1095
1414
|
stage: "start",
|
|
1096
|
-
id:
|
|
1415
|
+
id: identity.businessId,
|
|
1097
1416
|
name: lastUserText.substring(0, 30),
|
|
1098
|
-
begin_at:
|
|
1417
|
+
begin_at: identity.beginAt
|
|
1099
1418
|
}
|
|
1100
1419
|
};
|
|
1101
1420
|
}
|
|
@@ -1426,13 +1745,20 @@ function normalizeQuota(raw) {
|
|
|
1426
1745
|
unit
|
|
1427
1746
|
};
|
|
1428
1747
|
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Normalize an epoch-millis (or parseable date string) expiry to ISO-8601.
|
|
1750
|
+
*
|
|
1751
|
+
* The provider uses the int64 maximum as a "never expires" sentinel, which is
|
|
1752
|
+
* outside the `Date` range: an unusable value resolves to `undefined` instead
|
|
1753
|
+
* of throwing, so one bad field cannot abort the whole account read.
|
|
1754
|
+
*/
|
|
1429
1755
|
function normalizeExpiresAt(rawExpires) {
|
|
1430
1756
|
if (rawExpires === void 0 || rawExpires === null) return void 0;
|
|
1431
|
-
|
|
1432
|
-
if (
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1757
|
+
const parsed = typeof rawExpires === "number" ? rawExpires : typeof rawExpires === "string" && rawExpires.length > 0 ? Date.parse(rawExpires) : NaN;
|
|
1758
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
|
|
1759
|
+
const date = new Date(parsed);
|
|
1760
|
+
if (!Number.isFinite(date.getTime())) return void 0;
|
|
1761
|
+
return date.toISOString();
|
|
1436
1762
|
}
|
|
1437
1763
|
function asString(value) {
|
|
1438
1764
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
|
|
@@ -1447,6 +1773,76 @@ function asNumber(value) {
|
|
|
1447
1773
|
if (Number.isFinite(num)) return num;
|
|
1448
1774
|
}
|
|
1449
1775
|
}
|
|
1776
|
+
/**
|
|
1777
|
+
* The provider names a resource package with internal identifiers (campaign
|
|
1778
|
+
* ids) that must never reach the UI, so the subscriber-facing copy only ever
|
|
1779
|
+
* comes from `displayLabels`.
|
|
1780
|
+
*/
|
|
1781
|
+
function findDisplayLabel(raw, dimension) {
|
|
1782
|
+
if (!Array.isArray(raw)) return void 0;
|
|
1783
|
+
for (const entry of raw) {
|
|
1784
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1785
|
+
const obj = entry;
|
|
1786
|
+
if (asString(obj.dimension) === dimension) return obj;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Normalize one `displayLabels` entry into language-tagged copy.
|
|
1791
|
+
*
|
|
1792
|
+
* The provider's value lives under `valueI18n`/`value_i18n`, keyed by language
|
|
1793
|
+
* tag, plus `value` as the untagged fallback. An entry with neither is dropped
|
|
1794
|
+
* rather than surfaced as an empty label.
|
|
1795
|
+
*/
|
|
1796
|
+
function normalizeLocalizedText(raw) {
|
|
1797
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
1798
|
+
const obj = raw;
|
|
1799
|
+
const values = {};
|
|
1800
|
+
const localized = obj.valueI18n ?? obj.value_i18n;
|
|
1801
|
+
if (localized && typeof localized === "object") for (const [locale, text] of Object.entries(localized)) {
|
|
1802
|
+
const resolved = asString(text);
|
|
1803
|
+
if (resolved !== void 0) values[locale] = resolved;
|
|
1804
|
+
}
|
|
1805
|
+
const fallback = asString(obj.value);
|
|
1806
|
+
if (fallback === void 0 && Object.keys(values).length === 0) return void 0;
|
|
1807
|
+
return {
|
|
1808
|
+
values,
|
|
1809
|
+
fallback: fallback ?? ""
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Normalize the dedicated (entitlement-scoped) resource packages.
|
|
1814
|
+
*
|
|
1815
|
+
* Copy is read from `displayLabels` only, never from `name`/`description`,
|
|
1816
|
+
* which carry internal campaign identifiers. Entries without a usable size are
|
|
1817
|
+
* dropped and a non-array value yields `undefined`, so an unexpected payload
|
|
1818
|
+
* never fails the account read that also feeds the model catalog.
|
|
1819
|
+
*/
|
|
1820
|
+
function normalizeResourcePackages(raw) {
|
|
1821
|
+
if (!Array.isArray(raw)) return void 0;
|
|
1822
|
+
const packages = [];
|
|
1823
|
+
for (const entry of raw) {
|
|
1824
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1825
|
+
const obj = entry;
|
|
1826
|
+
const quota = normalizeQuota(obj);
|
|
1827
|
+
if (quota === void 0 || quota.total <= 0 && quota.used <= 0 && quota.remaining <= 0) continue;
|
|
1828
|
+
const id = asString(obj.id);
|
|
1829
|
+
const title = normalizeLocalizedText(findDisplayLabel(obj.displayLabels ?? obj.display_labels, "title"));
|
|
1830
|
+
const description = normalizeLocalizedText(findDisplayLabel(obj.displayLabels ?? obj.display_labels, "description"));
|
|
1831
|
+
const expiresAt = normalizeExpiresAt(obj.expiresAt);
|
|
1832
|
+
const available = asBoolean(obj.available);
|
|
1833
|
+
const status = asString(obj.status);
|
|
1834
|
+
packages.push({
|
|
1835
|
+
...id !== void 0 ? { id } : {},
|
|
1836
|
+
...title !== void 0 ? { title } : {},
|
|
1837
|
+
...description !== void 0 ? { description } : {},
|
|
1838
|
+
...quota,
|
|
1839
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
1840
|
+
...available !== void 0 ? { available } : {},
|
|
1841
|
+
...status !== void 0 ? { status } : {}
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
return packages.length > 0 ? packages : void 0;
|
|
1845
|
+
}
|
|
1450
1846
|
function normalizeOrganization(raw) {
|
|
1451
1847
|
if (!raw || typeof raw !== "object") return void 0;
|
|
1452
1848
|
const obj = raw;
|
|
@@ -1569,6 +1965,14 @@ var QoderUsageReader = class {
|
|
|
1569
1965
|
if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
|
|
1570
1966
|
else this.cache.clear();
|
|
1571
1967
|
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Read `GET /api/v2/quota/usage` and normalize it for the host/client seam.
|
|
1970
|
+
*
|
|
1971
|
+
* The `raw` echo keeps the upstream fields for diagnostics, but never
|
|
1972
|
+
* `dedicatedResourcePackages`: those entries carry the campaign identifiers
|
|
1973
|
+
* that the normalized list deliberately discards, and this payload crosses
|
|
1974
|
+
* the seam into the browser.
|
|
1975
|
+
*/
|
|
1572
1976
|
async fetchUsage(jobToken, signal) {
|
|
1573
1977
|
const data = await openApiJsonRequest(this.fetchImpl, {
|
|
1574
1978
|
url: getQoderUsageUrl(this.region),
|
|
@@ -1579,13 +1983,17 @@ var QoderUsageReader = class {
|
|
|
1579
1983
|
operation: "Usage",
|
|
1580
1984
|
logCategory: "account.usage"
|
|
1581
1985
|
});
|
|
1986
|
+
const dedicatedResourcePackages = normalizeResourcePackages(data.dedicatedResourcePackages);
|
|
1987
|
+
const rawUsage = { ...data };
|
|
1988
|
+
delete rawUsage.dedicatedResourcePackages;
|
|
1582
1989
|
return {
|
|
1583
1990
|
userQuota: normalizeQuota(data.userQuota),
|
|
1584
1991
|
orgResourcePackage: normalizeQuota(data.orgResourcePackage),
|
|
1992
|
+
...dedicatedResourcePackages !== void 0 ? { dedicatedResourcePackages } : {},
|
|
1585
1993
|
totalUsagePercentage: typeof data.totalUsagePercentage === "number" ? data.totalUsagePercentage : void 0,
|
|
1586
1994
|
isQuotaExceeded: typeof data.isQuotaExceeded === "boolean" ? data.isQuotaExceeded : false,
|
|
1587
1995
|
expiresAt: normalizeExpiresAt(data.expiresAt),
|
|
1588
|
-
raw:
|
|
1996
|
+
raw: rawUsage
|
|
1589
1997
|
};
|
|
1590
1998
|
}
|
|
1591
1999
|
async fetchPlan(jobToken, signal) {
|
|
@@ -1659,6 +2067,163 @@ function qoderEncodeBody(plaintext) {
|
|
|
1659
2067
|
}
|
|
1660
2068
|
return out;
|
|
1661
2069
|
}
|
|
2070
|
+
function searchAborted(signal, fallback) {
|
|
2071
|
+
return new WebError("Web search was cancelled.", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
|
|
2072
|
+
}
|
|
2073
|
+
function isAbortError(error) {
|
|
2074
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
2075
|
+
}
|
|
2076
|
+
var QoderSearchClient = class {
|
|
2077
|
+
fetchImpl;
|
|
2078
|
+
region;
|
|
2079
|
+
logger;
|
|
2080
|
+
timeoutMs;
|
|
2081
|
+
resolveCredentials;
|
|
2082
|
+
refreshCredentials;
|
|
2083
|
+
now;
|
|
2084
|
+
constructor(options) {
|
|
2085
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
2086
|
+
this.region = options.region ?? "global";
|
|
2087
|
+
this.logger = options.logger;
|
|
2088
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
2089
|
+
this.resolveCredentials = options.resolveCredentials;
|
|
2090
|
+
this.refreshCredentials = options.refreshCredentials;
|
|
2091
|
+
this.now = options.now ?? Date.now;
|
|
2092
|
+
}
|
|
2093
|
+
async search(request, signal) {
|
|
2094
|
+
if (signal?.aborted) throw searchAborted(signal);
|
|
2095
|
+
let credentials = await this.resolveCredentials(signal);
|
|
2096
|
+
if (signal?.aborted) throw searchAborted(signal);
|
|
2097
|
+
const first = await this.attempt(request, credentials, signal);
|
|
2098
|
+
if (first.result !== void 0) return first.result;
|
|
2099
|
+
if (first.retryable && this.refreshCredentials !== void 0) {
|
|
2100
|
+
this.logger?.debug?.("[web-search] refreshing credentials before retry", {
|
|
2101
|
+
region: this.region,
|
|
2102
|
+
reason: first.reason
|
|
2103
|
+
});
|
|
2104
|
+
try {
|
|
2105
|
+
credentials = await this.refreshCredentials(signal);
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
if (signal?.aborted) throw searchAborted(signal, error);
|
|
2108
|
+
throw new WebError(`Qoder credential refresh failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
2109
|
+
}
|
|
2110
|
+
const second = await this.attempt(request, credentials, signal);
|
|
2111
|
+
if (second.result !== void 0) return second.result;
|
|
2112
|
+
throw new WebError(`Qoder web search failed: ${second.reason}`, "WEB_PROVIDER_ERROR");
|
|
2113
|
+
}
|
|
2114
|
+
throw new WebError(`Qoder web search failed: ${first.reason}`, "WEB_PROVIDER_ERROR");
|
|
2115
|
+
}
|
|
2116
|
+
async attempt(request, credentials, signal) {
|
|
2117
|
+
const url = getQoderWebSearchUrl(this.region);
|
|
2118
|
+
const innerPayload = {
|
|
2119
|
+
query: request.query,
|
|
2120
|
+
timeRange: "NoLimit",
|
|
2121
|
+
contents: {
|
|
2122
|
+
mainText: false,
|
|
2123
|
+
markdownText: false,
|
|
2124
|
+
summary: false
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
const encodedBody = qoderEncodeBody(JSON.stringify({
|
|
2128
|
+
payload: JSON.stringify(innerPayload),
|
|
2129
|
+
encodeVersion: "1"
|
|
2130
|
+
}));
|
|
2131
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
2132
|
+
const requestSignal = signal !== void 0 ? AbortSignal.any([signal, timeout]) : timeout;
|
|
2133
|
+
const startedAt = this.now();
|
|
2134
|
+
this.logger?.debug?.("[web-search] started", {
|
|
2135
|
+
region: this.region,
|
|
2136
|
+
url,
|
|
2137
|
+
query: request.query
|
|
2138
|
+
});
|
|
2139
|
+
let response;
|
|
2140
|
+
try {
|
|
2141
|
+
const authHeaders = buildAuthHeaders(encodedBody, url, credentials);
|
|
2142
|
+
response = await this.fetchImpl(url, {
|
|
2143
|
+
method: "POST",
|
|
2144
|
+
headers: {
|
|
2145
|
+
...authHeaders,
|
|
2146
|
+
"Content-Type": "application/json",
|
|
2147
|
+
"Accept": "application/json",
|
|
2148
|
+
"Accept-Encoding": "identity"
|
|
2149
|
+
},
|
|
2150
|
+
body: encodedBody,
|
|
2151
|
+
signal: requestSignal
|
|
2152
|
+
});
|
|
2153
|
+
} catch (error) {
|
|
2154
|
+
if (signal?.aborted) throw searchAborted(signal, error);
|
|
2155
|
+
if (timeout.aborted || isAbortError(error)) return {
|
|
2156
|
+
retryable: false,
|
|
2157
|
+
reason: "request timed out"
|
|
2158
|
+
};
|
|
2159
|
+
this.logger?.warn?.("[web-search] network failure", { region: this.region }, redactLogValue(error));
|
|
2160
|
+
return {
|
|
2161
|
+
retryable: false,
|
|
2162
|
+
reason: `network failure: ${String(error)}`
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
const elapsedMs = this.now() - startedAt;
|
|
2166
|
+
this.logger?.debug?.("[web-search] response received", {
|
|
2167
|
+
status: response.status,
|
|
2168
|
+
elapsedMs
|
|
2169
|
+
});
|
|
2170
|
+
if (!response.ok) return {
|
|
2171
|
+
retryable: response.status === 401 || response.status === 403,
|
|
2172
|
+
reason: `HTTP ${response.status} ${response.statusText}`.trim()
|
|
2173
|
+
};
|
|
2174
|
+
let text;
|
|
2175
|
+
try {
|
|
2176
|
+
text = await readLimitedText(response, defaultMaxJsonBytes, "Qoder web search response");
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
if (signal?.aborted) throw searchAborted(signal, error);
|
|
2179
|
+
return {
|
|
2180
|
+
retryable: false,
|
|
2181
|
+
reason: `failed to read response body: ${String(error)}`
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
let parsed;
|
|
2185
|
+
try {
|
|
2186
|
+
parsed = JSON.parse(text);
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
return {
|
|
2189
|
+
retryable: false,
|
|
2190
|
+
reason: `invalid JSON response: ${String(error)}`
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
if (parsed.errorCode != null && parsed.errorCode !== 0) return {
|
|
2194
|
+
retryable: false,
|
|
2195
|
+
reason: `backend error code ${parsed.errorCode}: ${parsed.errorMsg ?? "unknown"}`
|
|
2196
|
+
};
|
|
2197
|
+
const rawItems = parsed.pageItems ?? [];
|
|
2198
|
+
const sources = [];
|
|
2199
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2200
|
+
for (const item of rawItems) {
|
|
2201
|
+
if (!item.link || item.link.trim().length === 0) continue;
|
|
2202
|
+
const link = item.link.trim();
|
|
2203
|
+
if (seen.has(link)) continue;
|
|
2204
|
+
seen.add(link);
|
|
2205
|
+
sources.push({
|
|
2206
|
+
url: link,
|
|
2207
|
+
...item.title !== void 0 && item.title.trim().length > 0 ? { title: item.title.trim() } : {},
|
|
2208
|
+
...item.snippet !== void 0 && item.snippet.trim().length > 0 ? { snippet: item.snippet.trim() } : {},
|
|
2209
|
+
...item.publishedAt !== void 0 && item.publishedAt.trim().length > 0 ? { publishedAt: item.publishedAt.trim() } : {}
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
this.logger?.debug?.("[web-search] succeeded", {
|
|
2213
|
+
region: this.region,
|
|
2214
|
+
resultsCount: sources.length,
|
|
2215
|
+
elapsedMs
|
|
2216
|
+
});
|
|
2217
|
+
return {
|
|
2218
|
+
result: {
|
|
2219
|
+
sources,
|
|
2220
|
+
truncated: false
|
|
2221
|
+
},
|
|
2222
|
+
retryable: false,
|
|
2223
|
+
reason: ""
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
1662
2227
|
//#endregion
|
|
1663
2228
|
//#region src/qoder/transport/wire/thinking.ts
|
|
1664
2229
|
const thinkingTags = [{
|
|
@@ -1823,6 +2388,30 @@ const doneMarker = "[DONE]";
|
|
|
1823
2388
|
function malformed(message) {
|
|
1824
2389
|
return new QoderLlmError(message, "MALFORMED_RESPONSE");
|
|
1825
2390
|
}
|
|
2391
|
+
function extractErrorDetail(envelope) {
|
|
2392
|
+
const raw = envelope.body ?? envelope.message;
|
|
2393
|
+
if (!raw) return void 0;
|
|
2394
|
+
if (typeof raw !== "string") return JSON.stringify(raw);
|
|
2395
|
+
const trimmed = raw.trim();
|
|
2396
|
+
try {
|
|
2397
|
+
const parsed = JSON.parse(trimmed);
|
|
2398
|
+
if (parsed && typeof parsed === "object") {
|
|
2399
|
+
let detailMsg;
|
|
2400
|
+
if (typeof parsed.details === "string") try {
|
|
2401
|
+
const inner = JSON.parse(parsed.details);
|
|
2402
|
+
if (typeof inner?.error?.message === "string") detailMsg = inner.error.message.trim();
|
|
2403
|
+
else if (typeof inner?.message === "string") detailMsg = inner.message.trim();
|
|
2404
|
+
} catch {}
|
|
2405
|
+
const msg = typeof parsed.message === "string" ? parsed.message.trim() : void 0;
|
|
2406
|
+
const code = typeof parsed.code === "string" ? parsed.code.trim() : void 0;
|
|
2407
|
+
const err = typeof parsed.error === "string" ? parsed.error.trim() : void 0;
|
|
2408
|
+
const text = detailMsg || msg || err;
|
|
2409
|
+
if (code && text) return `${code}: ${text}`;
|
|
2410
|
+
return text || code || trimmed;
|
|
2411
|
+
}
|
|
2412
|
+
} catch {}
|
|
2413
|
+
return trimmed;
|
|
2414
|
+
}
|
|
1826
2415
|
function parseEnvelope(rawData) {
|
|
1827
2416
|
let value;
|
|
1828
2417
|
try {
|
|
@@ -1834,7 +2423,13 @@ function parseEnvelope(rawData) {
|
|
|
1834
2423
|
const envelope = value;
|
|
1835
2424
|
if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
|
|
1836
2425
|
if (envelope.statusCodeValue !== void 0 && (!Number.isInteger(envelope.statusCodeValue) || envelope.statusCodeValue < 100 || envelope.statusCodeValue > 599)) throw malformed("Qoder SSE envelope has an invalid status code.");
|
|
1837
|
-
if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200)
|
|
2426
|
+
if (envelope.statusCodeValue !== void 0 && envelope.statusCodeValue !== 200) {
|
|
2427
|
+
const detail = extractErrorDetail(envelope);
|
|
2428
|
+
throw qoderHttpError(detail ? `Qoder service returned upstream error status ${envelope.statusCodeValue}: ${detail}` : `Qoder service returned upstream error status ${envelope.statusCodeValue}.`, {
|
|
2429
|
+
status: envelope.statusCodeValue,
|
|
2430
|
+
cause: envelope.body
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
1838
2433
|
if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
|
|
1839
2434
|
return envelope;
|
|
1840
2435
|
}
|
|
@@ -1866,10 +2461,18 @@ function finishKind(value) {
|
|
|
1866
2461
|
if (value === void 0 || value === null || value === "") return void 0;
|
|
1867
2462
|
if (value === "stop") return "stop";
|
|
1868
2463
|
if (value === "length") return "max-tokens";
|
|
1869
|
-
if (value === "tool_calls" || value === "toolUse") return "tool-calls";
|
|
2464
|
+
if (value === "tool_calls" || value === "toolUse" || value === "function_call") return "tool-calls";
|
|
1870
2465
|
if (value === "content_filter") throw new QoderLlmError("Qoder blocked the response through its content filter.", "PROVIDER_ERROR");
|
|
1871
2466
|
throw malformed(`Qoder returned unknown finish reason "${value}".`);
|
|
1872
2467
|
}
|
|
2468
|
+
/** Whether a choice delta streams content, reasoning, or tool-call data. Control-only frames do not. */
|
|
2469
|
+
function carriesStreamDelta(delta) {
|
|
2470
|
+
if (delta == null) return false;
|
|
2471
|
+
if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true;
|
|
2472
|
+
if (typeof delta.content === "string" && delta.content) return true;
|
|
2473
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return true;
|
|
2474
|
+
return false;
|
|
2475
|
+
}
|
|
1873
2476
|
async function* parseQoderSse(stream, options = {}) {
|
|
1874
2477
|
const reader = stream.getReader();
|
|
1875
2478
|
const decoder = new TextDecoder();
|
|
@@ -1882,6 +2485,7 @@ async function* parseQoderSse(stream, options = {}) {
|
|
|
1882
2485
|
let activeTextual;
|
|
1883
2486
|
let pendingUsage;
|
|
1884
2487
|
let terminalKind = "stop";
|
|
2488
|
+
let sawFinishReason = false;
|
|
1885
2489
|
let sawContent = false;
|
|
1886
2490
|
const maxBufferChars = options.maxBufferChars ?? 2097152;
|
|
1887
2491
|
const closeTextual = () => {
|
|
@@ -1976,7 +2580,8 @@ async function* parseQoderSse(stream, options = {}) {
|
|
|
1976
2580
|
sawDone = true;
|
|
1977
2581
|
break;
|
|
1978
2582
|
}
|
|
1979
|
-
const
|
|
2583
|
+
const envelope = parseEnvelope(rawData);
|
|
2584
|
+
const body = typeof envelope.body === "string" ? envelope.body.trim() : "";
|
|
1980
2585
|
if (!body) continue;
|
|
1981
2586
|
if (body === doneMarker) {
|
|
1982
2587
|
sawDone = true;
|
|
@@ -1985,7 +2590,11 @@ async function* parseQoderSse(stream, options = {}) {
|
|
|
1985
2590
|
const innerChunk = parseInner(body);
|
|
1986
2591
|
pendingUsage = tokenUsage(innerChunk) ?? pendingUsage;
|
|
1987
2592
|
for (const choice of innerChunk.choices ?? []) {
|
|
1988
|
-
|
|
2593
|
+
const kind = finishKind(choice.finish_reason);
|
|
2594
|
+
if (kind !== void 0) {
|
|
2595
|
+
terminalKind = kind;
|
|
2596
|
+
sawFinishReason = true;
|
|
2597
|
+
} else if (carriesStreamDelta(choice.delta)) sawFinishReason = false;
|
|
1989
2598
|
const delta = choice.delta;
|
|
1990
2599
|
if (!delta) continue;
|
|
1991
2600
|
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) for (const segment of thinkingParser.pushReasoning(delta.reasoning_content)) for (const chunk of appendSegment(segment)) yield chunk;
|
|
@@ -2055,7 +2664,7 @@ async function* parseQoderSse(stream, options = {}) {
|
|
|
2055
2664
|
await reader.cancel().catch(() => void 0);
|
|
2056
2665
|
reader.releaseLock();
|
|
2057
2666
|
}
|
|
2058
|
-
if (!sawDone) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
|
|
2667
|
+
if (!sawDone && !sawFinishReason) throw new QoderLlmError("SSE stream ended prematurely without [DONE].", "TRANSPORT");
|
|
2059
2668
|
for (const segment of thinkingParser.finish()) for (const chunk of appendSegment(segment)) yield chunk;
|
|
2060
2669
|
for (const chunk of closeTextual()) yield chunk;
|
|
2061
2670
|
for (const [, state] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
|
|
@@ -2123,7 +2732,10 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
|
|
|
2123
2732
|
let headerTimedOut = false;
|
|
2124
2733
|
let idleTimedOut = false;
|
|
2125
2734
|
let idleTimer;
|
|
2735
|
+
let chunkCount = 0;
|
|
2736
|
+
let reqId;
|
|
2126
2737
|
const startedAt = performance.now();
|
|
2738
|
+
let lastActivityAt = startedAt;
|
|
2127
2739
|
const onCallerAbort = () => requestController.abort(options.signal?.reason);
|
|
2128
2740
|
options.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
2129
2741
|
const headerTimer = setTimeout(() => {
|
|
@@ -2131,6 +2743,7 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
|
|
|
2131
2743
|
requestController.abort("response header timeout");
|
|
2132
2744
|
}, dependencies.responseHeaderTimeoutMs);
|
|
2133
2745
|
const resetIdleTimer = () => {
|
|
2746
|
+
lastActivityAt = performance.now();
|
|
2134
2747
|
if (idleTimer !== void 0) clearTimeout(idleTimer);
|
|
2135
2748
|
idleTimer = setTimeout(() => {
|
|
2136
2749
|
idleTimedOut = true;
|
|
@@ -2154,34 +2767,100 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
|
|
|
2154
2767
|
signal: requestController.signal
|
|
2155
2768
|
});
|
|
2156
2769
|
clearTimeout(headerTimer);
|
|
2770
|
+
reqId = qoderRequestId(response.headers);
|
|
2157
2771
|
dependencies.logger?.debug?.("[Qoder Stream] Response headers received", {
|
|
2158
2772
|
region: dependencies.region,
|
|
2159
2773
|
status: response.status,
|
|
2160
2774
|
durationMs: Math.round(performance.now() - startedAt),
|
|
2161
|
-
...
|
|
2775
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2162
2776
|
});
|
|
2163
2777
|
resetIdleTimer();
|
|
2164
|
-
if (!response.ok)
|
|
2778
|
+
if (!response.ok) {
|
|
2779
|
+
let bodyText = "";
|
|
2780
|
+
try {
|
|
2781
|
+
bodyText = (await response.text()).trim();
|
|
2782
|
+
} catch {}
|
|
2783
|
+
let detail = "";
|
|
2784
|
+
if (bodyText) try {
|
|
2785
|
+
const parsed = JSON.parse(bodyText);
|
|
2786
|
+
const msg = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message.trim() : void 0;
|
|
2787
|
+
const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : void 0;
|
|
2788
|
+
if (code && msg) detail = `: ${code}: ${msg}`;
|
|
2789
|
+
else if (msg) detail = `: ${msg}`;
|
|
2790
|
+
else detail = `: ${bodyText.slice(0, 300)}`;
|
|
2791
|
+
} catch {
|
|
2792
|
+
detail = `: ${bodyText.slice(0, 300)}`;
|
|
2793
|
+
}
|
|
2794
|
+
throw qoderHttpError(`Qoder upstream service returned HTTP ${response.status}${detail}.`, {
|
|
2795
|
+
status: response.status,
|
|
2796
|
+
headers: response.headers,
|
|
2797
|
+
cause: bodyText || void 0
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2165
2800
|
if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
|
|
2166
|
-
|
|
2801
|
+
let firstChunkDurationMs;
|
|
2802
|
+
let tokenUsage;
|
|
2803
|
+
let finishReason;
|
|
2804
|
+
const streamStartedAt = performance.now();
|
|
2805
|
+
for await (const chunk of parseQoderSse(response.body, { onActivity: resetIdleTimer })) {
|
|
2806
|
+
chunkCount++;
|
|
2807
|
+
if (firstChunkDurationMs === void 0) {
|
|
2808
|
+
firstChunkDurationMs = Math.round(performance.now() - startedAt);
|
|
2809
|
+
dependencies.logger?.debug?.("[Qoder Stream] First chunk received", {
|
|
2810
|
+
durationMs: firstChunkDurationMs,
|
|
2811
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
if (chunk.type === "usage") tokenUsage = chunk.usage;
|
|
2815
|
+
if (chunk.type === "finish") finishReason = chunk.reason.kind;
|
|
2816
|
+
yield chunk;
|
|
2817
|
+
}
|
|
2818
|
+
dependencies.logger?.debug?.("[Qoder Stream] Stream completed", {
|
|
2819
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2820
|
+
streamDurationMs: Math.round(performance.now() - streamStartedAt),
|
|
2821
|
+
chunkCount,
|
|
2822
|
+
...finishReason === void 0 ? {} : { finishReason },
|
|
2823
|
+
...tokenUsage === void 0 ? {} : { usage: tokenUsage },
|
|
2824
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2825
|
+
});
|
|
2167
2826
|
} catch (error) {
|
|
2168
2827
|
if (options.signal?.aborted) throw aborted$1("Request was aborted.");
|
|
2828
|
+
const elapsedMs = Math.round(performance.now() - startedAt);
|
|
2169
2829
|
if (headerTimedOut) {
|
|
2170
2830
|
const failure = new QoderLlmError("Qoder model request exceeded its response header timeout.", "TIMEOUT");
|
|
2171
|
-
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure)
|
|
2831
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2832
|
+
phase: "header",
|
|
2833
|
+
elapsedMs,
|
|
2834
|
+
timeoutMs: dependencies.responseHeaderTimeoutMs
|
|
2835
|
+
});
|
|
2172
2836
|
throw failure;
|
|
2173
2837
|
}
|
|
2174
2838
|
if (idleTimedOut) {
|
|
2175
2839
|
const failure = new QoderLlmError("Qoder model stream exceeded its idle timeout.", "TIMEOUT");
|
|
2176
|
-
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure)
|
|
2840
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2841
|
+
phase: "stream-idle",
|
|
2842
|
+
chunkCount,
|
|
2843
|
+
idleDurationMs: Math.round(performance.now() - lastActivityAt),
|
|
2844
|
+
elapsedMs,
|
|
2845
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2846
|
+
});
|
|
2177
2847
|
throw failure;
|
|
2178
2848
|
}
|
|
2179
2849
|
if (error instanceof QoderLlmError) {
|
|
2180
|
-
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error)
|
|
2850
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(error), {
|
|
2851
|
+
chunkCount,
|
|
2852
|
+
elapsedMs,
|
|
2853
|
+
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2854
|
+
});
|
|
2181
2855
|
throw error;
|
|
2182
2856
|
}
|
|
2183
2857
|
const failure = new QoderLlmError("Qoder transport request failed.", "TRANSPORT", { cause: error });
|
|
2184
|
-
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2858
|
+
dependencies.logger?.error?.("[Qoder Stream] Request failed", redactLogValue(failure), {
|
|
2859
|
+
chunkCount,
|
|
2860
|
+
elapsedMs,
|
|
2861
|
+
...reqId === void 0 ? {} : { requestId: reqId },
|
|
2862
|
+
cause: redactLogValue(error)
|
|
2863
|
+
});
|
|
2185
2864
|
throw failure;
|
|
2186
2865
|
} finally {
|
|
2187
2866
|
clearTimeout(headerTimer);
|
|
@@ -2209,6 +2888,7 @@ var DefaultQoderTransport = class {
|
|
|
2209
2888
|
modelFlights = new SingleFlight();
|
|
2210
2889
|
attachments;
|
|
2211
2890
|
imageUploader;
|
|
2891
|
+
searchClient;
|
|
2212
2892
|
preserveThinking;
|
|
2213
2893
|
constructor(options) {
|
|
2214
2894
|
this.region = options.region;
|
|
@@ -2216,7 +2896,7 @@ var DefaultQoderTransport = class {
|
|
|
2216
2896
|
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
2217
2897
|
this.logger = options.logger;
|
|
2218
2898
|
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? 3e5;
|
|
2219
|
-
this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ??
|
|
2899
|
+
this.responseHeaderTimeoutMs = options.responseHeaderTimeoutMs ?? 6e4;
|
|
2220
2900
|
this.metadataTimeoutMs = options.metadataTimeoutMs;
|
|
2221
2901
|
this.attachments = options.attachments;
|
|
2222
2902
|
this.preserveThinking = options.preserveThinking;
|
|
@@ -2245,6 +2925,20 @@ var DefaultQoderTransport = class {
|
|
|
2245
2925
|
return this.auth.getCredentials(pat, signal);
|
|
2246
2926
|
}
|
|
2247
2927
|
});
|
|
2928
|
+
this.searchClient = new QoderSearchClient({
|
|
2929
|
+
fetch: this.fetchImpl,
|
|
2930
|
+
logger: this.logger,
|
|
2931
|
+
region: this.region,
|
|
2932
|
+
resolveCredentials: async (signal) => {
|
|
2933
|
+
const pat = await this.requirePat(signal);
|
|
2934
|
+
return this.auth.getCredentials(pat, signal);
|
|
2935
|
+
},
|
|
2936
|
+
refreshCredentials: async (signal) => {
|
|
2937
|
+
const pat = await this.requirePat(signal);
|
|
2938
|
+
this.auth.clear(pat);
|
|
2939
|
+
return this.auth.getCredentials(pat, signal);
|
|
2940
|
+
}
|
|
2941
|
+
});
|
|
2248
2942
|
}
|
|
2249
2943
|
stream(options, model) {
|
|
2250
2944
|
return this.generate(options, model);
|
|
@@ -2270,6 +2964,9 @@ var DefaultQoderTransport = class {
|
|
|
2270
2964
|
signal: options?.signal
|
|
2271
2965
|
});
|
|
2272
2966
|
}
|
|
2967
|
+
async searchWeb(request, signal) {
|
|
2968
|
+
return this.searchClient.search(request, signal);
|
|
2969
|
+
}
|
|
2273
2970
|
async requirePat(signal) {
|
|
2274
2971
|
if (signal?.aborted) throw aborted("Qoder request was aborted.");
|
|
2275
2972
|
const pat = (await this.resolvePat()).trim();
|
|
@@ -2333,8 +3030,18 @@ const Config = z.object({
|
|
|
2333
3030
|
modelsByRegion: modelsByRegionSchema.default({}),
|
|
2334
3031
|
models: z.array(catalogModel),
|
|
2335
3032
|
streamIdleTimeoutMs: z.number().step(1).min(1).default(defaultStreamIdleTimeoutMs),
|
|
2336
|
-
|
|
3033
|
+
responseHeaderTimeoutMs: z.number().step(1).min(1).default(defaultResponseHeaderTimeoutMs),
|
|
3034
|
+
preserveThinking: z.boolean().default(true),
|
|
3035
|
+
webSearchMode: z.union([
|
|
3036
|
+
"auto",
|
|
3037
|
+
"always",
|
|
3038
|
+
"disabled"
|
|
3039
|
+
]).default("auto")
|
|
2337
3040
|
});
|
|
3041
|
+
const PluginConfig = Config.volatile();
|
|
3042
|
+
function readConfig(value) {
|
|
3043
|
+
return "get" in value ? Config(structuredClone(value.get())) : value;
|
|
3044
|
+
}
|
|
2338
3045
|
function resolveModels(models) {
|
|
2339
3046
|
const resolved = models ?? defaultModels;
|
|
2340
3047
|
if (resolved.length === 0) throw new Error("provider-qoder: at least one model is required");
|
|
@@ -2353,6 +3060,10 @@ function modelsFor(config, region, legacyModelsRegion = config.region ?? "global
|
|
|
2353
3060
|
return resolveModels(defaultModels);
|
|
2354
3061
|
}
|
|
2355
3062
|
//#endregion
|
|
3063
|
+
//#region src/dsh/provider.ts
|
|
3064
|
+
/** DSH model provider route, matching the plugin package name. */
|
|
3065
|
+
const QODER_PROVIDER_ID = "dsh-provider-qoder";
|
|
3066
|
+
//#endregion
|
|
2356
3067
|
//#region src/dsh/adapter.ts
|
|
2357
3068
|
/** Thin DSH adapter over the Qoder transport seam. */
|
|
2358
3069
|
function modelInfo(provider, model) {
|
|
@@ -2369,12 +3080,15 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2369
3080
|
catalogModels;
|
|
2370
3081
|
providerId;
|
|
2371
3082
|
providerName;
|
|
3083
|
+
onModelsDiscovered;
|
|
3084
|
+
discoveries = /* @__PURE__ */ new WeakMap();
|
|
2372
3085
|
constructor(options) {
|
|
2373
3086
|
super();
|
|
2374
3087
|
this.resolveTransport = options.resolveTransport;
|
|
2375
3088
|
this.catalogModels = options.models && options.models.length > 0 ? options.models : defaultModels;
|
|
2376
|
-
this.providerId = options.providerId ?? "qoder
|
|
3089
|
+
this.providerId = options.providerId ?? "dsh-provider-qoder";
|
|
2377
3090
|
this.providerName = options.providerName ?? "Qoder";
|
|
3091
|
+
this.onModelsDiscovered = options.onModelsDiscovered;
|
|
2378
3092
|
}
|
|
2379
3093
|
providerInfo(provider) {
|
|
2380
3094
|
return {
|
|
@@ -2382,15 +3096,55 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2382
3096
|
name: this.providerName
|
|
2383
3097
|
};
|
|
2384
3098
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
3099
|
+
/** Refresh advertised metadata on catalog reads, sharing a five-minute transport-local cache. */
|
|
3100
|
+
async listModels(provider) {
|
|
3101
|
+
const transport = this.resolveTransport();
|
|
3102
|
+
let cached = this.discoveries.get(transport);
|
|
3103
|
+
if (cached === void 0) {
|
|
3104
|
+
cached = { expiresAt: 0 };
|
|
3105
|
+
this.discoveries.set(transport, cached);
|
|
3106
|
+
}
|
|
3107
|
+
const entry = cached;
|
|
3108
|
+
if (entry.inflight === void 0 && Date.now() >= entry.expiresAt) entry.inflight = Promise.resolve().then(() => transport.discoverModels()).then((models) => {
|
|
3109
|
+
if (this.discoveries.get(transport) !== entry || this.resolveTransport() !== transport) return;
|
|
3110
|
+
entry.models = models;
|
|
3111
|
+
entry.expiresAt = Date.now() + 300 * 1e3;
|
|
3112
|
+
this.publishDiscoveredModels(transport, models);
|
|
3113
|
+
}).catch(() => {}).finally(() => {
|
|
3114
|
+
entry.inflight = void 0;
|
|
3115
|
+
});
|
|
3116
|
+
await entry.inflight;
|
|
3117
|
+
if (this.resolveTransport() !== transport) return this.listModels(provider);
|
|
3118
|
+
return this.effectiveModels().map((model) => modelInfo(provider, model));
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Announce one accepted discovery without joining the catalog read.
|
|
3122
|
+
*
|
|
3123
|
+
* Persisting metadata can queue behind unrelated settings writes, so awaiting
|
|
3124
|
+
* it here would stall every `listModels` call on settings storage.
|
|
3125
|
+
*/
|
|
3126
|
+
publishDiscoveredModels(transport, models) {
|
|
3127
|
+
try {
|
|
3128
|
+
const pending = this.onModelsDiscovered?.(transport, models);
|
|
3129
|
+
if (pending !== void 0 && pending !== null && typeof pending.catch === "function") pending.catch(() => {});
|
|
3130
|
+
} catch {}
|
|
3131
|
+
}
|
|
3132
|
+
effectiveModels() {
|
|
3133
|
+
return mergeQoderDiscoveryMetadata(this.catalogModels, this.discoveries.get(this.resolveTransport())?.models ?? []);
|
|
2387
3134
|
}
|
|
2388
3135
|
replaceModels(models) {
|
|
2389
3136
|
this.catalogModels = models;
|
|
2390
3137
|
}
|
|
3138
|
+
/** Publish an explicit discovery without letting older in-flight reads overwrite it. */
|
|
3139
|
+
updateDiscoveredModels(transport, models) {
|
|
3140
|
+
this.discoveries.set(transport, {
|
|
3141
|
+
models,
|
|
3142
|
+
expiresAt: Date.now() + 300 * 1e3
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
2391
3145
|
resolveModel(provider, modelId, signal) {
|
|
2392
3146
|
if (signal?.aborted) return Promise.reject(new QoderLlmError("Qoder model resolution was aborted.", "ABORTED"));
|
|
2393
|
-
const configured = this.
|
|
3147
|
+
const configured = this.effectiveModels().find((model) => model.id === modelId);
|
|
2394
3148
|
if (configured === void 0) return Promise.resolve({
|
|
2395
3149
|
provider,
|
|
2396
3150
|
id: modelId,
|
|
@@ -2413,11 +3167,64 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2413
3167
|
}
|
|
2414
3168
|
stream(options) {
|
|
2415
3169
|
if (options.provider !== this.providerId) throw new QoderLlmError(`Qoder adapter does not own provider "${options.provider}".`, "INVALID_PROVIDER");
|
|
2416
|
-
const model = this.
|
|
3170
|
+
const model = this.effectiveModels().find((candidate) => candidate.id === (options.model || "cmodel"));
|
|
2417
3171
|
return this.resolveTransport().stream(options, model);
|
|
2418
3172
|
}
|
|
2419
3173
|
};
|
|
2420
3174
|
//#endregion
|
|
3175
|
+
//#region src/dsh/search-provider.ts
|
|
3176
|
+
/**
|
|
3177
|
+
* DSH WebSearchProvider implementation with initiator-aware dynamic routing.
|
|
3178
|
+
*
|
|
3179
|
+
* Implements the `@deepseek-ai/dsh-web` capability seam, routing search queries
|
|
3180
|
+
* to Qoder Center when an agent runs with a Qoder model (`dsh-provider-qoder`),
|
|
3181
|
+
* or delegating to a fallback search provider when another model is active.
|
|
3182
|
+
*
|
|
3183
|
+
* @module dsh-provider-qoder/dsh/search-provider
|
|
3184
|
+
*/
|
|
3185
|
+
const QODER_SEARCH_PROVIDER_ID = "qoder";
|
|
3186
|
+
const QODER_MODEL_PROVIDER_ROUTE = QODER_PROVIDER_ID;
|
|
3187
|
+
var QoderSearchProvider = class {
|
|
3188
|
+
id = QODER_SEARCH_PROVIDER_ID;
|
|
3189
|
+
ctx;
|
|
3190
|
+
resolveTransport;
|
|
3191
|
+
searchClient;
|
|
3192
|
+
getWebSearchMode;
|
|
3193
|
+
fallbackProvider;
|
|
3194
|
+
constructor(options) {
|
|
3195
|
+
this.ctx = options.ctx;
|
|
3196
|
+
this.resolveTransport = options.resolveTransport;
|
|
3197
|
+
this.searchClient = options.searchClient;
|
|
3198
|
+
this.getWebSearchMode = options.getWebSearchMode;
|
|
3199
|
+
this.fallbackProvider = options.fallbackProvider;
|
|
3200
|
+
}
|
|
3201
|
+
available() {
|
|
3202
|
+
return this.getWebSearchMode() !== "disabled";
|
|
3203
|
+
}
|
|
3204
|
+
async search(request, signal) {
|
|
3205
|
+
const mode = this.getWebSearchMode();
|
|
3206
|
+
if (mode === "disabled") throw new WebError("Qoder web search is disabled in settings.", "WEB_PROVIDER_UNAVAILABLE");
|
|
3207
|
+
const agent = (this.ctx.get("agents") ?? this.ctx.agents)?.currentInitiator?.();
|
|
3208
|
+
const defaultModelService = this.ctx.get("agentDefaultModel");
|
|
3209
|
+
const providerRoute = agent?.options?.provider ?? defaultModelService?.get?.()?.provider;
|
|
3210
|
+
if (mode === "always" || providerRoute === QODER_MODEL_PROVIDER_ROUTE) {
|
|
3211
|
+
if (this.resolveTransport !== void 0) return this.resolveTransport().searchWeb(request, signal);
|
|
3212
|
+
if (this.searchClient !== void 0) return this.searchClient.search(request, signal);
|
|
3213
|
+
throw new WebError("No Qoder transport configured for web search.", "WEB_PROVIDER_ERROR");
|
|
3214
|
+
}
|
|
3215
|
+
const fallback = this.resolveFallback();
|
|
3216
|
+
if (fallback && fallback.available()) return fallback.search(request, signal);
|
|
3217
|
+
throw new WebError(`Current model provider is "${providerRoute ?? "unknown"}" rather than "${QODER_MODEL_PROVIDER_ROUTE}", and no fallback web search provider is available. Switch to a Qoder model or configure an ambient search provider.`, "WEB_PROVIDER_UNAVAILABLE");
|
|
3218
|
+
}
|
|
3219
|
+
resolveFallback() {
|
|
3220
|
+
if (this.fallbackProvider && this.fallbackProvider.available()) return this.fallbackProvider;
|
|
3221
|
+
const providers = this.ctx.get("web")?.searchProviders;
|
|
3222
|
+
if (providers && typeof providers.entries === "function") {
|
|
3223
|
+
for (const [id, provider] of providers.entries()) if (id !== "qoder" && provider.available()) return provider;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
};
|
|
3227
|
+
//#endregion
|
|
2421
3228
|
//#region src/dsh/credential-contract.ts
|
|
2422
3229
|
/** Shared DSH host/browser contract for the one managed Qoder credential. */
|
|
2423
3230
|
const qoderCredentialRef = "QODER_MANAGED_CREDENTIAL";
|
|
@@ -2436,6 +3243,116 @@ async function resolveManagedQoderPat(credentials) {
|
|
|
2436
3243
|
return hit.value.trim();
|
|
2437
3244
|
}
|
|
2438
3245
|
//#endregion
|
|
3246
|
+
//#region src/dsh/settings.ts
|
|
3247
|
+
function bindQoderSettings(owner, ctx, input, options) {
|
|
3248
|
+
const settings = ctx.settings;
|
|
3249
|
+
if (typeof settings.register === "function") {
|
|
3250
|
+
const namespace = "provider-qoder";
|
|
3251
|
+
return {
|
|
3252
|
+
namespace,
|
|
3253
|
+
scope: settings.register(namespace, Config, options)
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
3256
|
+
const namespace = owner.fiber.entry?.options.id ?? "provider-qoder";
|
|
3257
|
+
const get = () => readConfig(input);
|
|
3258
|
+
options.validate?.(get());
|
|
3259
|
+
ctx.effect(() => owner.on("internal/config", function(_raw, next) {
|
|
3260
|
+
const value = next();
|
|
3261
|
+
if (this === owner.fiber) options.validate?.(Config(value));
|
|
3262
|
+
return value;
|
|
3263
|
+
}));
|
|
3264
|
+
return {
|
|
3265
|
+
namespace,
|
|
3266
|
+
scope: {
|
|
3267
|
+
get,
|
|
3268
|
+
update: (patch) => settings.update(namespace, patch),
|
|
3269
|
+
watch: (listener) => {
|
|
3270
|
+
const on = owner.on.bind(owner);
|
|
3271
|
+
return ctx.effect(() => on("loader/volatile-update", () => {
|
|
3272
|
+
Promise.resolve().then(listener).catch((error) => ctx.emit("internal/error", error));
|
|
3273
|
+
}));
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
/**
|
|
3279
|
+
* Absolute Fetch-route prefix. Connection mounts its shared, authenticated API
|
|
3280
|
+
* channel at `/api`, and every exact Fetch route lives directly below it.
|
|
3281
|
+
*/
|
|
3282
|
+
const qoderRpcApiPath = `/api/qoder-subscription`;
|
|
3283
|
+
/** Endpoints the account card and the model catalog call. */
|
|
3284
|
+
const qoderRpcEndpoints = ["account", "models"];
|
|
3285
|
+
/** Absolute Fetch-route path of one endpoint. */
|
|
3286
|
+
function qoderRpcPath(endpoint) {
|
|
3287
|
+
return `${qoderRpcApiPath}/${endpoint}`;
|
|
3288
|
+
}
|
|
3289
|
+
/** Type guard for an untrusted endpoint name. */
|
|
3290
|
+
function isQoderRpcEndpoint(value) {
|
|
3291
|
+
return typeof value === "string" && qoderRpcEndpoints.includes(value);
|
|
3292
|
+
}
|
|
3293
|
+
//#endregion
|
|
3294
|
+
//#region src/dsh/rpc.ts
|
|
3295
|
+
/**
|
|
3296
|
+
* Mount every Qoder settings endpoint on the shared API channel.
|
|
3297
|
+
*
|
|
3298
|
+
* Registering is caller-owned: the returned disposer removes the routes again,
|
|
3299
|
+
* so the caller can tie them to its fiber with `ctx.effect`.
|
|
3300
|
+
*
|
|
3301
|
+
* @param ctx - host context carrying the Connection service.
|
|
3302
|
+
* @param handler - decoded endpoint handler, shared by every endpoint.
|
|
3303
|
+
* @returns disposer removing every registered route.
|
|
3304
|
+
* @throws when Connection exposes no exact Fetch registry.
|
|
3305
|
+
*/
|
|
3306
|
+
function registerQoderRpc(ctx, handler) {
|
|
3307
|
+
const registry = ctx.connection?.fetch;
|
|
3308
|
+
if (typeof registry?.register !== "function") throw new Error("provider-qoder: connection exposes no exact Fetch route registry");
|
|
3309
|
+
const disposers = qoderRpcEndpoints.map((endpoint) => registry.register({
|
|
3310
|
+
path: qoderRpcPath(endpoint),
|
|
3311
|
+
methods: ["POST"],
|
|
3312
|
+
requestBody: "buffered",
|
|
3313
|
+
fetch: (request) => handleQoderRpcRequest(endpoint, handler, request)
|
|
3314
|
+
}));
|
|
3315
|
+
return () => {
|
|
3316
|
+
for (const dispose of disposers) if (typeof dispose === "function") dispose();
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
/**
|
|
3320
|
+
* Decode one request, run the endpoint handler, and encode its result.
|
|
3321
|
+
*
|
|
3322
|
+
* Failures stay inside the response envelope so the client keeps a single error
|
|
3323
|
+
* path; only a malformed body and a handler crash use a transport status.
|
|
3324
|
+
*/
|
|
3325
|
+
async function handleQoderRpcRequest(endpoint, handler, request) {
|
|
3326
|
+
if (request.signal.aborted) return Response.json({
|
|
3327
|
+
ok: false,
|
|
3328
|
+
error: {
|
|
3329
|
+
code: "ABORTED",
|
|
3330
|
+
message: "Request aborted",
|
|
3331
|
+
details: { issues: [] }
|
|
3332
|
+
}
|
|
3333
|
+
});
|
|
3334
|
+
let payload = {};
|
|
3335
|
+
const body = await request.text();
|
|
3336
|
+
if (body.trim().length > 0) try {
|
|
3337
|
+
payload = JSON.parse(body);
|
|
3338
|
+
} catch {
|
|
3339
|
+
return new Response("body is not JSON", { status: 400 });
|
|
3340
|
+
}
|
|
3341
|
+
try {
|
|
3342
|
+
return Response.json(await handler(endpoint, payload, request.signal));
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
if (request.signal.aborted) return Response.json({
|
|
3345
|
+
ok: false,
|
|
3346
|
+
error: {
|
|
3347
|
+
code: "ABORTED",
|
|
3348
|
+
message: "Request aborted",
|
|
3349
|
+
details: { issues: [] }
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
return new Response(`handler failure: ${String(error)}`, { status: 500 });
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
//#endregion
|
|
2439
3356
|
//#region src/dsh/plugin.ts
|
|
2440
3357
|
const name = "provider-qoder";
|
|
2441
3358
|
const inject = [
|
|
@@ -2444,9 +3361,8 @@ const inject = [
|
|
|
2444
3361
|
"connection",
|
|
2445
3362
|
"attachments"
|
|
2446
3363
|
];
|
|
2447
|
-
const providerQoder =
|
|
3364
|
+
const providerQoder = QODER_PROVIDER_ID;
|
|
2448
3365
|
const settingsNamespace = "provider-qoder";
|
|
2449
|
-
const qoderChannel = "/qoder-subscription";
|
|
2450
3366
|
const fiberDisposed = 4;
|
|
2451
3367
|
const fiberUnloading = 5;
|
|
2452
3368
|
function logError(error) {
|
|
@@ -2457,17 +3373,18 @@ function logError(error) {
|
|
|
2457
3373
|
..."code" in error ? { code: error.code } : {}
|
|
2458
3374
|
};
|
|
2459
3375
|
}
|
|
2460
|
-
function publicError(message) {
|
|
3376
|
+
function publicError(code, message, details = { issues: [] }) {
|
|
2461
3377
|
return {
|
|
2462
3378
|
ok: false,
|
|
2463
3379
|
error: {
|
|
2464
|
-
code
|
|
3380
|
+
code,
|
|
2465
3381
|
message,
|
|
2466
|
-
details
|
|
3382
|
+
details
|
|
2467
3383
|
}
|
|
2468
3384
|
};
|
|
2469
3385
|
}
|
|
2470
|
-
function apply(ctx,
|
|
3386
|
+
function apply(ctx, input = {}) {
|
|
3387
|
+
const config = readConfig(input);
|
|
2471
3388
|
const logger = ctx.logger;
|
|
2472
3389
|
const initialRegion = config.region ?? "global";
|
|
2473
3390
|
const hasConfiguredModels = config.models !== void 0 && config.models.length > 0;
|
|
@@ -2476,7 +3393,9 @@ function apply(ctx, config = {}) {
|
|
|
2476
3393
|
modelsByRegion: { ...config.modelsByRegion },
|
|
2477
3394
|
...hasConfiguredModels ? { models: resolveModels(config.models) } : {},
|
|
2478
3395
|
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? 3e5,
|
|
2479
|
-
|
|
3396
|
+
responseHeaderTimeoutMs: config.responseHeaderTimeoutMs ?? 6e4,
|
|
3397
|
+
preserveThinking: config.preserveThinking ?? true,
|
|
3398
|
+
webSearchMode: config.webSearchMode ?? "auto"
|
|
2480
3399
|
};
|
|
2481
3400
|
let current = () => baseConfig;
|
|
2482
3401
|
let legacyModelsRegion = initialRegion;
|
|
@@ -2485,11 +3404,12 @@ function apply(ctx, config = {}) {
|
|
|
2485
3404
|
global: [],
|
|
2486
3405
|
china: []
|
|
2487
3406
|
};
|
|
2488
|
-
const createTransport = (region, streamIdleTimeoutMs, preserveThinking, resolvePat = () => resolveManagedQoderPat(ctx.credentials)) => createQoderTransport({
|
|
3407
|
+
const createTransport = (region, streamIdleTimeoutMs, responseHeaderTimeoutMs, preserveThinking, resolvePat = () => resolveManagedQoderPat(ctx.credentials)) => createQoderTransport({
|
|
2489
3408
|
region,
|
|
2490
3409
|
resolvePat,
|
|
2491
3410
|
logger,
|
|
2492
3411
|
streamIdleTimeoutMs,
|
|
3412
|
+
responseHeaderTimeoutMs,
|
|
2493
3413
|
attachments: ctx.attachments,
|
|
2494
3414
|
preserveThinking
|
|
2495
3415
|
});
|
|
@@ -2500,30 +3420,43 @@ function apply(ctx, config = {}) {
|
|
|
2500
3420
|
region,
|
|
2501
3421
|
models: mergeQoderDiscoveryMetadata(modelsFor(value, region, legacyModelsRegion), discoveredCatalogs[region]),
|
|
2502
3422
|
streamIdleTimeoutMs: value.streamIdleTimeoutMs ?? 3e5,
|
|
2503
|
-
|
|
3423
|
+
responseHeaderTimeoutMs: value.responseHeaderTimeoutMs ?? 6e4,
|
|
3424
|
+
preserveThinking: value.preserveThinking ?? true,
|
|
3425
|
+
webSearchMode: value.webSearchMode ?? "auto"
|
|
2504
3426
|
};
|
|
2505
3427
|
};
|
|
2506
3428
|
const initial = resolveConfig();
|
|
2507
|
-
let activeTransport = createTransport(initial.region, initial.streamIdleTimeoutMs, initial.preserveThinking);
|
|
3429
|
+
let activeTransport = createTransport(initial.region, initial.streamIdleTimeoutMs, initial.responseHeaderTimeoutMs, initial.preserveThinking);
|
|
2508
3430
|
let activeTransportConfig = {
|
|
2509
3431
|
region: initial.region,
|
|
2510
3432
|
streamIdleTimeoutMs: initial.streamIdleTimeoutMs,
|
|
3433
|
+
responseHeaderTimeoutMs: initial.responseHeaderTimeoutMs,
|
|
2511
3434
|
preserveThinking: initial.preserveThinking
|
|
2512
3435
|
};
|
|
2513
3436
|
const adapter = new QoderAdapter({
|
|
2514
3437
|
resolveTransport: () => activeTransport,
|
|
2515
3438
|
models: initial.models,
|
|
2516
3439
|
providerId: providerQoder,
|
|
2517
|
-
providerName: "Qoder"
|
|
3440
|
+
providerName: "Qoder",
|
|
3441
|
+
onModelsDiscovered: (transport, models) => {
|
|
3442
|
+
if (transport !== activeTransport || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
|
|
3443
|
+
const region = activeTransportConfig.region;
|
|
3444
|
+
discoveredCatalogs[region] = models;
|
|
3445
|
+
refreshAdapter();
|
|
3446
|
+
persistDiscoveredModels(region).catch((error) => {
|
|
3447
|
+
logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error));
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
2518
3450
|
});
|
|
2519
3451
|
const registration = ctx.llm.registerAdapter([providerQoder], adapter);
|
|
2520
3452
|
const refreshAdapter = () => {
|
|
2521
3453
|
const next = resolveConfig();
|
|
2522
|
-
if (next.region !== activeTransportConfig.region || next.streamIdleTimeoutMs !== activeTransportConfig.streamIdleTimeoutMs || next.preserveThinking !== activeTransportConfig.preserveThinking) {
|
|
2523
|
-
activeTransport = createTransport(next.region, next.streamIdleTimeoutMs, next.preserveThinking);
|
|
3454
|
+
if (next.region !== activeTransportConfig.region || next.streamIdleTimeoutMs !== activeTransportConfig.streamIdleTimeoutMs || next.responseHeaderTimeoutMs !== activeTransportConfig.responseHeaderTimeoutMs || next.preserveThinking !== activeTransportConfig.preserveThinking) {
|
|
3455
|
+
activeTransport = createTransport(next.region, next.streamIdleTimeoutMs, next.responseHeaderTimeoutMs, next.preserveThinking);
|
|
2524
3456
|
activeTransportConfig = {
|
|
2525
3457
|
region: next.region,
|
|
2526
3458
|
streamIdleTimeoutMs: next.streamIdleTimeoutMs,
|
|
3459
|
+
responseHeaderTimeoutMs: next.responseHeaderTimeoutMs,
|
|
2527
3460
|
preserveThinking: next.preserveThinking
|
|
2528
3461
|
};
|
|
2529
3462
|
}
|
|
@@ -2531,7 +3464,7 @@ function apply(ctx, config = {}) {
|
|
|
2531
3464
|
registration.replace([providerQoder]);
|
|
2532
3465
|
};
|
|
2533
3466
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
2534
|
-
const scope =
|
|
3467
|
+
const { scope, namespace } = bindQoderSettings(ctx, settingsCtx, input, {
|
|
2535
3468
|
base: baseConfig,
|
|
2536
3469
|
validate: (value) => {
|
|
2537
3470
|
for (const region of Object.keys(value.modelsByRegion ?? {})) if (region !== "global" && region !== "china") throw new Error(`provider-qoder: unsupported model catalog region "${region}"`);
|
|
@@ -2541,34 +3474,39 @@ function apply(ctx, config = {}) {
|
|
|
2541
3474
|
});
|
|
2542
3475
|
legacyModelsRegion = scope.get().region ?? initialRegion;
|
|
2543
3476
|
current = () => scope.get();
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
3477
|
+
let bindingActive = true;
|
|
3478
|
+
const persistCatalog = async (region) => {
|
|
3479
|
+
while (bindingActive && ctx.fiber.state !== fiberUnloading && ctx.fiber.state !== fiberDisposed) {
|
|
3480
|
+
if (!settingsCtx.settings.writable || discoveredCatalogs[region].length === 0) return;
|
|
3481
|
+
const snapshot = settingsCtx.settings.describe().find((section) => section.ns === namespace);
|
|
3482
|
+
if (snapshot === void 0) return;
|
|
3483
|
+
const selected = modelsFor(snapshot.value, region, legacyModelsRegion);
|
|
3484
|
+
const enriched = modelsFor(Config({ modelsByRegion: { [region]: mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]) } }), region);
|
|
3485
|
+
if (hasSameQoderDiscoveryMetadata(selected, enriched)) return;
|
|
3486
|
+
try {
|
|
3487
|
+
await settingsCtx.settings.update(namespace, { modelsByRegion: { [region]: enriched } }, snapshot.revision);
|
|
3488
|
+
return;
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
if (!(error instanceof SettingsConflictError)) throw error;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
2552
3493
|
};
|
|
3494
|
+
persistDiscoveredModels = persistCatalog;
|
|
2553
3495
|
refreshAdapter();
|
|
2554
3496
|
const loaded = scope.get();
|
|
2555
3497
|
const loadedRegion = loaded.region ?? "global";
|
|
2556
|
-
if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: {
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
} }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
|
|
2560
|
-
scope.watch(async (next) => {
|
|
2561
|
-
if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
|
|
3498
|
+
if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: { [loadedRegion]: resolveModels(loaded.models) } }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
|
|
3499
|
+
scope.watch(async () => {
|
|
3500
|
+
if (!bindingActive || ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
|
|
2562
3501
|
refreshAdapter();
|
|
2563
|
-
|
|
2564
|
-
const selected = modelsFor(next, region, legacyModelsRegion);
|
|
2565
|
-
const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
|
|
2566
|
-
if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
|
|
2567
|
-
...next.modelsByRegion,
|
|
2568
|
-
[region]: enriched
|
|
2569
|
-
} });
|
|
3502
|
+
await persistCatalog(scope.get().region ?? "global");
|
|
2570
3503
|
});
|
|
3504
|
+
for (const region of ["global", "china"]) {
|
|
3505
|
+
if (discoveredCatalogs[region].length === 0) continue;
|
|
3506
|
+
persistCatalog(region).catch((error) => logger?.error?.("[Qoder Settings] Failed to synchronize model catalog", logError(error)));
|
|
3507
|
+
}
|
|
2571
3508
|
settingsCtx.effect(() => () => {
|
|
3509
|
+
bindingActive = false;
|
|
2572
3510
|
if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
|
|
2573
3511
|
persistDiscoveredModels = async () => {};
|
|
2574
3512
|
current = () => baseConfig;
|
|
@@ -2577,8 +3515,10 @@ function apply(ctx, config = {}) {
|
|
|
2577
3515
|
});
|
|
2578
3516
|
const discoverModels = async (signal, suppliedPat) => {
|
|
2579
3517
|
const snapshot = resolveConfig();
|
|
3518
|
+
const snapshotTransport = activeTransport;
|
|
2580
3519
|
const normalizedPat = suppliedPat?.trim();
|
|
2581
|
-
const models = await (normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) :
|
|
3520
|
+
const models = await (normalizedPat ? createTransport(snapshot.region, snapshot.streamIdleTimeoutMs, snapshot.responseHeaderTimeoutMs, snapshot.preserveThinking, () => Promise.resolve(normalizedPat)) : snapshotTransport).discoverModels(signal);
|
|
3521
|
+
adapter.updateDiscoveredModels(snapshotTransport, models);
|
|
2582
3522
|
discoveredCatalogs[snapshot.region] = models;
|
|
2583
3523
|
refreshAdapter();
|
|
2584
3524
|
await persistDiscoveredModels(snapshot.region);
|
|
@@ -2593,40 +3533,69 @@ function apply(ctx, config = {}) {
|
|
|
2593
3533
|
maxTokens: model.maxTokens
|
|
2594
3534
|
}));
|
|
2595
3535
|
});
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
if (endpoint === "models") try {
|
|
2601
|
-
return {
|
|
2602
|
-
ok: true,
|
|
2603
|
-
value: await discoverModels(signal)
|
|
2604
|
-
};
|
|
2605
|
-
} catch (error) {
|
|
2606
|
-
if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED") throw error;
|
|
2607
|
-
logger?.error?.("[Qoder RPC] Failed to discover models", logError(error));
|
|
2608
|
-
return publicError(error instanceof Error ? error.message : "Failed to discover Qoder models");
|
|
2609
|
-
}
|
|
2610
|
-
const force = typeof payload === "object" && payload !== null && "force" in payload ? payload.force === true : false;
|
|
2611
|
-
logger?.debug?.("[Qoder RPC] Reading subscriber account", { force });
|
|
3536
|
+
const handler = async (endpoint, payload, signal) => {
|
|
3537
|
+
if (!isQoderRpcEndpoint(endpoint)) return publicError("UNKNOWN_ENDPOINT", `Unknown endpoint: ${endpoint}`);
|
|
3538
|
+
if (signal.aborted) return publicError("ABORTED", "Request aborted");
|
|
3539
|
+
const executeRpc = async (operation, task) => {
|
|
2612
3540
|
try {
|
|
2613
|
-
const account = await activeTransport.readAccount({
|
|
2614
|
-
force,
|
|
2615
|
-
signal
|
|
2616
|
-
});
|
|
2617
|
-
logger?.debug?.("[Qoder RPC] Subscriber account resolved");
|
|
2618
3541
|
return {
|
|
2619
3542
|
ok: true,
|
|
2620
|
-
value:
|
|
3543
|
+
value: await task()
|
|
2621
3544
|
};
|
|
2622
3545
|
} catch (error) {
|
|
2623
|
-
if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED")
|
|
2624
|
-
|
|
2625
|
-
|
|
3546
|
+
if (signal.aborted || error instanceof QoderLlmError && error.code === "ABORTED") {
|
|
3547
|
+
logger?.debug?.(`[Qoder RPC] ${operation} was aborted`);
|
|
3548
|
+
return publicError("ABORTED", "Request aborted");
|
|
3549
|
+
}
|
|
3550
|
+
let code = "INTERNAL";
|
|
3551
|
+
if (error instanceof QoderLlmError) if (error.code === "MISSING_CREDENTIAL" || error.code === "NO_CREDENTIALS") code = "NO_CREDENTIALS";
|
|
3552
|
+
else if (error.code === "AUTH") code = "UNAUTHENTICATED";
|
|
3553
|
+
else if (error.code === "TIMEOUT") code = "TIMEOUT";
|
|
3554
|
+
else code = "UPSTREAM_ERROR";
|
|
3555
|
+
logger?.error?.(`[Qoder RPC] Failed to ${operation}`, logError(error));
|
|
3556
|
+
return publicError(code, error instanceof Error ? error.message : `Failed to ${operation}`);
|
|
2626
3557
|
}
|
|
2627
3558
|
};
|
|
2628
|
-
|
|
3559
|
+
if (endpoint === "models") return await executeRpc("discover Qoder models", () => discoverModels(signal));
|
|
3560
|
+
const force = typeof payload === "object" && payload !== null && "force" in payload ? payload.force === true : false;
|
|
3561
|
+
logger?.debug?.("[Qoder RPC] Reading subscriber account", { force });
|
|
3562
|
+
const outcome = await executeRpc("load Qoder account", () => activeTransport.readAccount({
|
|
3563
|
+
force,
|
|
3564
|
+
signal
|
|
3565
|
+
}));
|
|
3566
|
+
if (outcome.ok) logger?.debug?.("[Qoder RPC] Subscriber account resolved");
|
|
3567
|
+
return outcome;
|
|
3568
|
+
};
|
|
3569
|
+
try {
|
|
3570
|
+
ctx.effect(() => registerQoderRpc(ctx, handler), "provider-qoder: settings RPC routes");
|
|
3571
|
+
} catch (error) {
|
|
3572
|
+
logger?.error?.("[Qoder RPC] Failed to register the settings RPC routes", logError(error));
|
|
2629
3573
|
}
|
|
3574
|
+
ctx.inject(["web"], (webCtx) => {
|
|
3575
|
+
const searchProvider = new QoderSearchProvider({
|
|
3576
|
+
ctx,
|
|
3577
|
+
resolveTransport: () => activeTransport,
|
|
3578
|
+
getWebSearchMode: () => resolveConfig().webSearchMode ?? "auto"
|
|
3579
|
+
});
|
|
3580
|
+
webCtx.web.registerSearchProvider(searchProvider);
|
|
3581
|
+
if (typeof webCtx.web.search === "function") {
|
|
3582
|
+
const originalSearch = webCtx.web.search.bind(webCtx.web);
|
|
3583
|
+
webCtx.effect(() => {
|
|
3584
|
+
webCtx.web.search = async (request, signal) => {
|
|
3585
|
+
const mode = resolveConfig().webSearchMode ?? "auto";
|
|
3586
|
+
if (mode === "disabled") return originalSearch(request, signal);
|
|
3587
|
+
const agent = (ctx.get("agents") ?? ctx.agents)?.currentInitiator?.();
|
|
3588
|
+
const defaultModelService = ctx.get("agentDefaultModel");
|
|
3589
|
+
const isQoderActive = (agent?.options?.provider ?? defaultModelService?.get?.()?.provider) === providerQoder;
|
|
3590
|
+
if (mode === "always" || isQoderActive) return searchProvider.search(request, signal);
|
|
3591
|
+
return originalSearch(request, signal);
|
|
3592
|
+
};
|
|
3593
|
+
return () => {
|
|
3594
|
+
webCtx.web.search = originalSearch;
|
|
3595
|
+
};
|
|
3596
|
+
}, "provider-qoder: transparent web search router");
|
|
3597
|
+
}
|
|
3598
|
+
});
|
|
2630
3599
|
}
|
|
2631
3600
|
//#endregion
|
|
2632
|
-
export { Config, apply, inject, name };
|
|
3601
|
+
export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
|