dsh-tap 0.20.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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * providers/qoder/gateway.js — OpenAI ↔ Qoder COSY 翻译网关(Qoder 聊天桥)。
3
+ *
4
+ * 定位同 providers/trae/gateway.js:core/bridge.js 是透传代理(上游说 OpenAI
5
+ * 方言),本网关是协议翻译器——Qoder 聊天面的线缆形态是 COSY 签名 + WASM 加密
6
+ * body + SSE 信封(证据与设计文档见 docs/goals/qoder-cn-provider-design.md
7
+ * §5b 与 2026-09-20 探测):
8
+ *
9
+ * 出站:POST {inferBaseURL}/algo/api/v2/service/pro/sse/agent_chat_generation
10
+ * ?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1
11
+ * (URL/头/body 全由 cosy.prepareChat 产出,签名绑定 URL 不可手改);
12
+ * body 明文 = OpenAI 白名单字段 + 官方客户端用量归因信封(request_id/
13
+ * request_set_id/session_id/chat_task/version:"3"/source:1/agent_id/
14
+ * task_id/session_type/model_config/business 块——2026-09-22 逆向:
15
+ * 配额扣减与形态无关(裸 body 也实时入账),统计视图由归因链驱动);
16
+ * 每轮结束补 business/finish + /api/v1/tracking 两条 COSY 签名上报
17
+ * (best-effort,官方客户端同语义,证据 docs/probes/qoder-attribution-*.json)
18
+ * 入站 SSE:data:{headers, body, statusCodeValue} 信封——body 是**字符串**,
19
+ * 内容为标准 OpenAI chat.completion.chunk 的 JSON 或 "[DONE]";
20
+ * 尾帧 {firstTokenDuration,totalDuration,serverDuration} 无 body 忽略;
21
+ * event:error + data:{stackTrace…} 为服务端异常(如 body 非合法 JSON);
22
+ * 带内失败帧 = HTTP 200 信封里 body 是业务错误对象(无 choices/usage、
23
+ * 有 code/message,实测形态 {"code":"400","message":"[FAIL]node:…
24
+ * msg:Execution failed: null"})——同等当错误上抛,不静默空响应。
25
+ * 翻译 = 拆信封把 inner chunk 原样下发(增量/tool_calls/finish/usage
26
+ * 全是标准 OpenAI 形态,2026-09-20 矩阵实测);usage.credits →
27
+ * usage.credit 进计量(usage-meter 契约)。
28
+ *
29
+ * 非流式入站:聚合成单个 chat.completion(内部仍走上游流式)。
30
+ * 生命周期纪律(踩坑 #17):listen 失败降级 runtime.lastError,绝不抛出。
31
+ */
32
+
33
+ import { createServer } from 'node:http'
34
+ import { appendFileSync } from 'node:fs'
35
+
36
+ import { SessionLimiter, extractSessionId } from '../../core/bridge.js'
37
+ import { sanitizeToolPairing, describeRepair } from '../tool-pairing.js'
38
+ import { QODER_COSY_VERSION } from './cosy.js'
39
+ import { randomUUID } from 'node:crypto'
40
+
41
+ /** 上游接受的 OpenAI 字段白名单(dsh/pi-ai 可能附带私有扩展,不透传)。 */
42
+ const CHAT_FIELDS = [
43
+ 'messages', 'tools', 'tool_choice', 'temperature', 'top_p', 'max_tokens',
44
+ 'max_completion_tokens', 'stop', 'reasoning_effort', 'presence_penalty',
45
+ 'frequency_penalty', 'response_format', 'seed', 'user', 'parallel_tool_calls',
46
+ ]
47
+
48
+ // 出站 messages 的 tool 配对不变量修复:实现与根因见 providers/tool-pairing.js
49
+ // (踩坑 #39)。本模块重新导出,保持既有 import 路径与回归断言可用。
50
+ export { sanitizeToolPairing }
51
+
52
+ /** Host 门(同 trae gateway):回环端口是唯一防线,Host 必须回环。 */
53
+ function isLoopbackHost(hostHeader) {
54
+ if (typeof hostHeader !== 'string' || !hostHeader) return false
55
+ let hostname
56
+ try {
57
+ hostname = new URL(`http://${hostHeader}`).hostname
58
+ } catch {
59
+ return false
60
+ }
61
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]'
62
+ }
63
+
64
+ /**
65
+ * Origin 门(与 Host 门互补,index.js localGuardFailure 同口径):浏览器
66
+ * 跨站请求(含 navigator.sendBeacon)恒带 Origin——host:port 必须与 Host
67
+ * 完全一致才放行,否则即借浏览器烧额度的 CSRF。无 Origin 放行:本机
68
+ * fetch/curl 与剥 Origin 的壳转发均不带该头,Host 门仍把守回环。
69
+ */
70
+ function isLoopbackOrigin(req) {
71
+ const origin = req.headers.origin
72
+ if (origin === undefined) return true
73
+ try {
74
+ return new URL(origin).host === req.headers.host
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Qoder SSE 信封解析器:吃原始 data 文本行,产出翻译事件
82
+ * { chunk?, usage?, done?, error? }。inner chunk 是标准 OpenAI 形态,
83
+ * 透传前只在需要计量时把 usage.credits 归一为 usage.credit。
84
+ */
85
+ export function createQoderEnvelopeParser() {
86
+ const state = { usage: null, done: false }
87
+ return {
88
+ isDone: () => state.done,
89
+ usage: () => state.usage,
90
+ /**
91
+ * @param {string|null} eventName SSE event: 行值
92
+ * @param {string} dataText data: 行原文
93
+ */
94
+ handle(eventName, dataText) {
95
+ if (eventName === 'error') {
96
+ state.done = true
97
+ return { error: dataText.slice(0, 400) }
98
+ }
99
+ let frame
100
+ try { frame = JSON.parse(dataText) } catch { return {} }
101
+ if (!frame || typeof frame !== 'object') return {}
102
+ if (typeof frame.body !== 'string') return {} // 尾帧计时统计等
103
+ if (frame.body === '[DONE]') {
104
+ state.done = true
105
+ return { done: true }
106
+ }
107
+ let chunk
108
+ try { chunk = JSON.parse(frame.body) } catch {
109
+ state.done = true
110
+ return { error: frame.body.slice(0, 400) }
111
+ }
112
+ // 带内失败帧:HTTP 200 信封装业务错误(无 choices/usage、有 code/message,
113
+ // 实测 2026-09-22 qfmodel 上游节点挂:{"code":"400","message":"[FAIL]node:…"})
114
+ if (!Array.isArray(chunk.choices) && !chunk.usage && (chunk.code !== undefined || typeof chunk.message === 'string')) {
115
+ state.done = true
116
+ return { error: frame.body.slice(0, 400) }
117
+ }
118
+ if (chunk.usage && typeof chunk.usage === 'object') {
119
+ const u = chunk.usage
120
+ state.usage = {
121
+ prompt_tokens: u.prompt_tokens ?? 0,
122
+ completion_tokens: u.completion_tokens ?? 0,
123
+ total_tokens: u.total_tokens ?? 0,
124
+ credit: typeof u.credits === 'number' ? u.credits : 0,
125
+ }
126
+ }
127
+ return { chunk }
128
+ },
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @param {{
134
+ * settings: () => object, // qoderInferBaseURL / maxConcurrentPerSession / upstreamFirstByteTimeoutMs
135
+ * resolveCredential: (s: object) => Promise<{authorization, machineId, uid}|null>,
136
+ * cosy: object, // createCosyRuntime 实例
137
+ * meter: { record: Function },
138
+ * runtime: { running, port, lastError },
139
+ * forensics?: { logPath: () => string|undefined },
140
+ * getCatalogProfiles: () => Array|null, // /v1/models 端点
141
+ * getCatalogEntry?: (id: string) => object|null, // 目录原始条目(model_config 取数)
142
+ * getModelSource: (id: string) => string, // X-Model-Source(目录 sources 映射)
143
+ * getModelPrefs?: () => object, // { [id]: { effort?, contextVariant? } } 出站补默认
144
+ * }} deps
145
+ */
146
+ export function createQoderGateway(deps) {
147
+ const limiter = new SessionLimiter()
148
+ const logPrefix = '[dsh-tap/qoder]'
149
+ // dsh 会话标识 → 官方形态 session_id(UUID):映射为实例状态(踩坑 #20),
150
+ // 同一 dsh 会话跨请求稳定,与官方 CLI 会话语义对齐。
151
+ const sessionUuids = new Map()
152
+ function qoderSessionId(dshSessionId) {
153
+ const key = String(dshSessionId)
154
+ let v = sessionUuids.get(key)
155
+ if (!v) {
156
+ v = randomUUID()
157
+ if (sessionUuids.size >= 5000) sessionUuids.clear()
158
+ sessionUuids.set(key, v)
159
+ }
160
+ return v
161
+ }
162
+
163
+ function gwLog(record) {
164
+ const path = deps.forensics?.logPath?.()
165
+ if (!path) return
166
+ try {
167
+ appendFileSync(path, JSON.stringify({ gw: 'qoder', ...record }) + '\n')
168
+ } catch { /* best-effort */ }
169
+ }
170
+
171
+ function modelSourceOf(model) {
172
+ return deps.getModelSource?.(model) ?? 'system'
173
+ }
174
+
175
+ /** 最后一条 user 消息的纯文本(content 字符串或多模态 parts 拼接)。 */
176
+ function lastUserTextOf(messages) {
177
+ for (let i = messages.length - 1; i >= 0; i--) {
178
+ const m = messages[i]
179
+ if (m?.role !== 'user') continue
180
+ if (typeof m.content === 'string') return m.content
181
+ if (Array.isArray(m.content)) {
182
+ return m.content.filter((p) => p?.type === 'text' && typeof p.text === 'string').map((p) => p.text).join('\n')
183
+ }
184
+ return ''
185
+ }
186
+ return ''
187
+ }
188
+
189
+ /**
190
+ * 用量归因上报(2026-09-22 逆向定案后接入,与官方客户端逐字段同构):
191
+ * 配额扣减(quota/usage 的 addOnQuota.used)对裸 OpenAI body 也实时入账
192
+ * (臂 9 大额双臂实证);但**统计视图**(credits-heatmap/credits-summary/
193
+ * 网页明细)是延迟批处理且由官方归因链驱动——官方客户端每轮业务结束
194
+ * 补两条 COSY 签名上报(bundle g4i/aPl 原文):
195
+ * 1. POST {infer}/api/v2/service/business/finish?Encode=1(mode auth)
196
+ * —— BUSINESS_FINISH 事件,business.id = request_set_id 为 join key;
197
+ * 2. POST {infer}/api/v1/tracking(mode sign)—— back-flow 事件,
198
+ * 聚合 total_credits/tokens(best-effort 语义,官方同样 fire-and-forget)。
199
+ * best-effort:任何失败都不影响主链路(调用侧 .catch 落地,踩坑 #33)。
200
+ */
201
+ async function reportUsage({ cred, endpoint, sessionId, requestSetId, stage, promptName, usage, model, ms }) {
202
+ const now = Date.now()
203
+ const uid = cred.uid ?? ''
204
+ const mid = cred.machineId ?? ''
205
+ const inner = {
206
+ event_time: now, event_type: 'BUSINESS_FINISH',
207
+ mid, aid: '', rid: randomUUID(), oid: '', yid: '', uid,
208
+ event_data: {
209
+ session_id: sessionId,
210
+ business: { product: 'cli', version: QODER_COSY_VERSION, type: 'agent', id: requestSetId, end_at: now, stage, name: promptName },
211
+ },
212
+ }
213
+ const finishBody = JSON.stringify({ payload: JSON.stringify(inner), encodeVersion: '1' })
214
+ const finish = await deps.cosy.prepareSigned(cred, { endpoint, path: '/api/v2/service/business/finish?Encode=1', method: 'POST', mode: 'auth', body: finishBody })
215
+ await fetch(finish.url, { method: 'POST', headers: { ...finish.headers, 'Content-Type': 'application/json' }, body: finish.body })
216
+ if (!usage) return
217
+ const envelope = [{
218
+ uuid: randomUUID(),
219
+ event_type: 'qodercli-back-flow-agent-query-finish',
220
+ event_time: now,
221
+ uid, oid: '', mid, aid: '',
222
+ os_arch: process.arch, os_version: '',
223
+ ide_type: 'CLI', ide_version: QODER_COSY_VERSION, cluster_env: '',
224
+ business_id: requestSetId, git_remote: '',
225
+ event_data: {
226
+ items: [{
227
+ schema_version: 2, session_id: sessionId, task_id: requestSetId,
228
+ request_set_id: requestSetId, prompt_id: randomUUID(),
229
+ entry: 'cli', product: 'cli', client_type: '5', cli_version: QODER_COSY_VERSION, os_type: process.platform,
230
+ query_callback: 'end', terminal_reason: stage, business_state_final: stage,
231
+ duration_ms: ms, loop_iteration_count: 1,
232
+ model_request_count: 1, model_request_success_count: stage === 'complete' ? 1 : 0, actual_model: model,
233
+ total_input_tokens: usage.prompt_tokens ?? 0, total_output_tokens: usage.completion_tokens ?? 0,
234
+ total_cache_read_tokens: 0, total_cache_write_tokens: 0, total_thinking_tokens: 0,
235
+ total_credits: usage.credit ?? 0, total_original_credits: usage.credit ?? 0,
236
+ average_ttft_ms: 0, delivery_mode: 'best_effort_background',
237
+ }],
238
+ },
239
+ }]
240
+ const track = await deps.cosy.prepareSigned(cred, { endpoint, path: '/api/v1/tracking', method: 'POST', mode: 'sign', body: JSON.stringify(envelope) })
241
+ await fetch(track.url, { method: 'POST', headers: { ...track.headers, 'Content-Type': 'application/json' }, body: track.body })
242
+ }
243
+
244
+ async function handleChat(req, res, rawBody) {
245
+ const s = deps.settings()
246
+ let payload = null
247
+ try { payload = JSON.parse(rawBody) } catch { payload = null }
248
+ if (!payload || !Array.isArray(payload.messages) || !payload.messages.length) {
249
+ res.writeHead(400, { 'Content-Type': 'application/json' })
250
+ res.end(JSON.stringify({ error: { message: 'invalid chat payload' } }))
251
+ return
252
+ }
253
+ const model = typeof payload.model === 'string' && payload.model ? payload.model : 'auto'
254
+ const wantStream = payload.stream === true
255
+ const sessionId = extractSessionId(req.headers, payload) ?? randomUUID()
256
+ const t0 = Date.now()
257
+
258
+ const release = await limiter.acquire(sessionId, s.maxConcurrentPerSession ?? 4)
259
+ try {
260
+ const cred = await deps.resolveCredential(s)
261
+ if (!cred) {
262
+ res.writeHead(503, { 'Content-Type': 'application/json' })
263
+ res.end(JSON.stringify({ error: { message: 'Qoder 未登录:请先在设置卡 Qoder CN 页完成浏览器授权', code: 'qoder_credential_unavailable' } }))
264
+ return
265
+ }
266
+ const accessToken = String(cred.authorization ?? '').replace(/^Bearer\s+/, '')
267
+
268
+ // 出站体:白名单透传 + 强制流式(usage 必带)
269
+ const upstream = { model, stream: true, stream_options: { include_usage: true } }
270
+ for (const f of CHAT_FIELDS) {
271
+ if (payload[f] !== undefined && payload[f] !== null) upstream[f] = payload[f]
272
+ }
273
+ // developer → system:宿主 pi-ai 对带 reasoning 的模型把 system prompt 序列化成
274
+ // `role:"developer"`(docs/rules/dev-role-boundary.md 同族),而 Qoder 上游在
275
+ // **反序列化阶段**就整请求拒绝(2026-09-22 实测 dmodel:details 内层
276
+ // "Failed to deserialize the JSON body...",HTTP 200 信封带内错误)。两者指令
277
+ // 语义等价,出站折叠为 system(同 codebuddy 桥 transformChatPayload 的策略)。
278
+ // 只换 role、不原地改调用方对象。
279
+ upstream.messages = upstream.messages.map((m) => (m && m.role === 'developer' ? { ...m, role: 'system' } : m))
280
+ // tool 配对 + 可见性体检(真根因见 providers/tool-pairing.js 头注释:严格上游
281
+ // 把 content 为 null 的 assistant/tool 消息从配对校验里当不存在,宿主对**每一个**
282
+ // 工具回合都发 content:null → 该家族上工具必 400)。只动 messages。
283
+ const pair = sanitizeToolPairing(upstream.messages)
284
+ upstream.messages = pair.messages
285
+ const repairedNote = describeRepair(pair.repaired)
286
+ // 出站补默认(客户端已带的绝不覆盖):
287
+ // - reasoning_effort:prefs.effort 已设且非 off——off 的语义是"省略参数"
288
+ // (同 cordis.patch.yml codebuddy 侧 verified 行为),不是发 'off' 线值;
289
+ // - max_completion_tokens:目录 profile.maxTokens 是输出上限的牙齿,payload
290
+ // 未带 max_tokens 系时注入。只补默认,不碰白名单/计量路径。
291
+ const prefs = deps.getModelPrefs?.() ?? {}
292
+ const effort = prefs[model]?.effort
293
+ if (upstream.reasoning_effort === undefined && effort !== undefined && effort !== 'off') {
294
+ upstream.reasoning_effort = effort
295
+ }
296
+ if (upstream.max_tokens === undefined && upstream.max_completion_tokens === undefined) {
297
+ const profile = (deps.getCatalogProfiles() ?? []).find((p) => p.id === model)
298
+ if (Number.isFinite(profile?.maxTokens)) upstream.max_completion_tokens = profile.maxTokens
299
+ }
300
+ const endpoint = String(s.qoderInferBaseURL ?? '').replace(/\/+$/, '')
301
+ // ── 官方客户端用量归因信封(2026-09-22 逆向:配额扣减对裸 body 也实时
302
+ // 入账,但统计视图——热力图/汇总/明细——由归因链驱动;官方 A6e 信封的
303
+ // 归因字段 + business 块逐字段对齐,business.id = request_set_id 与收尾
304
+ // business/finish 上报同源)──
305
+ const promptText = lastUserTextOf(upstream.messages)
306
+ const requestId = randomUUID()
307
+ const requestSetId = randomUUID()
308
+ const qSessionId = qoderSessionId(sessionId)
309
+ const catEntry = deps.getCatalogEntry?.(model) ?? null
310
+ upstream.request_id = requestId
311
+ upstream.request_set_id = requestSetId
312
+ upstream.chat_record_id = requestId
313
+ upstream.session_id = qSessionId
314
+ upstream.chat_task = 'FREE_INPUT'
315
+ upstream.chat_context = {
316
+ text: promptText, features: [],
317
+ extra: { context: [], modelConfig: { key: model, is_reasoning: catEntry?.is_reasoning === true }, originalContent: promptText },
318
+ chatPrompt: '', imageUrls: null,
319
+ }
320
+ upstream.is_reply = true
321
+ upstream.is_retry = false
322
+ upstream.source = 1
323
+ upstream.version = '3'
324
+ upstream.agent_id = 'agent_common'
325
+ upstream.task_id = 'common'
326
+ upstream.session_type = 'qoderclicn'
327
+ upstream.aliyun_user_type = ''
328
+ upstream.model_config = {
329
+ key: model, display_name: typeof catEntry?.display_name === 'string' ? catEntry.display_name : model,
330
+ model: '', format: 'openai',
331
+ is_vl: catEntry?.is_vl === true, is_reasoning: catEntry?.is_reasoning === true,
332
+ api_key: '', url: '', source: modelSourceOf(model),
333
+ max_input_tokens: Number.isFinite(catEntry?.max_input_tokens) ? catEntry.max_input_tokens : 128000,
334
+ }
335
+ upstream.business = {
336
+ product: 'cli', version: QODER_COSY_VERSION, type: 'agent',
337
+ id: requestSetId, name: promptText.slice(0, 10), begin_at: t0, stage: 'processing',
338
+ }
339
+ const report = (stage, usage) => reportUsage({
340
+ cred: { accessToken, machineId: cred.machineId, uid: cred.uid },
341
+ endpoint, sessionId: qSessionId, requestSetId, stage,
342
+ promptName: upstream.business.name, usage, model, ms: Date.now() - t0,
343
+ }).catch(() => { /* best-effort:上报失败不影响主链路(官方同语义) */ })
344
+ const bodyJson = JSON.stringify(upstream)
345
+
346
+ const signed = await deps.cosy.prepareChat(
347
+ { accessToken, machineId: cred.machineId, uid: cred.uid },
348
+ { endpoint, body: bodyJson, modelKey: model, modelSource: modelSourceOf(model) },
349
+ )
350
+
351
+ // 首字节护栏(同 trae gateway:边缘"收下不回应"时快速失败)
352
+ const firstByteMs = Number(s.upstreamFirstByteTimeoutMs) > 0 ? Number(s.upstreamFirstByteTimeoutMs) : 45_000
353
+ const inbound = new AbortController()
354
+ const firstByteTimer = setTimeout(
355
+ () => inbound.abort(new Error(`qoder 上游 ${firstByteMs}ms 内无响应(首字节超时)`)),
356
+ firstByteMs,
357
+ )
358
+ const upstreamResp = await fetch(signed.url, {
359
+ method: 'POST',
360
+ headers: { ...signed.headers, 'Content-Type': 'application/json', Accept: 'text/event-stream' },
361
+ body: signed.body,
362
+ signal: inbound.signal,
363
+ }).finally(() => clearTimeout(firstByteTimer))
364
+
365
+ if (!upstreamResp.ok) {
366
+ const text = await upstreamResp.text().catch(() => '')
367
+ const status = upstreamResp.status === 401 || upstreamResp.status === 429 ? upstreamResp.status : 502
368
+ res.writeHead(status, { 'Content-Type': 'application/json' })
369
+ res.end(JSON.stringify({ error: { message: `qoder upstream HTTP ${upstreamResp.status}: ${text.slice(0, 200)}`, code: `qoder_${upstreamResp.status}` } }))
370
+ gwLog({ dir: 'err', status: upstreamResp.status, model, ms: Date.now() - t0 })
371
+ report('error', null)
372
+ return
373
+ }
374
+
375
+ const id = `qoder-${randomUUID().slice(0, 8)}`
376
+ const parser = createQoderEnvelopeParser()
377
+ let content = ''
378
+ let reasoning = ''
379
+ let finishReason = null
380
+ const toolSlots = new Map()
381
+ const toolOrder = []
382
+
383
+ if (wantStream) {
384
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
385
+ }
386
+ const send = (chunk) => {
387
+ if (wantStream) res.write(`data: ${JSON.stringify(chunk)}\n\n`)
388
+ }
389
+ const emitError = (message) => {
390
+ if (!wantStream) return
391
+ send({ error: { message } })
392
+ res.write('data: [DONE]\n\n')
393
+ res.end()
394
+ }
395
+
396
+ // 逐行扫描信封(Buffer 边界安全:TextDecoder stream 模式)
397
+ const reader = upstreamResp.body.getReader()
398
+ const decoder = new TextDecoder()
399
+ let buf = ''
400
+ let lastEvent = null
401
+ // 客户端断连传播:res 关闭(且未正常结束)时取消上游流,不白烧配额。
402
+ let clientGone = false
403
+ const onClose = () => {
404
+ if (res.writableEnded) return
405
+ clientGone = true
406
+ reader.cancel().catch(() => {})
407
+ }
408
+ res.on('close', onClose)
409
+ try {
410
+ for (;;) {
411
+ const { done, value } = await reader.read()
412
+ if (done) break
413
+ buf += decoder.decode(value, { stream: true })
414
+ let nl
415
+ while ((nl = buf.indexOf('\n')) >= 0) {
416
+ const line = buf.slice(0, nl).trim()
417
+ buf = buf.slice(nl + 1)
418
+ if (!line) { lastEvent = null; continue }
419
+ if (line.startsWith('event:')) { lastEvent = line.slice(6).trim(); continue }
420
+ if (!line.startsWith('data:')) continue
421
+ const ev = parser.handle(lastEvent, line.slice(5).trim())
422
+ if (ev.error) {
423
+ gwLog({ dir: 'err', model, ms: Date.now() - t0, note: 'stream-error-frame', repaired: repairedNote })
424
+ report('error', null)
425
+ if (wantStream) {
426
+ emitError(`qoder upstream error: ${ev.error}`)
427
+ } else {
428
+ res.writeHead(502, { 'Content-Type': 'application/json' })
429
+ res.end(JSON.stringify({ error: { message: `qoder upstream error: ${ev.error}`, code: 'qoder_upstream_error' } }))
430
+ }
431
+ return
432
+ }
433
+ if (!ev.chunk) continue
434
+ const chunk = ev.chunk
435
+ const choice = chunk.choices?.[0]
436
+ if (choice?.delta?.content) content += choice.delta.content
437
+ if (choice?.delta?.reasoning_content) reasoning += choice.delta.reasoning_content
438
+ for (const tc of choice?.delta?.tool_calls ?? []) {
439
+ const idx = Number.isInteger(tc?.index) ? tc.index : 0
440
+ if (!toolOrder.includes(idx)) toolOrder.push(idx)
441
+ const slot = toolSlots.get(idx) ?? { id: '', name: '', arguments: '' }
442
+ if (tc.id) slot.id = tc.id
443
+ if (tc.function?.name) slot.name = tc.function.name
444
+ if (typeof tc.function?.arguments === 'string') slot.arguments += tc.function.arguments
445
+ toolSlots.set(idx, slot)
446
+ }
447
+ if (choice?.finish_reason) finishReason = choice.finish_reason
448
+ if (wantStream) res.write(`data: ${JSON.stringify(chunk)}\n\n`)
449
+ }
450
+ }
451
+ } finally {
452
+ res.off('close', onClose)
453
+ // 正常跑完时流已 EOF,cancel 是 no-op;提前 return 时取消残留 undici 连接。
454
+ if (!clientGone) reader.cancel().catch(() => {})
455
+ }
456
+
457
+ const usage = parser.usage()
458
+ if (wantStream) {
459
+ res.write('data: [DONE]\n\n')
460
+ res.end()
461
+ } else {
462
+ const message = { role: 'assistant', content }
463
+ if (reasoning) message.reasoning_content = reasoning
464
+ const calls = toolOrder.map((i) => {
465
+ const slot = toolSlots.get(i)
466
+ return { id: slot.id || `qoder-call-${i}`, type: 'function', function: { name: slot.name, arguments: slot.arguments } }
467
+ })
468
+ if (calls.length) message.tool_calls = calls
469
+ res.writeHead(200, { 'Content-Type': 'application/json' })
470
+ res.end(JSON.stringify({
471
+ id,
472
+ object: 'chat.completion',
473
+ created: Math.floor(Date.now() / 1000),
474
+ model,
475
+ choices: [{ index: 0, message, finish_reason: finishReason ?? 'stop' }],
476
+ usage: usage ?? {},
477
+ }))
478
+ }
479
+ if (usage) deps.meter.record({ ts: t0, kind: 'chat', model, usage })
480
+ gwLog({ dir: 'out', model, ms: Date.now() - t0, bytes: content.length, usage, finishReason, repaired: repairedNote })
481
+ report('complete', usage ?? null)
482
+ } catch (err) {
483
+ if (!res.headersSent) {
484
+ res.writeHead(500, { 'Content-Type': 'application/json' })
485
+ try { res.end(JSON.stringify({ error: { message: `qoder gateway error: ${err?.message ?? err}` } })) } catch { res.end() }
486
+ } else {
487
+ try { res.end() } catch { /* already closed */ }
488
+ }
489
+ } finally {
490
+ release()
491
+ }
492
+ }
493
+
494
+ function listen(port) {
495
+ const server = createServer((req, res) => {
496
+ // Host 门 + Origin 门:Host 必须回环(防 DNS rebinding / LAN 直连),
497
+ // 带 Origin 时其 host:port 必须与 Host 一致(防跨站借浏览器烧额度)。
498
+ if (!isLoopbackHost(req.headers.host) || !isLoopbackOrigin(req)) {
499
+ req.resume()
500
+ res.writeHead(403, { 'Content-Type': 'application/json' })
501
+ res.end(JSON.stringify({ error: { message: 'forbidden: loopback host + same-origin required' } }))
502
+ return
503
+ }
504
+ // Buffer 收集 + 一次解码(踩坑 #28);超 32MB 答 413(同 core/bridge.js),
505
+ // 不静默 reset——调用方能拿到真实状态码。
506
+ const chunks = []
507
+ let received = 0
508
+ let oversize = false
509
+ req.on('data', (c) => {
510
+ if (oversize) return
511
+ chunks.push(c)
512
+ received += c.length
513
+ if (received > 32 * 1024 * 1024) {
514
+ oversize = true
515
+ chunks.length = 0
516
+ res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' })
517
+ res.end(JSON.stringify({ error: { message: 'request body too large (limit 32MiB)' } }))
518
+ }
519
+ })
520
+ req.on('end', () => {
521
+ if (oversize) return
522
+ const rawBody = Buffer.concat(chunks).toString('utf8')
523
+ const path = req.url?.split('?')[0] ?? ''
524
+ try {
525
+ if (req.method === 'POST' && (path === '/v1/chat/completions' || path === '/chat/completions')) {
526
+ handleChat(req, res, rawBody).catch(() => { if (!res.headersSent) { res.writeHead(500); res.end() } })
527
+ return
528
+ }
529
+ if (req.method === 'GET' && (path === '/v1/models' || path === '/models')) {
530
+ res.writeHead(200, { 'Content-Type': 'application/json' })
531
+ res.end(JSON.stringify({
532
+ object: 'list',
533
+ data: (deps.getCatalogProfiles() ?? []).map((p) => ({ id: p.id, object: 'model', created: Math.floor(Date.now() / 1000) })),
534
+ }))
535
+ return
536
+ }
537
+ res.writeHead(404, { 'Content-Type': 'application/json' })
538
+ res.end(JSON.stringify({ error: { message: `no route: ${req.method} ${path}` } }))
539
+ } catch (err) {
540
+ if (!res.headersSent) res.writeHead(500)
541
+ res.end(String(err?.message ?? err))
542
+ }
543
+ })
544
+ })
545
+ server.on('error', (err) => {
546
+ deps.runtime.running = false
547
+ deps.runtime.lastError = err?.code ?? String(err?.message ?? err)
548
+ process.stderr.write(`${logPrefix} gateway :${port} unavailable: ${deps.runtime.lastError}(Qoder 分区其余功能不受影响)\n`)
549
+ })
550
+ server.on('listening', () => {
551
+ deps.runtime.port = server.address()?.port ?? port
552
+ deps.runtime.running = true
553
+ deps.runtime.lastError = null
554
+ })
555
+ server.listen(port, '127.0.0.1')
556
+ return () => new Promise((resolve) => {
557
+ server.close(() => resolve())
558
+ server.closeAllConnections?.()
559
+ })
560
+ }
561
+
562
+ return { listen, handleChat }
563
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * providers/qoder/index.js — Qoder CN(qoder.cn)薄适配器工厂。
3
+ *
4
+ * 与 providers/trae/ 同构:本目录收敛全部 Qoder 上游事实——
5
+ * oauth.js 设备流(PKCE S256,poll 404=未完成,refresh drt- 前缀)
6
+ * cosy.js COSY WASM 签名运行时(手写胶水 + 官方 wasm 字节)
7
+ * catalog.js /algo/api/v2/model/list 目录(签名 GET,明文/密文两态)
8
+ * gateway.js OpenAI ↔ COSY SSE 翻译网关(:3903 默认)
9
+ *
10
+ * 规则/证据文档:docs/goals/qoder-cn-provider-design.md(2026-09-20 修订:
11
+ * 聊天面 = prepareInferRequest → agent_chat_generation,OpenAI 面
12
+ * api2-v2.qoder.sh 对本账号 401 不可用,不要再走)。
13
+ */
14
+
15
+ import { fileURLToPath } from 'node:url'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ import { createQoderOAuth } from './oauth.js'
19
+ import { createCosyRuntime } from './cosy.js'
20
+ import { fetchQoderCatalog } from './catalog.js'
21
+ import { createQoderGateway } from './gateway.js'
22
+ import { createQoderQuota } from './quota.js'
23
+
24
+ export const QODER_PROVIDER_ID = 'qoder'
25
+
26
+ const WASM_PATH = join(dirname(fileURLToPath(import.meta.url)), 'qoder_auth.wasm')
27
+
28
+ /**
29
+ * @param {{
30
+ * readAuth: () => object,
31
+ * writeAuth: (v: object) => void,
32
+ * settings: () => object,
33
+ * meter: { record: Function },
34
+ * runtime: { running: boolean, port: number|null, lastError: string|null },
35
+ * forensics?: { logPath: () => string|undefined },
36
+ * getModelPrefs?: () => object, // { [id]: { effort?, contextVariant? } } 出站补默认
37
+ * }} deps
38
+ */
39
+ export function createQoderProvider(deps) {
40
+ const oauth = createQoderOAuth({ readAuth: deps.readAuth, writeAuth: deps.writeAuth })
41
+ const cosy = createCosyRuntime({ wasmPath: WASM_PATH })
42
+
43
+ // 目录实例状态(模块作用域每插件实例一份,踩坑 #20 纪律)。
44
+ let catalogState = null // { profiles, sources, variants, fetchedAt }
45
+ // 单飞:并发 syncCatalog 共享同一在飞请求——并发时互相完整覆盖 catalogState
46
+ // 不损坏但浪费上游请求;codebuddy 侧在组合根单飞(index.js),口径对齐于此。
47
+ let syncInFlight = null
48
+
49
+ const provider = {
50
+ id: QODER_PROVIDER_ID,
51
+ oauth,
52
+ cosy,
53
+
54
+ /**
55
+ * 从网关同步目录(单飞:并发调用共享同一在飞 Promise)。返回
56
+ * {ok, count|error, kept};未登录时报 ok:false 且不清旧目录。
57
+ */
58
+ async syncCatalog() {
59
+ if (syncInFlight) return syncInFlight
60
+ const run = (async () => {
61
+ const s = deps.settings()
62
+ let cred
63
+ try {
64
+ cred = await oauth.resolveQoderCredential(s)
65
+ } catch (err) {
66
+ return { ok: false, error: err?.message ?? String(err), kept: catalogState != null }
67
+ }
68
+ if (!cred) return { ok: false, error: '未登录(先完成 Qoder 浏览器授权)', kept: catalogState != null }
69
+ try {
70
+ const accessToken = String(cred.authorization).replace(/^Bearer\s+/, '')
71
+ const result = await fetchQoderCatalog(cosy, { accessToken, machineId: cred.machineId, uid: cred.uid }, s.qoderInferBaseURL)
72
+ catalogState = { profiles: result.profiles, sources: result.sources, variants: result.variants, efforts: result.efforts, entries: result.entries, fetchedAt: Date.now() }
73
+ return { ok: true, count: result.profiles.length, fetchedAt: catalogState.fetchedAt }
74
+ } catch (err) {
75
+ return { ok: false, error: err?.message ?? String(err), kept: catalogState != null }
76
+ }
77
+ })()
78
+ syncInFlight = run
79
+ try {
80
+ return await run
81
+ } finally {
82
+ if (syncInFlight === run) syncInFlight = null
83
+ }
84
+ },
85
+
86
+ catalogView() {
87
+ return catalogState
88
+ ? { at: catalogState.fetchedAt, count: catalogState.profiles.length, profiles: catalogState.profiles, variants: catalogState.variants, efforts: catalogState.efforts }
89
+ : null
90
+ },
91
+
92
+ /** 凭据视图(设置卡)。 */
93
+ credentialView() {
94
+ return oauth.oauthStatus()
95
+ },
96
+
97
+ /** 账户配额只读快照(60s memoize,永不 throw)。 */
98
+ quota: createQoderQuota({ settings: deps.settings, oauth }),
99
+ }
100
+
101
+ const gateway = createQoderGateway({
102
+ settings: deps.settings,
103
+ resolveCredential: (s) => oauth.resolveQoderCredential(s),
104
+ cosy,
105
+ meter: deps.meter,
106
+ runtime: deps.runtime,
107
+ forensics: deps.forensics,
108
+ getCatalogProfiles: () => catalogState?.profiles ?? null,
109
+ getCatalogEntry: (id) => catalogState?.entries?.[id] ?? null,
110
+ getModelSource: (id) => catalogState?.sources?.[id] ?? 'system',
111
+ getModelPrefs: deps.getModelPrefs ?? (() => ({})),
112
+ })
113
+ provider.gateway = gateway
114
+
115
+ return provider
116
+ }