dsh-provider-qoder 0.3.0 → 0.3.2
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 +21 -54
- package/README_CN.md +23 -54
- package/cordis.patch.yml +1 -1
- package/lib/client.js +18 -10
- package/lib/index.d.ts +5 -2
- package/lib/index.js +524 -95
- package/package.json +21 -16
package/README.md
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-provider-qoder
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-provider-qoder)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://awesome-dsh-plugin.com)
|
|
2
6
|
|
|
3
7
|
English | [简体中文](./README_CN.md)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
Use your Qoder subscription in DeepSeek Harness (DSH), supporting multimodality, web search, and tool calls across Global and China Qoder services.
|
|
6
10
|
|
|
7
11
|
The plugin handles authentication and model communication, while tool execution, workspace operations, and permission management are handled by DSH.
|
|
8
|
-
This project is a community adapter plugin.
|
|
12
|
+
This project is a community adapter plugin and is not affiliated with Qoder or DeepSeek.
|
|
9
13
|
|
|
10
14
|
## Features
|
|
11
15
|
|
|
12
16
|
- Configure subscription access via Qoder Personal Access Token (PAT).
|
|
13
17
|
- Supports both Global (`global`) and China (`china`) services.
|
|
14
18
|
- Discovers available models for your account, allows selecting enabled models, and displays pricing multipliers and reasoning effort options when reported by the service.
|
|
15
|
-
- Supports
|
|
19
|
+
- Supports multimodality, search, and tool calls.
|
|
16
20
|
- View account information, quota, and reset dates in Settings.
|
|
17
21
|
|
|
18
22
|
## Installation
|
|
@@ -53,62 +57,25 @@ Select the region matching your account, enter your Qoder PAT into the input box
|
|
|
53
57
|
|
|
54
58
|
Fetching models uses the **saved PAT and service region**. If you switch accounts or regions, you need to fetch models again. The initial model catalog is only a candidate reference; actual available models are subject to account query results. Model multipliers, reasoning effort options, and image-input capability are provided by Qoder and may not be available for all models.
|
|
55
59
|
|
|
56
|
-
The model picker refreshes Qoder metadata for enabled models when opened, with a five-minute cache after a successful fetch.
|
|
57
|
-
|
|
58
|
-
### 3. View Account & Quota
|
|
59
|
-
|
|
60
|
-
Open **Settings → Qoder** to view account and quota details.
|
|
61
|
-
|
|
62
|
-
## FAQ
|
|
63
|
-
|
|
64
|
-
### The plugin fails to start, or credentials cannot be saved
|
|
65
|
-
|
|
66
|
-
Ensure you have restarted the profile where the plugin was installed, and that the profile provides the DSH managed credentials service. If prompted that the storage is not writable, a writable credential store needs to be configured on the host side. The plugin does not read PATs from environment variables.
|
|
67
|
-
|
|
68
|
-
### PAT is saved, but model fetching or requests fail
|
|
69
|
-
|
|
70
|
-
Verify that the PAT is valid and matches the saved service region. If modified, save first before fetching models. The save action itself does not validate the token. If your account quota is exhausted, you will need to resolve your subscription quota first.
|
|
71
|
-
|
|
72
|
-
### Models remain unavailable after switching regions
|
|
73
|
-
|
|
74
|
-
Global and China model catalogs are stored separately. Switching regions does not automatically update your PAT or refetch that region's models; after saving the matching PAT, fetch and save the models for the selected region.
|
|
75
|
-
|
|
76
|
-
### Are requests automatically retried?
|
|
77
|
-
|
|
78
|
-
Model-generation retries are managed by DSH. To enable them, the running profile should include `@deepseek-ai/dsh-llm-retry`. The plugin reports empty responses, rate limits, server errors, timeouts, and transport errors using DSH's default retry policy; authentication, invalid requests, cancellations, quota issues, and protocol formatting errors are not retried by default. The Qoder transport may independently retry an idempotent model-catalog, subscriber-profile, or quota read once, but never retries a model-generation POST internally.
|
|
79
|
-
|
|
80
|
-
## Install from Source & Development
|
|
60
|
+
The model picker refreshes Qoder metadata for enabled models when opened, with a five-minute cache after a successful fetch. When settings are writable, refreshed multipliers and capabilities are also synchronized to the saved catalog shown in **Custom Settings**, without enabling additional models. Failed refreshes retain the last known metadata.
|
|
81
61
|
|
|
82
|
-
|
|
62
|
+
<p align="center">
|
|
63
|
+
<img src="./assets/credentials-settings.png" alt="Qoder credentials and model configuration" width="680" />
|
|
64
|
+
</p>
|
|
83
65
|
|
|
84
|
-
|
|
85
|
-
git clone https://github.com/mo-n/dsh-provider-qoder.git
|
|
86
|
-
cd dsh-provider-qoder
|
|
87
|
-
pnpm install --frozen-lockfile
|
|
88
|
-
pnpm run build
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Install the built repository directory into your DSH profile. The path below is a placeholder example; replace it with the actual absolute path:
|
|
92
|
-
|
|
93
|
-
```sh
|
|
94
|
-
dsh plugin --profile web add /absolute/path/to/dsh-provider-qoder
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
After restarting the profile, follow the configuration steps above. If running the CLI within a DSH source workspace, you can use `pnpm dsh` from that workspace.
|
|
98
|
-
|
|
99
|
-
For development, run watch mode in the plugin directory:
|
|
66
|
+
### 3. View Account & Quota
|
|
100
67
|
|
|
101
|
-
|
|
102
|
-
pnpm run dev
|
|
103
|
-
```
|
|
68
|
+
Open **Settings → Qoder** to view account and quota details, and configure the web search policy as needed:
|
|
104
69
|
|
|
105
|
-
|
|
70
|
+
<p align="center">
|
|
71
|
+
<img src="./assets/account-quota.png" alt="Qoder account quota and search settings" width="650" />
|
|
72
|
+
</p>
|
|
106
73
|
|
|
107
|
-
|
|
108
|
-
pnpm run check
|
|
109
|
-
```
|
|
74
|
+
## Upcoming Features
|
|
110
75
|
|
|
111
|
-
|
|
76
|
+
- [ ] Context window tier switching
|
|
77
|
+
- [ ] Request rate-limiting queue mechanism
|
|
78
|
+
- [ ] Connect with Qoder's Protobuf protocol
|
|
112
79
|
|
|
113
80
|
## References
|
|
114
81
|
|
package/README_CN.md
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-provider-qoder
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-provider-qoder)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/mo-n/dsh-provider-qoder)
|
|
6
|
+
[](https://awesome-dsh-plugin.com)
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
[English](./README.md) | 简体中文
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
在 DeepSeek Harness(DSH) 中使用 Qoder 订阅,支持多模态、联网搜索和工具调用,可选择国际版或中国区 Qoder 服务。
|
|
6
12
|
|
|
7
13
|
插件负责认证和模型通信,工具执行、工作区操作及权限管理由 DSH 负责。
|
|
8
|
-
|
|
14
|
+
本项目是社区适配插件,与 Qoder、DeepSeek 官方均无关联。
|
|
9
15
|
|
|
10
16
|
## 功能
|
|
11
17
|
|
|
12
18
|
- 通过 Qoder Personal Access Token(PAT,个人访问令牌)配置订阅访问。
|
|
13
19
|
- 支持国际版(`global`)和中国区(`china`)服务。
|
|
14
20
|
- 获取账号可用模型,选择启用的模型,并在服务返回相关信息时显示价格倍率和推理强度选项。
|
|
15
|
-
-
|
|
21
|
+
- 支持多模态、搜索以及工具调用。
|
|
16
22
|
- 在设置中查看账号信息、额度和重置时间。
|
|
17
23
|
|
|
18
24
|
## 安装
|
|
@@ -53,62 +59,25 @@ dsh plugin --profile web add dsh-provider-qoder
|
|
|
53
59
|
|
|
54
60
|
获取模型使用的是**已保存的 PAT 和服务区域**。更换账号或区域后,需要重新获取模型。初始模型目录仅供候选参考,实际可用模型以账号查询结果为准。模型倍率、推理强度选项及图片输入能力由 Qoder 提供,并非所有模型都有这些信息。
|
|
55
61
|
|
|
56
|
-
展开模型选择器时会自动刷新已启用 Qoder 模型的元数据,成功获取后缓存 5
|
|
57
|
-
|
|
58
|
-
### 3. 查看账号与额度
|
|
59
|
-
|
|
60
|
-
打开 **设置 → Qoder** 查看账号和额度。
|
|
61
|
-
|
|
62
|
-
## 常见问题
|
|
63
|
-
|
|
64
|
-
### 插件无法启动,或凭据无法保存
|
|
65
|
-
|
|
66
|
-
确认已重启安装插件的 profile,且该 profile 提供 DSH 托管凭据服务。如果提示存储不可写,需要在宿主侧配置可写的凭据存储。插件不会从环境变量读取 PAT。
|
|
67
|
-
|
|
68
|
-
### 已保存 PAT,但获取模型或请求失败
|
|
69
|
-
|
|
70
|
-
确认 PAT 有效且与已保存的服务区域匹配。修改后先保存,再获取模型。保存操作本身不会验证令牌。若账号额度已用尽,需先处理订阅额度问题。
|
|
71
|
-
|
|
72
|
-
### 切换区域后,模型仍不可用
|
|
73
|
-
|
|
74
|
-
Global 与 China 的模型目录会分别保存。切换区域不会自动替换 PAT,也不会自动重新获取该区域的模型;请先保存匹配区域的 PAT,再获取并保存所选区域的模型。
|
|
75
|
-
|
|
76
|
-
### 请求是否会自动重试?
|
|
77
|
-
|
|
78
|
-
模型生成重试由 DSH 管理。需要启用时,运行中的 profile 应包含 `@deepseek-ai/dsh-llm-retry`。插件使用 DSH 默认重试策略报告空响应、限流、服务端、超时和传输错误;认证、无效请求、取消、额度及协议格式错误默认不重试。Qoder transport 可以独立重试一次幂等的模型目录、订阅者资料或配额读取,但绝不会在内部重试模型生成 POST。
|
|
62
|
+
展开模型选择器时会自动刷新已启用 Qoder 模型的元数据,成功获取后缓存 5 分钟。设置可写时,更新后的倍率和能力信息也会同步到**自定义设置**显示的已保存目录,不会自动启用其他模型。刷新失败时保留最近一次成功获取的信息。
|
|
79
63
|
|
|
80
|
-
|
|
64
|
+
<p align="center">
|
|
65
|
+
<img src="./assets/credentials-settings.png" alt="Qoder 凭据与模型配置" width="680" />
|
|
66
|
+
</p>
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
```sh
|
|
85
|
-
git clone https://github.com/mo-n/dsh-provider-qoder.git
|
|
86
|
-
cd dsh-provider-qoder
|
|
87
|
-
pnpm install --frozen-lockfile
|
|
88
|
-
pnpm run build
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
将构建后的仓库目录安装到 DSH profile。以下路径为占位示例,请替换为实际绝对路径:
|
|
92
|
-
|
|
93
|
-
```sh
|
|
94
|
-
dsh plugin --profile web add /absolute/path/to/dsh-provider-qoder
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
重启 profile 后,按前文完成配置。若在 DSH 源码工作区中运行 CLI,可使用该工作区的 `pnpm dsh` 命令。
|
|
68
|
+
### 3. 查看账号与额度
|
|
98
69
|
|
|
99
|
-
|
|
70
|
+
打开 **设置 → Qoder** 查看账号额度,并可按需切换联网搜索策略:
|
|
100
71
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
72
|
+
<p align="center">
|
|
73
|
+
<img src="./assets/account-quota.png" alt="Qoder 账号额度与搜索设置" width="650" />
|
|
74
|
+
</p>
|
|
104
75
|
|
|
105
|
-
提交修改前运行:
|
|
106
|
-
|
|
107
|
-
```sh
|
|
108
|
-
pnpm run check
|
|
109
|
-
```
|
|
110
76
|
|
|
111
|
-
|
|
77
|
+
## 后续计划功能
|
|
78
|
+
- [ ] 支持上下文窗口(Context Window)档位切换
|
|
79
|
+
- [ ] 请求限流排队机制
|
|
80
|
+
- [ ] 接入 Qoder 的 Protobuf 协议
|
|
112
81
|
|
|
113
82
|
## 参考项目
|
|
114
83
|
|
package/cordis.patch.yml
CHANGED
package/lib/client.js
CHANGED
|
@@ -1166,21 +1166,32 @@ window.__ModuleLoader__.load({
|
|
|
1166
1166
|
"slots",
|
|
1167
1167
|
"locale",
|
|
1168
1168
|
"remote",
|
|
1169
|
-
"remote.credentials"
|
|
1170
|
-
"settingsScope"
|
|
1169
|
+
"remote.credentials"
|
|
1171
1170
|
];
|
|
1172
1171
|
const fill = (text, values) => {
|
|
1173
1172
|
if (!values) return text;
|
|
1174
1173
|
return Object.entries(values).reduce((next, [key, value]) => next.replace(new RegExp(`\\{${key}\\}`, "g"), String(value)), text);
|
|
1175
1174
|
};
|
|
1176
1175
|
function apply(ctx) {
|
|
1176
|
+
const configForms = ctx.get?.("configForms");
|
|
1177
|
+
if (configForms) {
|
|
1178
|
+
mount(ctx, configForms.get(settingsNamespace));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const settingsScope = ctx.get?.("settingsScope");
|
|
1182
|
+
if (settingsScope) {
|
|
1183
|
+
mount(ctx, settingsScope.bind({ namespace: settingsNamespace }));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
console.warn("[provider-qoder] Neither configForms nor settingsScope available; settings panel will not mount");
|
|
1187
|
+
}
|
|
1188
|
+
function mount(ctx, modelScope) {
|
|
1177
1189
|
ctx.effect(() => ctx.locale.register(localeNamespace, {
|
|
1178
1190
|
zh,
|
|
1179
1191
|
en
|
|
1180
1192
|
}), "provider-qoder: credential copy");
|
|
1181
1193
|
const credentials = ctx.remote.credentials;
|
|
1182
1194
|
const rpc = createQoderRpcCaller();
|
|
1183
|
-
const modelScope = ctx.settingsScope.bind({ namespace: settingsNamespace });
|
|
1184
1195
|
const operations = {
|
|
1185
1196
|
describe: async () => {
|
|
1186
1197
|
try {
|
|
@@ -1210,27 +1221,24 @@ window.__ModuleLoader__.load({
|
|
|
1210
1221
|
storeModels: async (region, models) => {
|
|
1211
1222
|
try {
|
|
1212
1223
|
const current = modelScope.getSnapshot().value;
|
|
1213
|
-
await modelScope.set("modelsByRegion", {
|
|
1224
|
+
return await modelScope.set("modelsByRegion", {
|
|
1214
1225
|
...current?.modelsByRegion,
|
|
1215
1226
|
[region]: models
|
|
1216
|
-
});
|
|
1217
|
-
return true;
|
|
1227
|
+
}) !== false;
|
|
1218
1228
|
} catch {
|
|
1219
1229
|
return false;
|
|
1220
1230
|
}
|
|
1221
1231
|
},
|
|
1222
1232
|
storeRegion: async (region) => {
|
|
1223
1233
|
try {
|
|
1224
|
-
await modelScope.set("region", region);
|
|
1225
|
-
return true;
|
|
1234
|
+
return await modelScope.set("region", region) !== false;
|
|
1226
1235
|
} catch {
|
|
1227
1236
|
return false;
|
|
1228
1237
|
}
|
|
1229
1238
|
},
|
|
1230
1239
|
storeWebSearchMode: async (mode) => {
|
|
1231
1240
|
try {
|
|
1232
|
-
await modelScope.set("webSearchMode", mode);
|
|
1233
|
-
return true;
|
|
1241
|
+
return await modelScope.set("webSearchMode", mode) !== false;
|
|
1234
1242
|
} catch {
|
|
1235
1243
|
return false;
|
|
1236
1244
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -51,11 +51,14 @@ interface Config {
|
|
|
51
51
|
webSearchMode?: QoderWebSearchMode;
|
|
52
52
|
}
|
|
53
53
|
declare const Config: z<Config>;
|
|
54
|
+
type PluginConfig = Config;
|
|
55
|
+
declare const PluginConfig: z<NoInfer<Config>, NoInfer<Config>, "volatile">;
|
|
56
|
+
type LiveConfig = ReturnType<typeof PluginConfig>;
|
|
54
57
|
//#endregion
|
|
55
58
|
//#region src/dsh/plugin.d.ts
|
|
56
59
|
declare const name = "provider-qoder";
|
|
57
60
|
declare const inject: string[];
|
|
58
|
-
declare function apply(ctx: Context,
|
|
61
|
+
declare function apply(ctx: Context, input?: Config | LiveConfig): void;
|
|
59
62
|
//#endregion
|
|
60
63
|
//#region src/qoder/account.d.ts
|
|
61
64
|
/** Browser-safe Qoder subscriber account and quota types. */
|
|
@@ -225,4 +228,4 @@ declare class QoderSearchProvider implements WebSearchProvider {
|
|
|
225
228
|
private resolveFallback;
|
|
226
229
|
}
|
|
227
230
|
//#endregion
|
|
228
|
-
export { Config, QODER_SEARCH_PROVIDER_ID, type QoderAccountInfo, type QoderCatalogModel, type QoderModelsByRegion, type QoderRegion, QoderSearchClient, type QoderSearchClientOptions, QoderSearchProvider, type QoderSearchProviderOptions, type QoderWebSearchMode, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
|
|
231
|
+
export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, type QoderAccountInfo, type QoderCatalogModel, type QoderModelsByRegion, type QoderRegion, QoderSearchClient, type QoderSearchClientOptions, QoderSearchProvider, type QoderSearchProviderOptions, type QoderWebSearchMode, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
|
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
8
|
+
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
8
9
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
9
10
|
//#region src/qoder/catalog.ts
|
|
10
11
|
const discoveredMetadataKeys = [
|
|
@@ -261,7 +262,8 @@ function qoderHttpError(message, response) {
|
|
|
261
262
|
return new QoderLlmError(message, code, {
|
|
262
263
|
status,
|
|
263
264
|
...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
|
|
264
|
-
...requestId === void 0 ? {} : { requestId }
|
|
265
|
+
...requestId === void 0 ? {} : { requestId },
|
|
266
|
+
...response.cause === void 0 ? {} : { cause: response.cause }
|
|
265
267
|
});
|
|
266
268
|
}
|
|
267
269
|
function qoderRequestId(headers) {
|
|
@@ -383,7 +385,7 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
|
383
385
|
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
384
386
|
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
385
387
|
-----END PUBLIC KEY-----`;
|
|
386
|
-
const qoderIdeVersion = "1.1.
|
|
388
|
+
const qoderIdeVersion = "1.1.60";
|
|
387
389
|
const defaultUserAgent = `qoder/${qoderIdeVersion}`;
|
|
388
390
|
const qoderDataPolicy = "disagree";
|
|
389
391
|
const qoderLoginVersion = "v2";
|
|
@@ -809,35 +811,6 @@ function toolResultText(block) {
|
|
|
809
811
|
}
|
|
810
812
|
return text;
|
|
811
813
|
}
|
|
812
|
-
/**
|
|
813
|
-
* Check message shapes without performing any provider I/O.
|
|
814
|
-
*
|
|
815
|
-
* Callers run this before resolving credentials so an invalid request never
|
|
816
|
-
* consumes a Qoder subscription.
|
|
817
|
-
*/
|
|
818
|
-
function validateMessageShapes(messages) {
|
|
819
|
-
for (const message of messages) {
|
|
820
|
-
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
821
|
-
if (toolResults.length > 0) {
|
|
822
|
-
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.");
|
|
823
|
-
for (const result of toolResults) toolResultText(result);
|
|
824
|
-
continue;
|
|
825
|
-
}
|
|
826
|
-
for (const block of message.content) {
|
|
827
|
-
if (block.type === "text") continue;
|
|
828
|
-
if (block.type === "image") {
|
|
829
|
-
if (message.role !== "user") throw unsupported("Qoder image content is valid only in user messages.");
|
|
830
|
-
continue;
|
|
831
|
-
}
|
|
832
|
-
if (block.type === "tool-call") {
|
|
833
|
-
if (message.role !== "assistant") throw unsupported("Qoder tool calls are valid only in assistant messages.");
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
if (block.type === "reasoning") continue;
|
|
837
|
-
throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
814
|
/** Reject a batch that exceeds the deployment image policy before any upload work starts. */
|
|
842
815
|
function enforceImageLimits(images, attachments) {
|
|
843
816
|
const limits = attachments.imageLimits;
|
|
@@ -852,10 +825,15 @@ async function resolveImagePart(block, context) {
|
|
|
852
825
|
let image;
|
|
853
826
|
try {
|
|
854
827
|
const limits = attachments.imageLimits;
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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);
|
|
859
837
|
} catch (error) {
|
|
860
838
|
if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
|
|
861
839
|
if (error instanceof QoderLlmError) throw error;
|
|
@@ -880,8 +858,29 @@ function translateTools(tools) {
|
|
|
880
858
|
}
|
|
881
859
|
}));
|
|
882
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
|
+
}
|
|
883
883
|
async function validateAndTranslateMessages(messages, systemPrompt, attachments, signal, pipeline) {
|
|
884
|
-
validateMessageShapes(messages);
|
|
885
884
|
const context = {
|
|
886
885
|
attachments,
|
|
887
886
|
signal,
|
|
@@ -890,18 +889,20 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
890
889
|
};
|
|
891
890
|
const preserveThinking = pipeline?.preserveThinking ?? true;
|
|
892
891
|
const output = [];
|
|
892
|
+
let lastAssistantToolCallIds = [];
|
|
893
893
|
if (typeof systemPrompt === "string" && systemPrompt.trim().length > 0) output.push({
|
|
894
894
|
role: "system",
|
|
895
895
|
content: systemPrompt
|
|
896
896
|
});
|
|
897
897
|
for (const message of messages) {
|
|
898
898
|
const toolResults = message.content.filter((block) => block.type === "tool-result");
|
|
899
|
+
const nonToolBlocks = message.content.filter((block) => block.type !== "tool-result");
|
|
899
900
|
if (toolResults.length > 0) {
|
|
900
|
-
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.");
|
|
901
901
|
for (const result of toolResults) {
|
|
902
|
+
const callId = consumeToolCallId(message, result, lastAssistantToolCallIds, output.length);
|
|
902
903
|
output.push({
|
|
903
904
|
role: "tool",
|
|
904
|
-
tool_call_id:
|
|
905
|
+
tool_call_id: callId,
|
|
905
906
|
content: toolResultText(result)
|
|
906
907
|
});
|
|
907
908
|
const images = result.content.filter((block) => block.type === "image");
|
|
@@ -916,24 +917,27 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
916
917
|
});
|
|
917
918
|
}
|
|
918
919
|
}
|
|
919
|
-
continue;
|
|
920
|
+
if (nonToolBlocks.length === 0) continue;
|
|
920
921
|
}
|
|
922
|
+
const contentBlocks = toolResults.length > 0 ? nonToolBlocks : message.content;
|
|
921
923
|
let text = "";
|
|
922
924
|
let reasoningText = "";
|
|
923
925
|
const userContent = [];
|
|
924
926
|
const pendingImages = [];
|
|
925
927
|
let hasImage = false;
|
|
926
928
|
const toolCalls = [];
|
|
927
|
-
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.`);
|
|
928
931
|
if (block.type === "text") {
|
|
929
932
|
text += block.text;
|
|
930
|
-
if (message.role === "user") userContent.push({
|
|
933
|
+
if (message.role === "user" || toolResults.length > 0) userContent.push({
|
|
931
934
|
type: "text",
|
|
932
935
|
text: block.text
|
|
933
936
|
});
|
|
934
937
|
continue;
|
|
935
938
|
}
|
|
936
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.");
|
|
937
941
|
hasImage = true;
|
|
938
942
|
pendingImages.push({
|
|
939
943
|
slot: userContent.length,
|
|
@@ -961,6 +965,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
961
965
|
if (message.role === "assistant") {
|
|
962
966
|
const hasReasoning = preserveThinking && reasoningText.length > 0;
|
|
963
967
|
if (!text && toolCalls.length === 0 && !hasReasoning) continue;
|
|
968
|
+
if (toolCalls.length > 0) lastAssistantToolCallIds = toolCalls.map((tc) => tc.id);
|
|
964
969
|
output.push({
|
|
965
970
|
role: "assistant",
|
|
966
971
|
content: text || " ",
|
|
@@ -969,20 +974,317 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
|
|
|
969
974
|
});
|
|
970
975
|
continue;
|
|
971
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
|
+
}
|
|
972
996
|
if (pendingImages.length > 0) {
|
|
973
997
|
if (attachments !== void 0) enforceImageLimits(pendingImages.map((pending) => pending.block), attachments);
|
|
974
998
|
await Promise.all(pendingImages.map(async (pending) => {
|
|
975
999
|
userContent[pending.slot] = await resolveImagePart(pending.block, context);
|
|
976
1000
|
}));
|
|
977
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;
|
|
978
1005
|
output.push({
|
|
979
|
-
role:
|
|
980
|
-
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 } : {}
|
|
981
1009
|
});
|
|
982
1010
|
}
|
|
983
1011
|
return output;
|
|
984
1012
|
}
|
|
985
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
|
|
986
1288
|
//#region src/qoder/transport/wire/serialize.ts
|
|
987
1289
|
/** Build the minimum qodercli request envelope for a validated DSH request. */
|
|
988
1290
|
function stableHash(prefix, ...inputs) {
|
|
@@ -994,18 +1296,25 @@ function stableHash(prefix, ...inputs) {
|
|
|
994
1296
|
}
|
|
995
1297
|
return hash.digest("hex").slice(0, 16);
|
|
996
1298
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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";
|
|
1009
1318
|
}
|
|
1010
1319
|
/**
|
|
1011
1320
|
* Reject an unusable request before any credential resolution or provider I/O.
|
|
@@ -1020,7 +1329,6 @@ function validateQoderRequestShape(options, model) {
|
|
|
1020
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");
|
|
1021
1330
|
}
|
|
1022
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");
|
|
1023
|
-
validateMessageShapes(options.messages);
|
|
1024
1332
|
}
|
|
1025
1333
|
/** Translate a request whose shape has already been validated. */
|
|
1026
1334
|
function translateQoderMessages(options, attachments, pipeline) {
|
|
@@ -1030,7 +1338,7 @@ async function validateQoderRequest(options, model, attachments) {
|
|
|
1030
1338
|
validateQoderRequestShape(options, model);
|
|
1031
1339
|
return validateAndTranslateMessages(options.messages, options.system, attachments, options.signal);
|
|
1032
1340
|
}
|
|
1033
|
-
async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments) {
|
|
1341
|
+
async function buildQoderRequestBody(options, userId, translatedMessages, model, attachments, tracker) {
|
|
1034
1342
|
if (!userId) throw new QoderLlmError("Qoder request identity is missing.", "AUTH");
|
|
1035
1343
|
const modelKey = options.model || "cmodel";
|
|
1036
1344
|
const messages = translatedMessages ?? await validateQoderRequest(options, model, attachments);
|
|
@@ -1045,17 +1353,18 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
|
|
|
1045
1353
|
}]));
|
|
1046
1354
|
let lastUserText = "";
|
|
1047
1355
|
for (let index = messages.length - 1; index >= 0; index--) if (messages[index].role === "user") {
|
|
1048
|
-
|
|
1049
|
-
lastUserText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
|
|
1356
|
+
lastUserText = wireMessageText(messages[index]);
|
|
1050
1357
|
break;
|
|
1051
1358
|
}
|
|
1052
|
-
const stablePart = stableHash("qoder-session", userId
|
|
1359
|
+
const stablePart = stableHash("qoder-session", userId);
|
|
1053
1360
|
const sessionId = options.sessionId === void 0 ? `${stablePart}-${crypto.randomUUID()}` : `${stablePart}-${String(options.sessionId)}`;
|
|
1054
|
-
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);
|
|
1055
1364
|
return {
|
|
1056
|
-
request_id:
|
|
1057
|
-
request_set_id:
|
|
1058
|
-
chat_record_id:
|
|
1365
|
+
request_id: requestId,
|
|
1366
|
+
request_set_id: identity.requestSetId,
|
|
1367
|
+
chat_record_id: identity.chatRecordId,
|
|
1059
1368
|
session_id: sessionId,
|
|
1060
1369
|
stream: true,
|
|
1061
1370
|
chat_task: "FREE_INPUT",
|
|
@@ -1100,12 +1409,12 @@ async function buildQoderRequestBody(options, userId, translatedMessages, model,
|
|
|
1100
1409
|
},
|
|
1101
1410
|
business: {
|
|
1102
1411
|
product: "cli",
|
|
1103
|
-
version: "1.0.
|
|
1412
|
+
version: "1.0.60",
|
|
1104
1413
|
type: "agent",
|
|
1105
1414
|
stage: "start",
|
|
1106
|
-
id:
|
|
1415
|
+
id: identity.businessId,
|
|
1107
1416
|
name: lastUserText.substring(0, 30),
|
|
1108
|
-
begin_at:
|
|
1417
|
+
begin_at: identity.beginAt
|
|
1109
1418
|
}
|
|
1110
1419
|
};
|
|
1111
1420
|
}
|
|
@@ -2079,6 +2388,30 @@ const doneMarker = "[DONE]";
|
|
|
2079
2388
|
function malformed(message) {
|
|
2080
2389
|
return new QoderLlmError(message, "MALFORMED_RESPONSE");
|
|
2081
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
|
+
}
|
|
2082
2415
|
function parseEnvelope(rawData) {
|
|
2083
2416
|
let value;
|
|
2084
2417
|
try {
|
|
@@ -2090,7 +2423,13 @@ function parseEnvelope(rawData) {
|
|
|
2090
2423
|
const envelope = value;
|
|
2091
2424
|
if (envelope.statusCodeValue !== void 0 && typeof envelope.statusCodeValue !== "number") throw malformed("Qoder SSE envelope has an invalid status code.");
|
|
2092
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.");
|
|
2093
|
-
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
|
+
}
|
|
2094
2433
|
if (envelope.body !== void 0 && typeof envelope.body !== "string") throw malformed("Qoder SSE envelope has an invalid model body.");
|
|
2095
2434
|
return envelope;
|
|
2096
2435
|
}
|
|
@@ -2241,7 +2580,8 @@ async function* parseQoderSse(stream, options = {}) {
|
|
|
2241
2580
|
sawDone = true;
|
|
2242
2581
|
break;
|
|
2243
2582
|
}
|
|
2244
|
-
const
|
|
2583
|
+
const envelope = parseEnvelope(rawData);
|
|
2584
|
+
const body = typeof envelope.body === "string" ? envelope.body.trim() : "";
|
|
2245
2585
|
if (!body) continue;
|
|
2246
2586
|
if (body === doneMarker) {
|
|
2247
2587
|
sawDone = true;
|
|
@@ -2435,7 +2775,28 @@ async function* streamQoderChat(options, model, credentials, messages, dependenc
|
|
|
2435
2775
|
...reqId === void 0 ? {} : { requestId: reqId }
|
|
2436
2776
|
});
|
|
2437
2777
|
resetIdleTimer();
|
|
2438
|
-
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
|
+
}
|
|
2439
2800
|
if (!response.body) throw new QoderLlmError("Qoder response contains no readable body stream.", "EMPTY_RESPONSE");
|
|
2440
2801
|
let firstChunkDurationMs;
|
|
2441
2802
|
let tokenUsage;
|
|
@@ -2677,6 +3038,10 @@ const Config = z.object({
|
|
|
2677
3038
|
"disabled"
|
|
2678
3039
|
]).default("auto")
|
|
2679
3040
|
});
|
|
3041
|
+
const PluginConfig = Config.volatile();
|
|
3042
|
+
function readConfig(value) {
|
|
3043
|
+
return "get" in value ? Config(structuredClone(value.get())) : value;
|
|
3044
|
+
}
|
|
2680
3045
|
function resolveModels(models) {
|
|
2681
3046
|
const resolved = models ?? defaultModels;
|
|
2682
3047
|
if (resolved.length === 0) throw new Error("provider-qoder: at least one model is required");
|
|
@@ -2715,6 +3080,7 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2715
3080
|
catalogModels;
|
|
2716
3081
|
providerId;
|
|
2717
3082
|
providerName;
|
|
3083
|
+
onModelsDiscovered;
|
|
2718
3084
|
discoveries = /* @__PURE__ */ new WeakMap();
|
|
2719
3085
|
constructor(options) {
|
|
2720
3086
|
super();
|
|
@@ -2722,6 +3088,7 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2722
3088
|
this.catalogModels = options.models && options.models.length > 0 ? options.models : defaultModels;
|
|
2723
3089
|
this.providerId = options.providerId ?? "dsh-provider-qoder";
|
|
2724
3090
|
this.providerName = options.providerName ?? "Qoder";
|
|
3091
|
+
this.onModelsDiscovered = options.onModelsDiscovered;
|
|
2725
3092
|
}
|
|
2726
3093
|
providerInfo(provider) {
|
|
2727
3094
|
return {
|
|
@@ -2739,8 +3106,10 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2739
3106
|
}
|
|
2740
3107
|
const entry = cached;
|
|
2741
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;
|
|
2742
3110
|
entry.models = models;
|
|
2743
3111
|
entry.expiresAt = Date.now() + 300 * 1e3;
|
|
3112
|
+
this.publishDiscoveredModels(transport, models);
|
|
2744
3113
|
}).catch(() => {}).finally(() => {
|
|
2745
3114
|
entry.inflight = void 0;
|
|
2746
3115
|
});
|
|
@@ -2748,6 +3117,18 @@ var QoderAdapter = class extends LlmAdapter {
|
|
|
2748
3117
|
if (this.resolveTransport() !== transport) return this.listModels(provider);
|
|
2749
3118
|
return this.effectiveModels().map((model) => modelInfo(provider, model));
|
|
2750
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
|
+
}
|
|
2751
3132
|
effectiveModels() {
|
|
2752
3133
|
return mergeQoderDiscoveryMetadata(this.catalogModels, this.discoveries.get(this.resolveTransport())?.models ?? []);
|
|
2753
3134
|
}
|
|
@@ -2861,6 +3242,39 @@ async function resolveManagedQoderPat(credentials) {
|
|
|
2861
3242
|
if (hit === void 0 || isEnvironmentCredentialSource(hit.source)) return "";
|
|
2862
3243
|
return hit.value.trim();
|
|
2863
3244
|
}
|
|
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
|
+
}
|
|
2864
3278
|
/**
|
|
2865
3279
|
* Absolute Fetch-route prefix. Connection mounts its shared, authenticated API
|
|
2866
3280
|
* channel at `/api`, and every exact Fetch route lives directly below it.
|
|
@@ -2969,7 +3383,8 @@ function publicError(code, message, details = { issues: [] }) {
|
|
|
2969
3383
|
}
|
|
2970
3384
|
};
|
|
2971
3385
|
}
|
|
2972
|
-
function apply(ctx,
|
|
3386
|
+
function apply(ctx, input = {}) {
|
|
3387
|
+
const config = readConfig(input);
|
|
2973
3388
|
const logger = ctx.logger;
|
|
2974
3389
|
const initialRegion = config.region ?? "global";
|
|
2975
3390
|
const hasConfiguredModels = config.models !== void 0 && config.models.length > 0;
|
|
@@ -3022,7 +3437,16 @@ function apply(ctx, config = {}) {
|
|
|
3022
3437
|
resolveTransport: () => activeTransport,
|
|
3023
3438
|
models: initial.models,
|
|
3024
3439
|
providerId: providerQoder,
|
|
3025
|
-
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
|
+
}
|
|
3026
3450
|
});
|
|
3027
3451
|
const registration = ctx.llm.registerAdapter([providerQoder], adapter);
|
|
3028
3452
|
const refreshAdapter = () => {
|
|
@@ -3040,7 +3464,7 @@ function apply(ctx, config = {}) {
|
|
|
3040
3464
|
registration.replace([providerQoder]);
|
|
3041
3465
|
};
|
|
3042
3466
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
3043
|
-
const scope =
|
|
3467
|
+
const { scope, namespace } = bindQoderSettings(ctx, settingsCtx, input, {
|
|
3044
3468
|
base: baseConfig,
|
|
3045
3469
|
validate: (value) => {
|
|
3046
3470
|
for (const region of Object.keys(value.modelsByRegion ?? {})) if (region !== "global" && region !== "china") throw new Error(`provider-qoder: unsupported model catalog region "${region}"`);
|
|
@@ -3050,34 +3474,39 @@ function apply(ctx, config = {}) {
|
|
|
3050
3474
|
});
|
|
3051
3475
|
legacyModelsRegion = scope.get().region ?? initialRegion;
|
|
3052
3476
|
current = () => scope.get();
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
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
|
+
}
|
|
3061
3493
|
};
|
|
3494
|
+
persistDiscoveredModels = persistCatalog;
|
|
3062
3495
|
refreshAdapter();
|
|
3063
3496
|
const loaded = scope.get();
|
|
3064
3497
|
const loadedRegion = loaded.region ?? "global";
|
|
3065
|
-
if (loaded.models !== void 0 && loaded.models.length > 0 && loaded.modelsByRegion?.[loadedRegion] === void 0) scope.update({ modelsByRegion: {
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
} }).catch((error) => logger?.error?.("[Qoder Settings] Failed to migrate model catalog", logError(error)));
|
|
3069
|
-
scope.watch(async (next) => {
|
|
3070
|
-
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;
|
|
3071
3501
|
refreshAdapter();
|
|
3072
|
-
|
|
3073
|
-
const selected = modelsFor(next, region, legacyModelsRegion);
|
|
3074
|
-
const enriched = mergeQoderDiscoveryMetadata(selected, discoveredCatalogs[region]);
|
|
3075
|
-
if (!hasSameQoderDiscoveryMetadata(selected, enriched)) await scope.update({ modelsByRegion: {
|
|
3076
|
-
...next.modelsByRegion,
|
|
3077
|
-
[region]: enriched
|
|
3078
|
-
} });
|
|
3502
|
+
await persistCatalog(scope.get().region ?? "global");
|
|
3079
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
|
+
}
|
|
3080
3508
|
settingsCtx.effect(() => () => {
|
|
3509
|
+
bindingActive = false;
|
|
3081
3510
|
if (ctx.fiber.state === fiberUnloading || ctx.fiber.state === fiberDisposed) return;
|
|
3082
3511
|
persistDiscoveredModels = async () => {};
|
|
3083
3512
|
current = () => baseConfig;
|
|
@@ -3169,4 +3598,4 @@ function apply(ctx, config = {}) {
|
|
|
3169
3598
|
});
|
|
3170
3599
|
}
|
|
3171
3600
|
//#endregion
|
|
3172
|
-
export { Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
|
|
3601
|
+
export { PluginConfig as Config, QODER_SEARCH_PROVIDER_ID, QoderSearchClient, QoderSearchProvider, apply, getQoderWebSearchUrl, inject, name, qoderWebSearchPath };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-provider-qoder",
|
|
3
3
|
"description": "Global Qoder subscription adapter for the DeepSeek Harness LLM seam",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -80,23 +80,28 @@
|
|
|
80
80
|
"react": "^18.2.0"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
83
|
+
"@deepseek-ai/schemastery": "3.18.3"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
-
"@deepseek-ai/cordis": "4.0.
|
|
87
|
-
"@deepseek-ai/
|
|
88
|
-
"@deepseek-ai/dsh-
|
|
89
|
-
"@deepseek-ai/dsh-
|
|
90
|
-
"@deepseek-ai/dsh-
|
|
91
|
-
"@deepseek-ai/dsh-
|
|
92
|
-
"@deepseek-ai/dsh-
|
|
93
|
-
"@deepseek-ai/dsh-client-
|
|
94
|
-
"@deepseek-ai/dsh-
|
|
95
|
-
"@deepseek-ai/dsh-
|
|
96
|
-
"@deepseek-ai/dsh-
|
|
97
|
-
"@deepseek-ai/dsh-
|
|
98
|
-
"@deepseek-ai/dsh-
|
|
99
|
-
"@deepseek-ai/
|
|
86
|
+
"@deepseek-ai/cordis": "4.0.3",
|
|
87
|
+
"@deepseek-ai/cordis-plugin-loader": "1.0.4",
|
|
88
|
+
"@deepseek-ai/dsh-agent": "0.1.7-alpha.1",
|
|
89
|
+
"@deepseek-ai/dsh-api-remotes": "0.1.7-alpha.1",
|
|
90
|
+
"@deepseek-ai/dsh-app-boot": "0.1.7-alpha.1",
|
|
91
|
+
"@deepseek-ai/dsh-attachment": "0.1.7-alpha.1",
|
|
92
|
+
"@deepseek-ai/dsh-brand": "0.1.7-alpha.1",
|
|
93
|
+
"@deepseek-ai/dsh-client-connection": "0.1.7-alpha.1",
|
|
94
|
+
"@deepseek-ai/dsh-client-locale": "0.1.7-alpha.1",
|
|
95
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.7-alpha.1",
|
|
96
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.7-alpha.1",
|
|
97
|
+
"@deepseek-ai/dsh-config-editor": "0.1.7-alpha.1",
|
|
98
|
+
"@deepseek-ai/dsh-credentials": "0.1.7-alpha.1",
|
|
99
|
+
"@deepseek-ai/dsh-invariants": "0.1.7-alpha.1",
|
|
100
|
+
"@deepseek-ai/dsh-llm": "0.1.7-alpha.1",
|
|
101
|
+
"@deepseek-ai/dsh-session": "0.1.7-alpha.1",
|
|
102
|
+
"@deepseek-ai/dsh-settings": "0.1.7-alpha.1",
|
|
103
|
+
"@deepseek-ai/dsh-settings-legacy": "npm:@deepseek-ai/dsh-settings@0.1.2-rc.1",
|
|
104
|
+
"@deepseek-ai/dsh-web": "0.1.7-alpha.1",
|
|
100
105
|
"@types/node": "24.3.0",
|
|
101
106
|
"@types/react": "18.3.1",
|
|
102
107
|
"lightningcss": "^1.33.0",
|