dsh-connect-qoder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/cordis.patch.yml +6 -0
- package/lib/adapter.js +445 -0
- package/lib/client.js +992 -0
- package/lib/credentials.js +324 -0
- package/lib/errors.js +37 -0
- package/lib/index.js +691 -0
- package/lib/shim.js +378 -0
- package/lib/upstream.js +1232 -0
- package/package.json +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# DSH Connect Qoder
|
|
2
|
+
|
|
3
|
+
把本机已登录的 **Qoder** 模型接入 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
|
|
4
|
+
零配置即可在 DSH 的模型选择器里使用你的 Qoder 账号额度。
|
|
5
|
+
|
|
6
|
+
国内版 **Qoder CN** 与国际版 **Qoder** 是两个并行的 provider(`qoder-cn` / `qoder`),
|
|
7
|
+
装哪个就出现哪一组模型,两个都装就两组并存,各自使用自己的账号与额度。
|
|
8
|
+
|
|
9
|
+
## 工作原理
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
DSH PiAiAdapter(每个区域一套)
|
|
13
|
+
-> 安全 loopback shim(随机端口 + 进程内随机 secret)
|
|
14
|
+
-> COSY 签名 + 自定义 base64 编码
|
|
15
|
+
-> 国内版 https://gateway.qoder.com.cn/
|
|
16
|
+
-> 国际版 https://api3.qoder.sh/
|
|
17
|
+
-> Qoder 双层包装 SSE
|
|
18
|
+
-> OpenAI SSE
|
|
19
|
+
-> DSH 本地执行工具并回传结果
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Qoder 不是 OpenAI 兼容端点,所以需要三样东西:
|
|
23
|
+
|
|
24
|
+
1. **COSY 签名头** —— 每个网关请求都带 `Cosy-*` 头与 `Authorization: Bearer COSY.<payload>.<sig>`,
|
|
25
|
+
签名是对 base64 负载、RSA 包装的 AES 密钥、时间戳、请求体与签名路径做 MD5。
|
|
26
|
+
2. **置换 base64 请求体** —— 查询串里的 `Encode=1` 表示 JSON 体要先 base64、再按换过的字母表
|
|
27
|
+
逐字符替换、最后按三分之一旋转。
|
|
28
|
+
3. **双层 SSE** —— 每个 `data:` 帧是一个信封对象,它的 `body` 字段本身又是 JSON 字符串,
|
|
29
|
+
里面才是 OpenAI 风格的 chunk。
|
|
30
|
+
|
|
31
|
+
## 凭据来源
|
|
32
|
+
|
|
33
|
+
插件复用 Qoder 桌面应用自己的登录状态,**不启动额外的 OAuth 流程,也不写入应用的文件**
|
|
34
|
+
(数据库以只读方式打开)。
|
|
35
|
+
|
|
36
|
+
Qoder 把登录信息放在 VS Code 风格的 `state.vscdb` 里,密文是 Chromium OSCrypt 格式
|
|
37
|
+
(`v10` + nonce + 密文 + tag,AES-256-GCM)。解密用的密钥保存在应用的 `Local State` 中,
|
|
38
|
+
由操作系统 keystore 包裹 —— Windows 上是当前用户作用域的 DPAPI,因此同一用户下的进程都能解开它。
|
|
39
|
+
Node 没有内置 DPAPI 绑定,这一步交给 PowerShell,并通过临时文件交换结果(不使用管道),
|
|
40
|
+
这样在禁止管道 stdio 的沙箱里同样可用。
|
|
41
|
+
|
|
42
|
+
没有桌面应用登录时,可以用官方文档的 **PAT** 兜底:设置 `QODERCN_PAT`(国内版)或
|
|
43
|
+
`QODER_PAT`(国际版),插件会用它换取 job token。
|
|
44
|
+
|
|
45
|
+
## 安装
|
|
46
|
+
|
|
47
|
+
### 从 DSH 市场安装(推荐给 DSH 桌面用户)
|
|
48
|
+
|
|
49
|
+
在 DSH 的「插件市场」里粘贴这个源:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
github:hdhgsysh/dsh-connect-qoder
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
或本地开发模式:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
dsh plugin --profile web add <本仓库路径>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
安装后需要**重启 DSH 进程**:bundle 的 patch 在启动时读取。
|
|
62
|
+
|
|
63
|
+
## 凭据安全
|
|
64
|
+
|
|
65
|
+
插件**不存储任何凭据**:
|
|
66
|
+
|
|
67
|
+
- 复用本机已登录的 Qoder 桌面应用的本地数据库(只读打开,临时副本在 `os.tmpdir()`),
|
|
68
|
+
关闭后立即删除。
|
|
69
|
+
- 进程内随机 bearer token 绑定 loopback 端口;Qoder 真实的 RSA 包装 key 与 session key
|
|
70
|
+
不会离开本插件的沙箱。
|
|
71
|
+
|
|
72
|
+
## 已知行为
|
|
73
|
+
|
|
74
|
+
- **始终思考的模型**:部分模型(如 `GLM-5.3-Flash`、`Kimi-K3`)声明了推理档位但不允许关闭思考,
|
|
75
|
+
对它们发送 `enable_thinking: false` 会被上游以 `provider_error 1210` 拒绝。插件从模型目录
|
|
76
|
+
识别这类模型并**完全省略**该字段,让模型使用自己的默认档位。
|
|
77
|
+
- **上游错误可见**:上游失败时返回的是普通 200 帧里的错误对象,而不是 chunk。插件把它翻译成
|
|
78
|
+
一条可读的错误,而不是让用户看到一个空的助手回合。
|
|
79
|
+
- **国际版额度**:国际版的试用额度可能已用尽(`isQuotaExceeded`),此时目录请求会返回
|
|
80
|
+
403 `Login expired`,该区域就不会显示模型;国内版不受影响。
|
|
81
|
+
- 依赖 Qoder 客户端接口(非官方开放 API),Qoder 更新后插件可能需要随之调整。
|
|
82
|
+
|
|
83
|
+
## 目录
|
|
84
|
+
|
|
85
|
+
| 文件 | 作用 |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `lib/credentials.js` | 从 Qoder 应用读取并解密登录凭据 |
|
|
88
|
+
| `lib/upstream.js` | COSY 签名、请求体编码、目录与对话流 |
|
|
89
|
+
| `lib/shim.js` | 面向 pi-ai 的 OpenAI 兼容回环端点 |
|
|
90
|
+
| `lib/adapter.js` | pi-ai provider 与 `PiAiAdapter` profile |
|
|
91
|
+
| `lib/index.js` | 按区域注册 provider 的插件入口 |
|
|
92
|
+
|
|
93
|
+
## 免责声明
|
|
94
|
+
|
|
95
|
+
仅供个人学习研究使用,仅驱动使用者自己的 Qoder 账号在本机调用。使用者需遵守 Qoder 的服务条款,
|
|
96
|
+
因使用本项目产生的后果由使用者自行承担。本项目与 Qoder、DeepSeek 均无关联。
|
|
97
|
+
|
|
98
|
+
## 许可证
|
|
99
|
+
|
|
100
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Qoder pi-ai adapter.
|
|
3
|
+
*
|
|
4
|
+
* One adapter instance serves both Qoder regions, because each region is just
|
|
5
|
+
* another profile in the same map — the same way a single `PiAiAdapter` can
|
|
6
|
+
* front several providers. Every model's `baseUrl` points at its region's
|
|
7
|
+
* loopback shim, so routing a request to a model is enough to select the
|
|
8
|
+
* region, the credential, and the signing path.
|
|
9
|
+
*
|
|
10
|
+
* The profile is assembled by hand rather than through `dsh-llm-pi-ai`'s
|
|
11
|
+
* internal resolver: that helper is not part of the package's public export
|
|
12
|
+
* surface, so every field it would have supplied has to be named here.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-connect-qoder/adapter
|
|
15
|
+
*/
|
|
16
|
+
import { createProvider } from '@earendil-works/pi-ai'
|
|
17
|
+
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
|
|
18
|
+
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
19
|
+
import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
|
|
21
|
+
/** Idle ceiling while one stream read is outstanding. */
|
|
22
|
+
const STREAM_IDLE_TIMEOUT_MS = 300000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Image budgets at the `dsh-llm-pi-ai` defaults. They bound requests to models
|
|
26
|
+
* whose catalog entry declares image input; text-only models never see images.
|
|
27
|
+
*/
|
|
28
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
29
|
+
maxRequestImageBytes: 20971520,
|
|
30
|
+
requestImagePixelBudget: 4194304,
|
|
31
|
+
requestImageMaxBytes: 1048576,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inert pi-ai auth plane.
|
|
36
|
+
*
|
|
37
|
+
* This route authenticates only through the shim's shared secret, resolved per
|
|
38
|
+
* request by `resolveApiKey`. pi-ai's own credential lifecycle must never
|
|
39
|
+
* manufacture a credential for it, so every ambient question answers "nothing
|
|
40
|
+
* stored, nothing set".
|
|
41
|
+
*/
|
|
42
|
+
const INERT_AUTH = {
|
|
43
|
+
credentials: {
|
|
44
|
+
async read() {},
|
|
45
|
+
async list() {
|
|
46
|
+
return []
|
|
47
|
+
},
|
|
48
|
+
async modify() {
|
|
49
|
+
throw new Error('dsh-connect-qoder: the qoder route has no pi-ai credential lifecycle')
|
|
50
|
+
},
|
|
51
|
+
async delete() {},
|
|
52
|
+
},
|
|
53
|
+
authContext: {
|
|
54
|
+
async env() {},
|
|
55
|
+
async fileExists() {
|
|
56
|
+
return false
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether one model may receive images.
|
|
63
|
+
*
|
|
64
|
+
* `auto` follows the catalog's own `is_vl` flag, which is what Qoder publishes
|
|
65
|
+
* for every model it serves; `on` and `off` are the user's explicit override
|
|
66
|
+
* from the settings card. An unrecognised mode degrades to `auto` rather than
|
|
67
|
+
* disabling images, so a stale saved value cannot silently drop a capability.
|
|
68
|
+
*
|
|
69
|
+
* @param entry - one normalized catalog entry.
|
|
70
|
+
* @param imageMode - `'auto'`, `'on'`, or `'off'`.
|
|
71
|
+
* @returns true when the model should declare image input.
|
|
72
|
+
*/
|
|
73
|
+
function imageEnabled(entry, imageMode) {
|
|
74
|
+
if (imageMode === 'on') return true
|
|
75
|
+
if (imageMode === 'off') return false
|
|
76
|
+
return entry.isVL === true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** No per-token price is knowable for a subscription quota; report zero. */
|
|
80
|
+
const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
81
|
+
|
|
82
|
+
/** Default context window when the catalog declares no usable one. */
|
|
83
|
+
const FALLBACK_CONTEXT_WINDOW = 200000
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The name the picker shows for one model.
|
|
87
|
+
*
|
|
88
|
+
* `listModels` forwards only `id`, `name`, and the input modalities, so the
|
|
89
|
+
* multiplier has nowhere else to travel and is folded into the name — the same
|
|
90
|
+
* approach the WorkBuddy bundle uses for its own rate. Every DSH-side join keys
|
|
91
|
+
* on the model **id** (the selector's choice, the session events, the wire
|
|
92
|
+
* request), so decorating the name is display-only and cannot affect routing.
|
|
93
|
+
*
|
|
94
|
+
* A zero multiplier means the model is free, which is worth saying outright;
|
|
95
|
+
* anything else is shown to two decimals so the ordering is comparable. A
|
|
96
|
+
* catalog entry with no multiplier at all keeps its bare name.
|
|
97
|
+
*/
|
|
98
|
+
function displayNameFor(entry, now) {
|
|
99
|
+
const factor = effectiveRate(entry, now)
|
|
100
|
+
if (!Number.isFinite(factor)) return entry.name
|
|
101
|
+
if (factor <= 0) return `${entry.name} · 免费`
|
|
102
|
+
const base = `${entry.name} · x${factor.toFixed(2)}`
|
|
103
|
+
// The off-peak discount is time-dependent, so the name says which side of the
|
|
104
|
+
// window it was computed on. Without this a rate that silently changes at
|
|
105
|
+
// 22:00 would look like a bug.
|
|
106
|
+
return isOffPeakActive(entry, now) ? `${base} 错峰` : base
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Seconds past local midnight in `timezone`, or `undefined` when the zone is
|
|
111
|
+
* unusable.
|
|
112
|
+
*
|
|
113
|
+
* `Intl` is used rather than a manual UTC offset because the promotion window is
|
|
114
|
+
* declared in a named zone (`Asia/Shanghai`) and China has no DST — but a zone
|
|
115
|
+
* that does would silently drift with a fixed offset.
|
|
116
|
+
*/
|
|
117
|
+
function localSecondsOf(date, timezone) {
|
|
118
|
+
try {
|
|
119
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
120
|
+
timeZone: timezone,
|
|
121
|
+
hour12: false,
|
|
122
|
+
hour: '2-digit',
|
|
123
|
+
minute: '2-digit',
|
|
124
|
+
second: '2-digit',
|
|
125
|
+
}).formatToParts(date)
|
|
126
|
+
const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? Number.NaN)
|
|
127
|
+
const hour = read('hour') % 24
|
|
128
|
+
const minute = read('minute')
|
|
129
|
+
const second = read('second')
|
|
130
|
+
if (![hour, minute, second].every(Number.isFinite)) return undefined
|
|
131
|
+
return hour * 3600 + minute * 60 + second
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Parse `HH:MM` (or `HH:MM:SS`) into seconds past midnight. */
|
|
138
|
+
function parseClock(text) {
|
|
139
|
+
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(text).trim())
|
|
140
|
+
if (match === null) return undefined
|
|
141
|
+
const hour = Number(match[1])
|
|
142
|
+
const minute = Number(match[2])
|
|
143
|
+
const second = Number(match[3] ?? 0)
|
|
144
|
+
if (hour > 23 || minute > 59 || second > 59) return undefined
|
|
145
|
+
return hour * 3600 + minute * 60 + second
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a model's off-peak discount is in effect right now.
|
|
150
|
+
*
|
|
151
|
+
* A window whose end is not after its start crosses midnight (`22:00`-`08:00`
|
|
152
|
+
* is the case Qoder actually uses), so the test is a disjunction rather than a
|
|
153
|
+
* range check. This mirrors the client's own `resolveModelPromotionState`.
|
|
154
|
+
*/
|
|
155
|
+
function isOffPeakActive(entry, now = new Date()) {
|
|
156
|
+
const promotion = entry.promotion
|
|
157
|
+
if (promotion === undefined || promotion.active !== true) return false
|
|
158
|
+
const start = parseClock(promotion.windowStart)
|
|
159
|
+
const end = parseClock(promotion.windowEnd)
|
|
160
|
+
if (start === undefined || end === undefined || start === end) return false
|
|
161
|
+
const seconds = localSecondsOf(now, promotion.timezone)
|
|
162
|
+
if (seconds === undefined) return false
|
|
163
|
+
return start < end ? seconds >= start && seconds < end : seconds >= start || seconds < end
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The multiplier that actually applies right now.
|
|
168
|
+
*
|
|
169
|
+
* Qoder publishes `price_factor` as the **discounted** price and
|
|
170
|
+
* `before_promotion_price_factor` as the price outside the window — the two are
|
|
171
|
+
* related by exactly `before × discount_factor`, which is what the catalog
|
|
172
|
+
* reports while the window is open. Reading `price_factor` alone therefore
|
|
173
|
+
* understates the cost by the discount for most of the day: during working hours
|
|
174
|
+
* these models bill at the *before* rate, which is 2.5x to 5x higher.
|
|
175
|
+
*
|
|
176
|
+
* The window is evaluated locally rather than trusted from the server, matching
|
|
177
|
+
* what the Qoder client itself does, so the number is right on both sides of the
|
|
178
|
+
* boundary regardless of when the catalog was fetched.
|
|
179
|
+
*/
|
|
180
|
+
function effectiveRate(entry, now = new Date()) {
|
|
181
|
+
const base = Number(entry.priceFactor)
|
|
182
|
+
const promotion = entry.promotion
|
|
183
|
+
if (promotion === undefined) return Number.isFinite(base) ? base : Number.NaN
|
|
184
|
+
const before = Number(promotion.beforePromotionPriceFactor)
|
|
185
|
+
const discount = Number(promotion.discountFactor)
|
|
186
|
+
if (isOffPeakActive(entry, now)) {
|
|
187
|
+
// Inside the window: the discounted price, which the catalog also reports as
|
|
188
|
+
// `price_factor`. The product is preferred so a stale `price_factor` cannot
|
|
189
|
+
// disagree with the window the name is annotated from.
|
|
190
|
+
if (Number.isFinite(before) && Number.isFinite(discount)) return before * discount
|
|
191
|
+
return Number.isFinite(base) ? base : Number.NaN
|
|
192
|
+
}
|
|
193
|
+
// Outside the window the discount does not apply.
|
|
194
|
+
if (Number.isFinite(before)) return before
|
|
195
|
+
return Number.isFinite(base) ? base : Number.NaN
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Seconds until the current off-peak window flips, or `undefined`. */
|
|
199
|
+
function offPeakRemainingSeconds(entry, now = new Date()) {
|
|
200
|
+
const promotion = entry.promotion
|
|
201
|
+
if (promotion === undefined || promotion.active !== true) return undefined
|
|
202
|
+
const start = parseClock(promotion.windowStart)
|
|
203
|
+
const end = parseClock(promotion.windowEnd)
|
|
204
|
+
if (start === undefined || end === undefined || start === end) return undefined
|
|
205
|
+
const seconds = localSecondsOf(now, promotion.timezone)
|
|
206
|
+
if (seconds === undefined) return undefined
|
|
207
|
+
const active = start < end ? seconds >= start && seconds < end : seconds >= start || seconds < end
|
|
208
|
+
const target = active ? end : start
|
|
209
|
+
const delta = target >= seconds ? target - seconds : 86400 - seconds + target
|
|
210
|
+
return delta
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Map a catalog entry onto a pi-ai model descriptor.
|
|
215
|
+
*
|
|
216
|
+
* `id` is the value DSH shows and the value the shim receives; `upstreamKey`
|
|
217
|
+
* rides along as the Qoder-side model key, which the shim needs on the wire.
|
|
218
|
+
*
|
|
219
|
+
* Two deliberate omissions:
|
|
220
|
+
*
|
|
221
|
+
* - **`maxTokens` is not declared.** `dsh-llm-pi-ai` records a declared value
|
|
222
|
+
* as the model's *configured* output ceiling, and pi-ai sends it as
|
|
223
|
+
* `max_tokens`. Qoder's catalog publishes no output ceiling, and reasoning
|
|
224
|
+
* shares that budget with the answer, so a declared value truncates long
|
|
225
|
+
* reasoned replies — the harness then reports `finish: max-tokens` and the
|
|
226
|
+
* visible text stops mid-sentence. Leaving it out lets the harness apply its
|
|
227
|
+
* own default instead.
|
|
228
|
+
* - **`contextWindow` comes from `context_config`, not `max_input_tokens`.**
|
|
229
|
+
* The catalog's `context_config` is the set of windows the app itself offers,
|
|
230
|
+
* with one flagged as the default; `max_input_tokens` is a smaller floor that
|
|
231
|
+
* is not the advertised capacity. When `preferMaximumContext` is set the
|
|
232
|
+
* largest offered window is advertised instead of the catalog's own default,
|
|
233
|
+
* which is the choice the settings section exposes.
|
|
234
|
+
*
|
|
235
|
+
* The thinking map is the same decision the wire makes, expressed for the
|
|
236
|
+
* picker: a model that always thinks refuses `enable_thinking: false`, so `off`
|
|
237
|
+
* is reported as unsupported for it rather than offered and then rejected.
|
|
238
|
+
*/
|
|
239
|
+
export function toPiModel(entry, baseUrl, providerId, preferMaximumContext = false, imageMode = 'auto', now = new Date()) {
|
|
240
|
+
const reasoning = entry.isReasoning === true
|
|
241
|
+
const levels = Array.isArray(entry.effortLevels) ? entry.effortLevels : []
|
|
242
|
+
const offered = (level) => (levels.includes(level) ? level : null)
|
|
243
|
+
const canDisable = reasoning && entry.alwaysThinking !== true
|
|
244
|
+
const options = Array.isArray(entry.contextOptions) ? entry.contextOptions.filter((n) => Number(n) > 0) : []
|
|
245
|
+
const widest = options.length > 0 ? Math.max(...options) : 0
|
|
246
|
+
// The Qoder client itself offers these windows up to 1M, so advertising the
|
|
247
|
+
// largest declared window is legitimate — the gateway accepts it there, and
|
|
248
|
+
// DSH sizes context from this number. Do not clamp it to `max_input_tokens`,
|
|
249
|
+
// which is a smaller per-request floor, not the model's real ceiling.
|
|
250
|
+
const preferred = preferMaximumContext && widest > 0 ? widest : 0
|
|
251
|
+
const contextWindow =
|
|
252
|
+
preferred > 0
|
|
253
|
+
? preferred
|
|
254
|
+
: Number(entry.defaultContextWindow) > 0
|
|
255
|
+
? Number(entry.defaultContextWindow)
|
|
256
|
+
: Number(entry.maxInputTokens) > 0
|
|
257
|
+
? Number(entry.maxInputTokens)
|
|
258
|
+
: FALLBACK_CONTEXT_WINDOW
|
|
259
|
+
return {
|
|
260
|
+
id: entry.id,
|
|
261
|
+
// The credit multiplier rides in the name because `listModels` forwards only
|
|
262
|
+
// id/name/modalities, so there is no other field the picker would show. It is
|
|
263
|
+
// resolved against `now`, because an off-peak model costs different amounts
|
|
264
|
+
// on either side of its window.
|
|
265
|
+
name: displayNameFor(entry, now),
|
|
266
|
+
api: 'openai-completions',
|
|
267
|
+
provider: providerId,
|
|
268
|
+
baseUrl,
|
|
269
|
+
// The catalog's `is_vl` is the default; the user's per-model choice wins.
|
|
270
|
+
input: imageEnabled(entry, imageMode) ? ['text', 'image'] : ['text'],
|
|
271
|
+
reasoning,
|
|
272
|
+
...(reasoning
|
|
273
|
+
? {
|
|
274
|
+
thinkingLevelMap: {
|
|
275
|
+
off: canDisable ? 'off' : null,
|
|
276
|
+
minimal: null,
|
|
277
|
+
low: offered('low'),
|
|
278
|
+
medium: offered('medium'),
|
|
279
|
+
high: offered('high'),
|
|
280
|
+
xhigh: offered('xhigh'),
|
|
281
|
+
max: offered('max'),
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
285
|
+
cost: NO_COST,
|
|
286
|
+
contextWindow,
|
|
287
|
+
// `supportsDeveloperRole: false` is load-bearing, not cosmetic.
|
|
288
|
+
//
|
|
289
|
+
// pi-ai's OpenAI-completions API decides the system-prompt role as
|
|
290
|
+
// `model.reasoning && compat.supportsDeveloperRole ? 'developer' : 'system'`.
|
|
291
|
+
// When left unset, pi-ai auto-detects it from the provider/URL, and the
|
|
292
|
+
// detection returns true for anything that does not look like a known
|
|
293
|
+
// non-standard provider. This route's baseUrl is the loopback shim
|
|
294
|
+
// (`http://127.0.0.1:<port>/v1`), so nothing matches that list and the
|
|
295
|
+
// resolved value is true — and every Qoder model declares `reasoning: true`,
|
|
296
|
+
// so pi-ai emits `role: "developer"` for the system prompt.
|
|
297
|
+
//
|
|
298
|
+
// Qoder's endpoint has no `developer` role. `toQoderMessages` used to drop
|
|
299
|
+
// the unknown role silently, which left the request with no system message
|
|
300
|
+
// at all — and the gateway answers a system-less request with
|
|
301
|
+
// `403 {"code":"10605"}` ("your request is already in the queue") on every
|
|
302
|
+
// attempt, no matter how long DSH retries. Declaring the capability false
|
|
303
|
+
// makes pi-ai emit `role: "system"`, which the endpoint honours.
|
|
304
|
+
compat: { maxTokensField: 'max_tokens', supportsDeveloperRole: false },
|
|
305
|
+
// Qoder's own key for this model, carried for the shim's wire request.
|
|
306
|
+
upstreamKey: entry.key,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Seconds until the current off-peak window flips, or `undefined`.
|
|
312
|
+
*
|
|
313
|
+
* Exported so the settings card can show the same countdown the Qoder client
|
|
314
|
+
* does, computed from the same catalog entry rather than re-derived.
|
|
315
|
+
*/
|
|
316
|
+
export function offPeakRemaining(entry, now = new Date()) {
|
|
317
|
+
return offPeakRemainingSeconds(entry, now)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Whether a model's off-peak discount applies right now. */
|
|
321
|
+
export function offPeakActive(entry, now = new Date()) {
|
|
322
|
+
return isOffPeakActive(entry, now)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The multiplier that applies right now, resolving the off-peak window. */
|
|
326
|
+
export function rateNow(entry, now = new Date()) {
|
|
327
|
+
return effectiveRate(entry, now)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Narrow a catalog to the models the user enabled.
|
|
332
|
+
*
|
|
333
|
+
* An **empty list means "no filter"**, following the WorkBuddy convention: a
|
|
334
|
+
* fresh install has saved nothing and must still see every model. Once the list
|
|
335
|
+
* is non-empty it becomes an allow-list.
|
|
336
|
+
*
|
|
337
|
+
* This is shared because two independent readers must agree: the adapter builds
|
|
338
|
+
* the model list DSH routes requests through, and the shim answers
|
|
339
|
+
* `GET /v1/models`, which is what the picker's discovery actually reads. If they
|
|
340
|
+
* diverge, unchecking a model removes it from one surface and leaves it in the
|
|
341
|
+
* other — so the filtering lives here rather than being written twice.
|
|
342
|
+
*/
|
|
343
|
+
export function filterByEnabled(models, enabled) {
|
|
344
|
+
const list = Array.isArray(enabled) ? enabled.filter((id) => typeof id === 'string' && id.length > 0) : []
|
|
345
|
+
if (list.length === 0) return models
|
|
346
|
+
const allowed = new Set(list)
|
|
347
|
+
return models.filter((entry) => allowed.has(entry.id))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Assemble one adapter covering every region.
|
|
352
|
+
*
|
|
353
|
+
* A single `PiAiAdapter` serves all regions because `registerAdapter` maps a
|
|
354
|
+
* *set* of providers onto one adapter: registering each region in its own call
|
|
355
|
+
* would leave only the last one owned, since a later call replaces the previous
|
|
356
|
+
* registration rather than adding to it.
|
|
357
|
+
*
|
|
358
|
+
* @param options.regions - `[{ region, shim, catalog }]`, one entry per region.
|
|
359
|
+
* @param options.preferMaximumContext - `() => boolean`, read per model build so
|
|
360
|
+
* the settings switch takes effect on the next `llm/adapters-updated`.
|
|
361
|
+
* @param options.imageModeFor - `(modelId) => string`, the user's per-model image
|
|
362
|
+
* choice, read per model build for the same reason: turning a model's image
|
|
363
|
+
* input on or off must reach the picker without re-registering the adapter.
|
|
364
|
+
* @param options.enabledIdsFor - `(regionId) => string[]`, the models the user
|
|
365
|
+
* wants offered in that region. An empty list means "no filter", not "none":
|
|
366
|
+
* a fresh install has saved nothing and must still show every model, which is
|
|
367
|
+
* the same convention the WorkBuddy bundle uses. Read per build so curating
|
|
368
|
+
* the roster reaches the picker on the next `llm/adapters-updated`.
|
|
369
|
+
* @returns `{ adapter, invalidate, providerIds }`.
|
|
370
|
+
*/
|
|
371
|
+
export function createQoderAdapter(options) {
|
|
372
|
+
const runtimes = options.regions
|
|
373
|
+
if (runtimes.length === 0) throw new Error('dsh-connect-qoder: no regions to adapt')
|
|
374
|
+
const preferMaximumContext = options.preferMaximumContext ?? (() => false)
|
|
375
|
+
const imageModeFor = options.imageModeFor ?? (() => 'auto')
|
|
376
|
+
const enabledIdsFor = options.enabledIdsFor ?? (() => [])
|
|
377
|
+
|
|
378
|
+
const buildModels = (runtime) => {
|
|
379
|
+
const baseUrl = `${runtime.shim.baseUrl()}/v1`
|
|
380
|
+
const widest = preferMaximumContext() === true
|
|
381
|
+
const enabled = enabledIdsFor(runtime.region.id)
|
|
382
|
+
return filterByEnabled(runtime.catalog(), enabled).map((entry) =>
|
|
383
|
+
toPiModel(entry, baseUrl, runtime.region.id, widest, imageModeFor(entry.id)),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const buildProfiles = () => {
|
|
388
|
+
const profiles = new Map()
|
|
389
|
+
for (const runtime of runtimes) {
|
|
390
|
+
const providerId = runtime.region.id
|
|
391
|
+
const displayName = runtime.region.displayName
|
|
392
|
+
const provider = {
|
|
393
|
+
...createProvider({
|
|
394
|
+
id: providerId,
|
|
395
|
+
name: displayName,
|
|
396
|
+
auth: {
|
|
397
|
+
apiKey: {
|
|
398
|
+
name: 'Qoder loopback shim token',
|
|
399
|
+
async resolve({ credential }) {
|
|
400
|
+
const apiKey = credential?.key
|
|
401
|
+
return apiKey === undefined || apiKey.length === 0
|
|
402
|
+
? undefined
|
|
403
|
+
: { auth: { apiKey }, source: displayName }
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
models: buildModels(runtime),
|
|
408
|
+
api: openAICompletionsApi(),
|
|
409
|
+
}),
|
|
410
|
+
getModels: () => buildModels(runtime),
|
|
411
|
+
}
|
|
412
|
+
profiles.set(providerId, {
|
|
413
|
+
provider: providerId,
|
|
414
|
+
displayName,
|
|
415
|
+
streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
|
416
|
+
retryPolicy: resolveRetryPolicy(undefined, `dsh-connect-qoder.${providerId}.retryPolicy`),
|
|
417
|
+
configuredMaxTokens: new Map(),
|
|
418
|
+
modelErrors: new Map(),
|
|
419
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
420
|
+
piProvider: provider,
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
return profiles
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let profiles = buildProfiles()
|
|
427
|
+
|
|
428
|
+
const adapter = new PiAiAdapter({
|
|
429
|
+
profiles: () => profiles,
|
|
430
|
+
auth: INERT_AUTH,
|
|
431
|
+
// Each region's shim secret is the only credential this route presents.
|
|
432
|
+
resolveApiKey: async (provider) => {
|
|
433
|
+
const runtime = runtimes.find((entry) => entry.region.id === provider)
|
|
434
|
+
return runtime?.shim.token()
|
|
435
|
+
},
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
adapter,
|
|
440
|
+
invalidate: () => {
|
|
441
|
+
profiles = buildProfiles()
|
|
442
|
+
},
|
|
443
|
+
providerIds: runtimes.map((runtime) => runtime.region.id),
|
|
444
|
+
}
|
|
445
|
+
}
|