@twinsearth/dsh-master 0.0.0-stage → 3.2.9
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/LICENSE +661 -0
- package/README.md +93 -2
- package/client.js +334 -0
- package/cordis.patch.yml +71 -0
- package/dist-host/contract/client.js +82 -0
- package/dist-host/contract/constants.js +70 -0
- package/dist-host/contract/envelope.js +84 -0
- package/dist-host/contract/index.js +17 -0
- package/dist-host/contract/negotiate.js +87 -0
- package/dist-host/contract/server.js +71 -0
- package/dist-host/contract/transports.js +360 -0
- package/dist-host/index.js +260 -0
- package/dist-host/kernel.js +186 -0
- package/dist-host/plugins/cost-optimizer.js +147 -0
- package/dist-host/plugins/dual-manager.js +251 -0
- package/dist-host/plugins/superagent-router.js +499 -0
- package/dist-host/plugins/superagent-worker.js +73 -0
- package/dist-host/superagent/bridge-tokens.js +236 -0
- package/dist-host/superagent/dispatch.js +198 -0
- package/dist-host/superagent/feishu.js +115 -0
- package/dist-host/superagent/github.js +119 -0
- package/dist-host/superagent/pr-delivery.js +351 -0
- package/dist-host/superagent/router.js +219 -0
- package/dist-host/superagent/stream-adapter.js +238 -0
- package/dist-host/superagent/stream-tokens.js +141 -0
- package/dist-host/superagent/ticket-store.js +108 -0
- package/dist-host/superagent/trust.js +145 -0
- package/dist-host/superagent/types.js +144 -0
- package/dist-host/superagent/worker.js +95 -0
- package/dist-host/superagent/writeback.js +162 -0
- package/dist-worker/agent-loop.js +493 -0
- package/dist-worker/errors.js +41 -0
- package/dist-worker/github-pr.js +44 -0
- package/dist-worker/index.js +323 -0
- package/dist-worker/llm-http.js +351 -0
- package/dist-worker/main.js +45 -0
- package/dist-worker/prompt.js +35 -0
- package/dist-worker/sink-http.js +135 -0
- package/dist-worker/tools.js +542 -0
- package/dist-worker/types.js +14 -0
- package/docs/ARCHITECTURE.md +114 -0
- package/docs/CHANGELOG.md +389 -0
- package/docs/INTEROP.md +60 -0
- package/docs/RELEASE-v3.2.8.md +27 -0
- package/docs/RELEASE-v3.2.9.md +29 -0
- package/docs/REVIEW-v3.2.7.md +121 -0
- package/docs/ROADMAP.md +106 -0
- package/docs/SECURITY.md +153 -0
- package/docs/SMOKE-3.2.5.md +77 -0
- package/docs/SMOKE-3.2.7.md +73 -0
- package/docs/SUPERAGENT.md +141 -0
- package/docs/WORKER-AGENT-LOOP.md +139 -0
- package/icon.svg +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/src/contract/client.js +86 -0
- package/src/contract/constants.js +79 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +19 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.ts +265 -0
- package/src/kernel.ts +190 -0
- package/src/plugins/cost-optimizer.ts +155 -0
- package/src/plugins/dual-manager.ts +252 -0
- package/src/plugins/superagent-router.ts +465 -0
- package/src/plugins/superagent-worker.ts +85 -0
- package/src/superagent/bridge-tokens.ts +236 -0
- package/src/superagent/dispatch.ts +197 -0
- package/src/superagent/feishu.ts +104 -0
- package/src/superagent/github.ts +111 -0
- package/src/superagent/pr-delivery.ts +308 -0
- package/src/superagent/router.ts +201 -0
- package/src/superagent/stream-adapter.ts +184 -0
- package/src/superagent/stream-tokens.ts +142 -0
- package/src/superagent/ticket-store.ts +104 -0
- package/src/superagent/trust.ts +139 -0
- package/src/superagent/types.ts +135 -0
- package/src/superagent/worker.ts +100 -0
- package/src/superagent/writeback.ts +154 -0
- package/tsconfig.host.build.json +12 -0
- package/tsconfig.host.json +21 -0
- package/tsconfig.worker.build.json +13 -0
- package/tsconfig.worker.json +17 -0
- package/types/superagent.d.ts +99 -0
- package/worker/agent-loop.ts +524 -0
- package/worker/errors.ts +47 -0
- package/worker/github-pr.ts +65 -0
- package/worker/index.ts +334 -0
- package/worker/llm-http.ts +359 -0
- package/worker/main.ts +45 -0
- package/worker/prompt.ts +39 -0
- package/worker/sink-http.ts +159 -0
- package/worker/tools.ts +531 -0
- package/worker/types.ts +247 -0
package/worker/index.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* dsh-superagent-worker preset 的 Cordis 入口(v3.2.3)。
|
|
6
|
+
*
|
|
7
|
+
* 运行在分身 VM 内。Router 派发时注入 `DSH_MASTER_TICKET`,同时注入:
|
|
8
|
+
* * `DSH_MASTER_STREAM_URL` / `DSH_MASTER_STREAM_TOKEN`:Worker→Router 实时事件通道;
|
|
9
|
+
* fork 宿主加载本 preset 后会自动跑一次 Agent Loop(可用 DSH_MASTER_WORKER_AUTORUN=0 关闭)。
|
|
10
|
+
*
|
|
11
|
+
* 角色安全:只允许 `DSH_FANG_ROLE=fork` 且非元宝运行态(`DSH_FANG_ESSENCE!=1`)执行;
|
|
12
|
+
* 在母体/元主即使误装也不会运行 Worker。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { TicketPayload, WorkerDeps, WorkerResult, WorkerSink } from './types.ts'
|
|
16
|
+
import { SuperAgentWorker, normalizeTicket } from './agent-loop.ts'
|
|
17
|
+
import { createSandboxTools } from './tools.ts'
|
|
18
|
+
import { createHttpLlmClient } from './llm-http.ts'
|
|
19
|
+
import { createHttpSink } from './sink-http.ts'
|
|
20
|
+
import { createGithubPrClient } from './github-pr.ts'
|
|
21
|
+
|
|
22
|
+
export const name = 'dsh-master-superagent-loop'
|
|
23
|
+
// 依赖房主核心服务:Cordis 会等 dshMaster 就绪后再 apply,保证本行能注册到核心。
|
|
24
|
+
export const inject = ['dshMaster']
|
|
25
|
+
|
|
26
|
+
export interface CordisLoggerLike {
|
|
27
|
+
info?(message: string): void
|
|
28
|
+
warn?(message: string): void
|
|
29
|
+
error?(message: string): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CordisLike {
|
|
33
|
+
logger?: CordisLoggerLike
|
|
34
|
+
llm?: { chat(request: unknown): Promise<unknown> }
|
|
35
|
+
tools?: { register?(tool: unknown): unknown; getSchemas?(names: string[]): unknown[]; execute?(name: string, args: unknown): unknown }
|
|
36
|
+
get?(name: string): unknown
|
|
37
|
+
on?(event: string, handler: (payload: unknown) => void): unknown
|
|
38
|
+
emit?(event: string, payload: unknown): void
|
|
39
|
+
effect?(dispose: () => void): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorkerEnvOptions {
|
|
43
|
+
env?: Record<string, string | undefined>
|
|
44
|
+
workspace?: string
|
|
45
|
+
model?: string
|
|
46
|
+
llm?: WorkerDeps['llm']
|
|
47
|
+
limits?: WorkerDeps['limits']
|
|
48
|
+
sink?: WorkerSink
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let autoStartedTicket = ''
|
|
52
|
+
|
|
53
|
+
function envOf(options: WorkerEnvOptions): Record<string, string | undefined> {
|
|
54
|
+
return options.env ?? process.env
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* VM 隔离证明:只有明确声明 `DSH_MASTER_VM_ISOLATION=attested` 才允许 Shell(P0-2 fail-closed)。
|
|
59
|
+
* 瓮城在已证明隔离的分身里注入该变量;普通进程不会自动获得。
|
|
60
|
+
*/
|
|
61
|
+
export function vmIsolationAttested(env: Record<string, string | undefined>): boolean {
|
|
62
|
+
return String(env.DSH_MASTER_VM_ISOLATION ?? '').trim().toLowerCase() === 'attested'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function prClientFromEnv(env: Record<string, string | undefined>) {
|
|
66
|
+
const token = String(env.DSH_MASTER_GITHUB_TOKEN ?? '').trim()
|
|
67
|
+
const repo = String(env.DSH_MASTER_GITHUB_REPO ?? '').trim()
|
|
68
|
+
if (!token || !repo) return undefined
|
|
69
|
+
return createGithubPrClient({ token, repo, apiBase: env.DSH_MASTER_GITHUB_API_BASE, baseBranch: env.DSH_MASTER_GITHUB_BASE })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function consoleLogger(): WorkerDeps['logger'] {
|
|
73
|
+
return {
|
|
74
|
+
info: (message: string) => { process.stderr.write(message + '\n') },
|
|
75
|
+
warn: (message: string) => { process.stderr.write('[warn] ' + message + '\n') },
|
|
76
|
+
error: (message: string) => { process.stderr.write('[error] ' + message + '\n') },
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 收集需要从日志/步骤中抹掉的具体 secret 值(key 只在 Worker 进程内使用)。 */
|
|
81
|
+
export function secretValuesOf(env: Record<string, string | undefined>): string[] {
|
|
82
|
+
// v3.2.5:不只列白名单——任何形如 *_API_KEY / *_TOKEN / *_SECRET / *_PASSWORD 的长值
|
|
83
|
+
// 都进脱敏表,避免新增凭据(DSH_MASTER_LLM_API_KEY / DSH_MASTER_GITHUB_TOKEN)漏脱敏。
|
|
84
|
+
const NAMED = [
|
|
85
|
+
'WORKER_LLM_API_KEY',
|
|
86
|
+
'DSH_MASTER_LLM_API_KEY',
|
|
87
|
+
'DSH_MASTER_STREAM_TOKEN',
|
|
88
|
+
'DSH_MASTER_GITHUB_TOKEN',
|
|
89
|
+
'DSH_ADAPTER_TOKEN',
|
|
90
|
+
]
|
|
91
|
+
const values = new Set<string>()
|
|
92
|
+
for (const key of NAMED) {
|
|
93
|
+
const value = env[key]
|
|
94
|
+
if (typeof value === 'string' && value.length >= 8) values.add(value)
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(env)) {
|
|
97
|
+
if (typeof value !== 'string' || value.length < 8) continue
|
|
98
|
+
if (!/(_API_KEY|_APIKEY|_TOKEN|_SECRET|_PASSWORD)$/i.test(key)) continue
|
|
99
|
+
values.add(value)
|
|
100
|
+
}
|
|
101
|
+
return [...values]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从环境变量构造 Worker 依赖;缺 Ticket/LLM 时抛可读错误。 */
|
|
105
|
+
export function buildWorkerDeps(options: WorkerEnvOptions = {}): { ticket: TicketPayload; deps: WorkerDeps } {
|
|
106
|
+
const env = envOf(options)
|
|
107
|
+
const raw = env.DSH_MASTER_TICKET
|
|
108
|
+
if (!raw) throw new Error('DSH_MASTER_TICKET is required to start a SuperAgent Worker')
|
|
109
|
+
let parsed: unknown
|
|
110
|
+
try {
|
|
111
|
+
parsed = parseTicketEnv(raw)
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error('DSH_MASTER_TICKET is not valid JSON')
|
|
114
|
+
}
|
|
115
|
+
const ticket = normalizeTicket(parsed as Record<string, unknown>)
|
|
116
|
+
const workspace = String(options.workspace ?? env.WORKER_WORKSPACE ?? env.DSH_MASTER_WORKSPACE ?? process.cwd())
|
|
117
|
+
const llm = options.llm ?? createHttpLlmClient({
|
|
118
|
+
baseUrl: String(env.WORKER_LLM_BASE_URL ?? env.DSH_MASTER_LLM_BASE_URL ?? ''),
|
|
119
|
+
apiKey: env.WORKER_LLM_API_KEY ?? env.DSH_MASTER_LLM_API_KEY,
|
|
120
|
+
model: String(options.model ?? env.WORKER_LLM_MODEL ?? env.DSH_MASTER_LLM_MODEL ?? 'deepseek-chat'),
|
|
121
|
+
timeoutMs: Number(env.WORKER_LLM_TIMEOUT_MS ?? 120_000),
|
|
122
|
+
})
|
|
123
|
+
const githubToken = String(env.DSH_MASTER_GITHUB_TOKEN ?? '').trim()
|
|
124
|
+
const githubRepo = String(env.DSH_MASTER_GITHUB_REPO ?? '').trim()
|
|
125
|
+
const prClient = githubToken && githubRepo
|
|
126
|
+
? createGithubPrClient({ token: githubToken, repo: githubRepo, apiBase: env.DSH_MASTER_GITHUB_API_BASE, baseBranch: env.DSH_MASTER_GITHUB_BASE })
|
|
127
|
+
: undefined
|
|
128
|
+
const tools = createSandboxTools({ workspace, mandate: ticket.mandate, prClient, vmIsolation: vmIsolationAttested(env) })
|
|
129
|
+
return {
|
|
130
|
+
ticket,
|
|
131
|
+
deps: {
|
|
132
|
+
llm,
|
|
133
|
+
tools,
|
|
134
|
+
workspace,
|
|
135
|
+
mandate: ticket.mandate,
|
|
136
|
+
logger: options.sink ? undefined : consoleLogger(),
|
|
137
|
+
sink: options.sink,
|
|
138
|
+
limits: options.limits,
|
|
139
|
+
model: String(options.model ?? env.WORKER_LLM_MODEL ?? '') || undefined,
|
|
140
|
+
redactValues: secretValuesOf(env),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cordisSink(ctx: CordisLike): WorkerSink {
|
|
146
|
+
return {
|
|
147
|
+
log: (message: string) => {
|
|
148
|
+
try { ctx.logger?.info?.(message) } catch { /* ignore */ }
|
|
149
|
+
try { ctx.emit?.('superagent:log', { message, at: Date.now() }) } catch { /* ignore */ }
|
|
150
|
+
},
|
|
151
|
+
step: (step) => { try { ctx.emit?.('superagent:step', step) } catch { /* ignore */ } },
|
|
152
|
+
phase: (phase) => { try { ctx.emit?.('superagent:phase', { phase, at: Date.now() }) } catch { /* ignore */ } },
|
|
153
|
+
stream: (delta) => { try { ctx.emit?.('superagent:stream', delta) } catch { /* ignore */ } },
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 组合 sink:Cordis 事件 + Router HTTP 实时通道(任一失败不影响另一个)。 */
|
|
158
|
+
function composeSink(local: WorkerSink, remote: WorkerSink | null): WorkerSink {
|
|
159
|
+
if (!remote) return local
|
|
160
|
+
const wrap = <K extends keyof WorkerSink>(key: K) => {
|
|
161
|
+
const localFn = local[key]
|
|
162
|
+
const remoteFn = remote[key]
|
|
163
|
+
if (!localFn && !remoteFn) return undefined
|
|
164
|
+
return ((...args: Parameters<NonNullable<WorkerSink[K]>>) => {
|
|
165
|
+
try { (localFn as ((...a: unknown[]) => void) | undefined)?.(...args) } catch { /* ignore */ }
|
|
166
|
+
try { (remoteFn as ((...a: unknown[]) => void) | undefined)?.(...args) } catch { /* ignore */ }
|
|
167
|
+
}) as NonNullable<WorkerSink[K]>
|
|
168
|
+
}
|
|
169
|
+
return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream') }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 归档真实 token 用量到 fork 内的 HarnessMM observe 服务;无该服务时不影响 Worker。 */
|
|
173
|
+
export function recordWorkerCost(ctx: CordisLike, ticketId: string, result: WorkerResult): unknown {
|
|
174
|
+
try {
|
|
175
|
+
const cost = ctx.get?.('dshMasterCost') as { record?(input: Record<string, unknown>): unknown } | undefined
|
|
176
|
+
if (!cost || typeof cost.record !== 'function') return { ok: false, code: 'cost-service-unavailable' }
|
|
177
|
+
const tokens = Math.max(0, Number(result.tokenUsage ?? 0) || 0)
|
|
178
|
+
// 尚未接入 EGD/GDR 优化,优化前后相等,绝不虚报节省;只记录真实 token 与成功率。
|
|
179
|
+
return cost.record({ taskId: ticketId, baselineTokens: tokens, optimizedTokens: tokens, success: result.ok === true })
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return { ok: false, code: 'cost-record-failed', error: String(error instanceof Error ? error.message : error) }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 跑一次 Worker(不依赖 Cordis;供 main.ts / 宿主/测试复用)。 */
|
|
186
|
+
export async function runWorker(ticket: TicketPayload, deps: WorkerDeps): Promise<WorkerResult> {
|
|
187
|
+
const worker = new SuperAgentWorker(deps)
|
|
188
|
+
return await worker.execute(ticket)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 宿主服务查找(房主核心存在时才注册子插件;独立 fork 无核心也能跑循环)。 */
|
|
192
|
+
function masterOf(ctx: CordisLike): { registerSubplugin?: (descriptor: Record<string, unknown>) => unknown } | null {
|
|
193
|
+
try {
|
|
194
|
+
const candidate = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
|
195
|
+
? (ctx as { get: (name: string) => unknown }).get('dshMaster')
|
|
196
|
+
: null
|
|
197
|
+
return candidate && typeof candidate === 'object' ? candidate as never : null
|
|
198
|
+
} catch {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** bundle 行的空字符串视为未配置(否则会覆盖 env 默认值)。 */
|
|
204
|
+
function optionalOf(value: unknown): string | undefined {
|
|
205
|
+
const text = String(value ?? '').trim()
|
|
206
|
+
return text ? text : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 环境变量里的 Ticket JSON:剥离 BOM/空白后再解析(Windows 脚本写入常见 BOM)。 */
|
|
210
|
+
export function parseTicketEnv(raw: string): unknown {
|
|
211
|
+
return JSON.parse(String(raw ?? '').replace(/^\uFEFF/, '').trim())
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface Config {
|
|
215
|
+
/** bundle 行配置:`enabled: false` 时不自动执行(仍保留事件入口)。 */
|
|
216
|
+
enabled?: boolean
|
|
217
|
+
/** 是否在检测到 Ticket 后自动跑循环;默认 true(可用 DSH_MASTER_WORKER_AUTORUN=0 关闭)。 */
|
|
218
|
+
autorun?: boolean
|
|
219
|
+
/** 覆盖 LLM 模型名(默认取 env)。 */
|
|
220
|
+
model?: string
|
|
221
|
+
/** 覆盖工作区(默认取 env / cwd)。 */
|
|
222
|
+
workspace?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Cordis preset 入口:有 Ticket 的 fork 自动跑 Worker;没有 Ticket 时只等待事件。
|
|
227
|
+
* 返回 {ready, autoRun, reason} 便于宿主/测试检查。
|
|
228
|
+
*/
|
|
229
|
+
export function apply(ctx: CordisLike, config: Config = {}): { ready: boolean; autoRun: boolean; reason?: string } {
|
|
230
|
+
if (config && config.enabled === false) return { ready: false, autoRun: false, reason: 'disabled-by-config' }
|
|
231
|
+
const env = process.env
|
|
232
|
+
const raw = env.DSH_MASTER_TICKET
|
|
233
|
+
const emitError = (message: string, extra: Record<string, unknown> = {}) => {
|
|
234
|
+
try { ctx.logger?.error?.(message) } catch { /* ignore */ }
|
|
235
|
+
try { ctx.emit?.('superagent:worker:error', { error: message, ...extra }) } catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 角色安全:Worker preset 只允许 fork(非元宝运行态)。与房主 ROLE_MATRIX 的
|
|
239
|
+
// master-superagent-loop 行一致,避免宿主状态把“跑过循环却显示未激活”。
|
|
240
|
+
const role = String(env.DSH_FANG_ROLE ?? '').trim()
|
|
241
|
+
const essence = String(env.DSH_FANG_ESSENCE ?? '') === '1' || String(env.DSH_FANG_TASK_ID ?? '').startsWith('rt-')
|
|
242
|
+
const effectiveRole = role === 'fork' ? (essence ? 'essence' : 'fork') : (essence ? 'essence' : (role || 'mother'))
|
|
243
|
+
if (effectiveRole !== 'fork') {
|
|
244
|
+
return { ready: false, autoRun: false, reason: `role-not-allowed:${effectiveRole}` }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 向房主核心注册本行(核心存在时;重复注册不应打断 preset)。
|
|
248
|
+
try {
|
|
249
|
+
const master = masterOf(ctx)
|
|
250
|
+
if (master && typeof master.registerSubplugin === 'function') {
|
|
251
|
+
master.registerSubplugin({
|
|
252
|
+
id: 'superagent-loop',
|
|
253
|
+
name: 'dsh-master-superagent-loop',
|
|
254
|
+
capabilities: ['worker-agent-loop', 'llm-tool-loop', 'streaming-events', 'result-writeback'],
|
|
255
|
+
description: raw ? 'Worker Agent Loop 已接管工单(fork)' : 'Worker Agent Loop 就绪(等待工单)',
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
} catch { /* 已注册/无核心:不影响循环 */ }
|
|
259
|
+
|
|
260
|
+
if (!raw) {
|
|
261
|
+
// 普通 fork / 其他角色:只保留事件监听入口,不自动执行。
|
|
262
|
+
try {
|
|
263
|
+
const off = ctx.on?.('superagent:worker:start', (payload) => {
|
|
264
|
+
void (async () => {
|
|
265
|
+
const ticket = normalizeTicket(payload as Record<string, unknown>)
|
|
266
|
+
const { deps } = buildWorkerDeps({ sink: cordisSink(ctx) })
|
|
267
|
+
const result = await runWorker(ticket, deps)
|
|
268
|
+
try { ctx.emit?.('superagent:worker:done', { ticketId: ticket.ticketId, result }) } catch { /* ignore */ }
|
|
269
|
+
})().catch((error) => emitError(error instanceof Error ? error.message : String(error)))
|
|
270
|
+
})
|
|
271
|
+
if (typeof off === 'function') ctx.effect?.(off as () => void)
|
|
272
|
+
} catch { /* ignore */ }
|
|
273
|
+
return { ready: true, autoRun: false, reason: 'no-ticket' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const autorun = config.autorun === false ? false : env.DSH_MASTER_WORKER_AUTORUN !== '0'
|
|
277
|
+
if (!autorun) return { ready: true, autoRun: false, reason: 'autorun-disabled' }
|
|
278
|
+
|
|
279
|
+
let ticket: TicketPayload
|
|
280
|
+
try {
|
|
281
|
+
ticket = normalizeTicket(parseTicketEnv(raw) as Record<string, unknown>)
|
|
282
|
+
} catch (error) {
|
|
283
|
+
emitError(`worker ticket invalid: ${error instanceof Error ? error.message : String(error)}`)
|
|
284
|
+
return { ready: false, autoRun: false, reason: 'ticket-invalid' }
|
|
285
|
+
}
|
|
286
|
+
if (autoStartedTicket === ticket.ticketId) return { ready: true, autoRun: false, reason: 'already-started' }
|
|
287
|
+
autoStartedTicket = ticket.ticketId
|
|
288
|
+
|
|
289
|
+
const remoteSink = (env.DSH_MASTER_STREAM_URL && env.DSH_MASTER_STREAM_TOKEN)
|
|
290
|
+
? createHttpSink({
|
|
291
|
+
url: String(env.DSH_MASTER_STREAM_URL),
|
|
292
|
+
token: String(env.DSH_MASTER_STREAM_TOKEN),
|
|
293
|
+
ticketId: ticket.ticketId,
|
|
294
|
+
streamFlushMs: Number(optionalOf(env.DSH_MASTER_STREAM_FLUSH_MS)) || undefined,
|
|
295
|
+
log: (message) => { try { ctx.logger?.warn?.(message) } catch { /* ignore */ } },
|
|
296
|
+
})
|
|
297
|
+
: null
|
|
298
|
+
const sink = composeSink(cordisSink(ctx), remoteSink)
|
|
299
|
+
|
|
300
|
+
let deps: WorkerDeps
|
|
301
|
+
try {
|
|
302
|
+
deps = buildWorkerDeps({ sink, model: optionalOf(config.model), workspace: optionalOf(config.workspace) }).deps
|
|
303
|
+
} catch (error) {
|
|
304
|
+
autoStartedTicket = ''
|
|
305
|
+
emitError(`worker dependency failed: ${error instanceof Error ? error.message : String(error)}`, { ticketId: ticket.ticketId })
|
|
306
|
+
return { ready: false, autoRun: false, reason: 'deps-invalid' }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// v3.2.7(A 治根之二):fork 的 Host 启动期会抢占 CPU,导致已经按 260ms 节奏到达的
|
|
310
|
+
// SSE 分片在 socket 里积压后一次性排空(前端只能看到"跳变")。这里在进入循环前先让
|
|
311
|
+
// 宿主把插件加载/首帧渲染完成(默认 1200ms,可用 DSH_MASTER_WARMUP_MS 覆盖,0=关闭)。
|
|
312
|
+
const warmupMs = Math.max(0, Number(optionalOf(env.DSH_MASTER_WARMUP_MS) ?? 1200) || 0)
|
|
313
|
+
const warmup = warmupMs > 0 ? new Promise((resolve) => setTimeout(resolve, warmupMs)) : Promise.resolve()
|
|
314
|
+
|
|
315
|
+
void warmup
|
|
316
|
+
.then(() => runWorker(ticket, deps))
|
|
317
|
+
.then(async (result) => {
|
|
318
|
+
autoStartedTicket = ''
|
|
319
|
+
try { await remoteSink?.done(result) } catch { /* ignore */ }
|
|
320
|
+
try { await remoteSink?.close() } catch { /* ignore */ }
|
|
321
|
+
try { recordWorkerCost(ctx, ticket.ticketId, result) } catch { /* ignore */ }
|
|
322
|
+
try { ctx.emit?.('superagent:worker:done', { ticketId: ticket.ticketId, result }) } catch { /* ignore */ }
|
|
323
|
+
try {
|
|
324
|
+
ctx.logger?.info?.(`[worker] ${ticket.ticketId} ${result.ok ? 'done' : 'failed'} in ${result.elapsedMs}ms`)
|
|
325
|
+
} catch { /* ignore */ }
|
|
326
|
+
})
|
|
327
|
+
.catch((error) => {
|
|
328
|
+
autoStartedTicket = ''
|
|
329
|
+
emitError(error instanceof Error ? error.message : String(error), { ticketId: ticket.ticketId })
|
|
330
|
+
})
|
|
331
|
+
return { ready: true, autoRun: true }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export type { WorkerResult, WorkerDeps, TicketPayload }
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI 兼容 Chat Completions 的 LLM 适配器(v3.2.2)。
|
|
6
|
+
*
|
|
7
|
+
* Worker 运行在分身 VM 内,通过环境变量拿到模型端点/凭据;本适配器不做任何
|
|
8
|
+
* 日志打印或错误回显中的 key 泄露。Provider 只负责“模型调用”,工具执行仍在
|
|
9
|
+
* Worker 本地(沙箱内)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChatMessage, LLMClient, LLMRequest, LLMResponse, LLMStreamDelta, ToolCall } from './types.ts'
|
|
13
|
+
|
|
14
|
+
export interface HttpLlmOptions {
|
|
15
|
+
/** 例如 https://api.deepseek.com/v1;会拼接 /chat/completions。 */
|
|
16
|
+
baseUrl: string
|
|
17
|
+
apiKey?: string
|
|
18
|
+
model: string
|
|
19
|
+
timeoutMs?: number
|
|
20
|
+
headers?: Record<string, string>
|
|
21
|
+
fetchImpl?: typeof fetch
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OpenAiToolCall {
|
|
25
|
+
id?: string
|
|
26
|
+
type?: string
|
|
27
|
+
function?: { name?: string; arguments?: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OpenAiMessage {
|
|
31
|
+
content?: string | null
|
|
32
|
+
tool_calls?: OpenAiToolCall[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface OpenAiResponse {
|
|
36
|
+
choices?: Array<{ message?: OpenAiMessage; finish_reason?: string }>
|
|
37
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
|
38
|
+
error?: { message?: string }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function endpointOf(baseUrl: string): string {
|
|
42
|
+
const trimmed = String(baseUrl ?? '').trim().replace(/\/+$/, '')
|
|
43
|
+
if (!trimmed) throw new Error('WORKER_LLM_BASE_URL is required')
|
|
44
|
+
return trimmed.endsWith('/chat/completions') ? trimmed : `${trimmed}/chat/completions`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toWireMessages(messages: ChatMessage[]): unknown[] {
|
|
48
|
+
return messages.map((message) => {
|
|
49
|
+
if (message.role === 'tool') {
|
|
50
|
+
return { role: 'tool', tool_call_id: message.tool_call_id, content: message.content ?? '' }
|
|
51
|
+
}
|
|
52
|
+
if (message.role === 'assistant' && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
|
|
53
|
+
return {
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: message.content ?? null,
|
|
56
|
+
tool_calls: message.tool_calls.map((call) => ({
|
|
57
|
+
id: call.id,
|
|
58
|
+
type: 'function',
|
|
59
|
+
function: { name: call.function.name, arguments: call.function.arguments },
|
|
60
|
+
})),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { role: message.role, content: message.content ?? '' }
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fromWireResponse(payload: OpenAiResponse): LLMResponse {
|
|
68
|
+
const choice = payload?.choices?.[0]
|
|
69
|
+
const message = choice?.message ?? {}
|
|
70
|
+
const calls: ToolCall[] = Array.isArray(message.tool_calls)
|
|
71
|
+
? message.tool_calls.map((call, index) => ({
|
|
72
|
+
id: String(call?.id ?? `call_${index + 1}`),
|
|
73
|
+
type: 'function',
|
|
74
|
+
function: {
|
|
75
|
+
name: String(call?.function?.name ?? ''),
|
|
76
|
+
arguments: String(call?.function?.arguments ?? '{}'),
|
|
77
|
+
},
|
|
78
|
+
}))
|
|
79
|
+
: []
|
|
80
|
+
return {
|
|
81
|
+
message: {
|
|
82
|
+
role: 'assistant',
|
|
83
|
+
content: message.content ?? '',
|
|
84
|
+
tool_calls: calls.length > 0 ? calls : undefined,
|
|
85
|
+
},
|
|
86
|
+
usage: payload?.usage
|
|
87
|
+
? {
|
|
88
|
+
promptTokens: Number(payload.usage.prompt_tokens ?? 0),
|
|
89
|
+
completionTokens: Number(payload.usage.completion_tokens ?? 0),
|
|
90
|
+
totalTokens: Number(payload.usage.total_tokens ?? 0),
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
93
|
+
finishReason: choice?.finish_reason,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface StreamToolCallState {
|
|
98
|
+
id?: string
|
|
99
|
+
name?: string
|
|
100
|
+
arguments: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 从一行 SSE 文本里取出 data 负载;返回 null 表示控制行。 */
|
|
104
|
+
function sseDataOf(line: string): string | null {
|
|
105
|
+
const trimmed = line.trim()
|
|
106
|
+
if (!trimmed || trimmed.startsWith(':')) return null
|
|
107
|
+
if (!trimmed.startsWith('data:')) return null
|
|
108
|
+
return trimmed.slice(5).trim()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 解析 OpenAI 兼容的流式 chunk 并聚合为完整响应。
|
|
113
|
+
* 同时把 content 增量通过 onDelta 实时回调;tool_calls 按 index 聚合片段。
|
|
114
|
+
*/
|
|
115
|
+
function createStreamAccumulator(onDelta: (delta: LLMStreamDelta) => void) {
|
|
116
|
+
let content = ''
|
|
117
|
+
let finishReason: string | undefined
|
|
118
|
+
let usage: LLMResponse['usage']
|
|
119
|
+
const calls: StreamToolCallState[] = []
|
|
120
|
+
let seenToolName = ''
|
|
121
|
+
|
|
122
|
+
const applyChunk = (chunk: Record<string, unknown>): boolean => {
|
|
123
|
+
const choices = Array.isArray(chunk?.choices) ? chunk.choices as Array<Record<string, unknown>> : []
|
|
124
|
+
const choice = choices[0] ?? null
|
|
125
|
+
if (choice?.delta && typeof choice.delta === 'object') {
|
|
126
|
+
const delta = choice.delta as Record<string, unknown>
|
|
127
|
+
const piece = typeof delta.content === 'string' ? delta.content : ''
|
|
128
|
+
if (piece) {
|
|
129
|
+
content += piece
|
|
130
|
+
try { onDelta({ content: piece }) } catch { /* 回调异常不得打断流 */ }
|
|
131
|
+
}
|
|
132
|
+
const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls as Array<Record<string, unknown>> : []
|
|
133
|
+
for (const item of toolDeltas) {
|
|
134
|
+
const index = Number(item?.index ?? calls.length)
|
|
135
|
+
const slot = calls[index] ?? (calls[index] = { arguments: '' })
|
|
136
|
+
const fn = (item?.function ?? {}) as Record<string, unknown>
|
|
137
|
+
if (typeof item?.id === 'string' && item.id) slot.id = item.id
|
|
138
|
+
if (typeof fn.name === 'string' && fn.name) slot.name = fn.name
|
|
139
|
+
if (typeof fn.arguments === 'string' && fn.arguments) slot.arguments += fn.arguments
|
|
140
|
+
const name = slot.name ?? ''
|
|
141
|
+
if (name && name !== seenToolName) {
|
|
142
|
+
seenToolName = name
|
|
143
|
+
try { onDelta({ toolName: name }) } catch { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (typeof choice?.finish_reason === 'string') finishReason = choice.finish_reason
|
|
148
|
+
if (chunk?.usage && typeof chunk.usage === 'object') {
|
|
149
|
+
const raw = chunk.usage as Record<string, unknown>
|
|
150
|
+
usage = {
|
|
151
|
+
promptTokens: Number(raw.prompt_tokens ?? 0),
|
|
152
|
+
completionTokens: Number(raw.completion_tokens ?? 0),
|
|
153
|
+
totalTokens: Number(raw.total_tokens ?? 0),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Boolean(chunk?.choices || chunk?.usage)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
applyChunk,
|
|
161
|
+
/** 返回聚合后的 LLMResponse(tool_calls 只保留有 name 的)。 */
|
|
162
|
+
result(): LLMResponse {
|
|
163
|
+
const toolCalls: ToolCall[] = calls
|
|
164
|
+
.filter((slot) => Boolean(slot && slot.name))
|
|
165
|
+
.map((slot, index) => ({
|
|
166
|
+
id: String(slot.id ?? `call_${index + 1}`),
|
|
167
|
+
type: 'function',
|
|
168
|
+
function: { name: String(slot.name ?? ''), arguments: slot.arguments || '{}' },
|
|
169
|
+
}))
|
|
170
|
+
return {
|
|
171
|
+
message: {
|
|
172
|
+
role: 'assistant',
|
|
173
|
+
content,
|
|
174
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
175
|
+
},
|
|
176
|
+
usage,
|
|
177
|
+
finishReason,
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
get sawChunk(): boolean {
|
|
181
|
+
return content.length > 0 || calls.length > 0 || finishReason !== undefined
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 逐块读取响应体并喂给 SSE 解析器;返回是否识别到任何 SSE 数据。 */
|
|
187
|
+
async function consumeSse(body: ReadableStream<Uint8Array> | null, accumulator: ReturnType<typeof createStreamAccumulator>): Promise<boolean> {
|
|
188
|
+
if (!body || typeof body.getReader !== 'function') return false
|
|
189
|
+
const reader = body.getReader()
|
|
190
|
+
const decoder = new TextDecoder()
|
|
191
|
+
let buffer = ''
|
|
192
|
+
let sawData = false
|
|
193
|
+
try {
|
|
194
|
+
while (true) {
|
|
195
|
+
const { done, value } = await reader.read()
|
|
196
|
+
if (done) break
|
|
197
|
+
buffer += decoder.decode(value, { stream: true })
|
|
198
|
+
// SSE 事件以空行分隔;兼容 \r\n。
|
|
199
|
+
let boundary = buffer.search(/\r?\n\r?\n/)
|
|
200
|
+
while (boundary >= 0) {
|
|
201
|
+
const rawEvent = buffer.slice(0, boundary)
|
|
202
|
+
buffer = buffer.slice(boundary + (buffer[boundary] === '\r' ? 4 : 2))
|
|
203
|
+
for (const line of rawEvent.split(/\r?\n/)) {
|
|
204
|
+
const data = sseDataOf(line)
|
|
205
|
+
if (!data) continue
|
|
206
|
+
if (data === '[DONE]') return true
|
|
207
|
+
sawData = true
|
|
208
|
+
try {
|
|
209
|
+
const chunk = JSON.parse(data)
|
|
210
|
+
accumulator.applyChunk(chunk)
|
|
211
|
+
} catch { /* 非 JSON 的 data 行忽略 */ }
|
|
212
|
+
}
|
|
213
|
+
boundary = buffer.search(/\r?\n\r?\n/)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// 收尾:没有空行结尾的最后一个事件。
|
|
217
|
+
const tail = buffer.trim()
|
|
218
|
+
if (tail) {
|
|
219
|
+
for (const line of tail.split(/\r?\n/)) {
|
|
220
|
+
const data = sseDataOf(line)
|
|
221
|
+
if (!data || data === '[DONE]') continue
|
|
222
|
+
sawData = true
|
|
223
|
+
try { accumulator.applyChunk(JSON.parse(data)) } catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
try { reader.releaseLock() } catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
return sawData
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 创建 HTTP LLM 客户端;调用方负责在 Worker 循环外层套超时。 */
|
|
233
|
+
export function createHttpLlmClient(options: HttpLlmOptions): LLMClient {
|
|
234
|
+
const url = endpointOf(options.baseUrl)
|
|
235
|
+
const model = String(options.model ?? '').trim()
|
|
236
|
+
if (!model) throw new Error('WORKER_LLM_MODEL is required')
|
|
237
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
238
|
+
const timeoutMs = Math.max(1, Number(options.timeoutMs ?? 120_000) || 120_000)
|
|
239
|
+
return {
|
|
240
|
+
async chat(request: LLMRequest): Promise<LLMResponse> {
|
|
241
|
+
const controller = new AbortController()
|
|
242
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
243
|
+
const onAbort = () => controller.abort()
|
|
244
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
245
|
+
try {
|
|
246
|
+
const body: Record<string, unknown> = {
|
|
247
|
+
model: request.model ?? model,
|
|
248
|
+
messages: toWireMessages(request.messages),
|
|
249
|
+
stream: false,
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
|
252
|
+
body.tools = request.tools.map((tool) => ({ type: 'function', function: tool }))
|
|
253
|
+
body.tool_choice = 'auto'
|
|
254
|
+
}
|
|
255
|
+
if (request.responseFormat?.type === 'json_object') body.response_format = { type: 'json_object' }
|
|
256
|
+
const headers: Record<string, string> = {
|
|
257
|
+
'content-type': 'application/json',
|
|
258
|
+
accept: 'application/json',
|
|
259
|
+
...(options.headers ?? {}),
|
|
260
|
+
}
|
|
261
|
+
if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`
|
|
262
|
+
const response = await fetchImpl(url, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers,
|
|
265
|
+
body: JSON.stringify(body),
|
|
266
|
+
signal: controller.signal,
|
|
267
|
+
})
|
|
268
|
+
const text = await response.text()
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
const detail = text.slice(0, 300)
|
|
271
|
+
throw new Error(`LLM HTTP ${response.status}: ${detail}`)
|
|
272
|
+
}
|
|
273
|
+
let parsed: OpenAiResponse
|
|
274
|
+
try {
|
|
275
|
+
parsed = JSON.parse(text) as OpenAiResponse
|
|
276
|
+
} catch {
|
|
277
|
+
throw new Error('LLM response is not valid JSON')
|
|
278
|
+
}
|
|
279
|
+
if (parsed.error?.message) throw new Error(`LLM error: ${parsed.error.message}`)
|
|
280
|
+
return fromWireResponse(parsed)
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer)
|
|
283
|
+
request.signal?.removeEventListener('abort', onAbort)
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 真实流式调用:stream=true + SSE,边收边回调。
|
|
289
|
+
* provider 若忽略 stream 并返回普通 JSON,会自动退回聚合解析(不会重复回调)。
|
|
290
|
+
*/
|
|
291
|
+
async chatStream(request: LLMRequest, onDelta: (delta: LLMStreamDelta) => void): Promise<LLMResponse> {
|
|
292
|
+
const controller = new AbortController()
|
|
293
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
294
|
+
const onAbort = () => controller.abort()
|
|
295
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
296
|
+
try {
|
|
297
|
+
const body: Record<string, unknown> = {
|
|
298
|
+
model: request.model ?? model,
|
|
299
|
+
messages: toWireMessages(request.messages),
|
|
300
|
+
stream: true,
|
|
301
|
+
stream_options: { include_usage: true },
|
|
302
|
+
}
|
|
303
|
+
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
|
304
|
+
body.tools = request.tools.map((tool) => ({ type: 'function', function: tool }))
|
|
305
|
+
body.tool_choice = 'auto'
|
|
306
|
+
}
|
|
307
|
+
const headers: Record<string, string> = {
|
|
308
|
+
'content-type': 'application/json',
|
|
309
|
+
accept: 'text/event-stream',
|
|
310
|
+
...(options.headers ?? {}),
|
|
311
|
+
}
|
|
312
|
+
if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`
|
|
313
|
+
const response = await fetchImpl(url, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers,
|
|
316
|
+
body: JSON.stringify(body),
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
})
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
const detail = String(await response.text()).slice(0, 300)
|
|
321
|
+
throw new Error(`LLM HTTP ${response.status}: ${detail}`)
|
|
322
|
+
}
|
|
323
|
+
const accumulator = createStreamAccumulator(onDelta)
|
|
324
|
+
const contentType = String(response.headers?.get?.('content-type') ?? '')
|
|
325
|
+
// 声明为 SSE:边收边解析(真正的增量 streaming)。
|
|
326
|
+
if (contentType.includes('text/event-stream')) {
|
|
327
|
+
const sawData = await consumeSse(response.body as ReadableStream<Uint8Array> | null, accumulator)
|
|
328
|
+
if (!sawData) throw new Error('LLM stream ended without any SSE data')
|
|
329
|
+
return accumulator.result()
|
|
330
|
+
}
|
|
331
|
+
// 未声明 SSE(provider 忽略 stream 参数):整体读回,按 JSON 或 SSE 文本兜底。
|
|
332
|
+
const text = await response.text()
|
|
333
|
+
if (/^\s*data:/m.test(text)) {
|
|
334
|
+
for (const line of text.split(/\r?\n/)) {
|
|
335
|
+
const data = sseDataOf(line)
|
|
336
|
+
if (!data || data === '[DONE]') continue
|
|
337
|
+
try { accumulator.applyChunk(JSON.parse(data)) } catch { /* ignore */ }
|
|
338
|
+
}
|
|
339
|
+
if (accumulator.sawChunk) return accumulator.result()
|
|
340
|
+
}
|
|
341
|
+
let parsed: OpenAiResponse
|
|
342
|
+
try {
|
|
343
|
+
parsed = JSON.parse(text) as OpenAiResponse
|
|
344
|
+
} catch {
|
|
345
|
+
throw new Error('LLM response is not valid JSON')
|
|
346
|
+
}
|
|
347
|
+
if (parsed.error?.message) throw new Error(`LLM error: ${parsed.error.message}`)
|
|
348
|
+
const fallback = fromWireResponse(parsed)
|
|
349
|
+
if (fallback.message.content) {
|
|
350
|
+
try { onDelta({ content: fallback.message.content }) } catch { /* ignore */ }
|
|
351
|
+
}
|
|
352
|
+
return fallback
|
|
353
|
+
} finally {
|
|
354
|
+
clearTimeout(timer)
|
|
355
|
+
request.signal?.removeEventListener('abort', onAbort)
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
}
|