@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.10
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/package.json +12 -3
- package/src/adapter/cloudflare/index.ts +56 -0
- package/src/adapter/cloudflare/resources/runtime-resources.ts +89 -0
- package/src/adapter/cloudflare/sandbox/adapter.ts +1513 -0
- package/src/adapter/cloudflare/sandbox/id.ts +23 -0
- package/src/adapter/cloudflare/sandbox/policy.ts +15 -0
- package/src/adapter/cloudflare/subagent/definition.ts +574 -0
- package/src/adapter/cloudflare/subagent/runner.ts +175 -0
- package/src/adapter/cloudflare/subagent/tools.ts +254 -0
- package/src/adapter/cloudflare/universal-agent/hooks.ts +35 -0
- package/src/adapter/cloudflare/universal-agent/preparation.ts +277 -0
- package/src/adapter/cloudflare/universal-agent/tools.ts +80 -0
- package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
- package/src/adapter/cloudflare/workspace/publisher.ts +31 -0
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +376 -0
- package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
- package/src/agent-tool-runtime.ts +152 -0
- package/src/db/agent-tool.repo.ts +27 -0
- package/src/db/index.ts +33 -0
- package/src/db/interaction.repo.ts +185 -0
- package/src/db/schema.ts +25 -1
- package/src/db/submission.repo.ts +63 -1
- package/src/index.ts +57 -21
- package/src/kernel/approval-lifecycle.ts +41 -6
- package/src/kernel/bindings.ts +73 -9
- package/src/kernel/interaction-lifecycle.ts +395 -0
- package/src/kernel/public-contracts.ts +2 -0
- package/src/kernel/recoverable-chat-agent.ts +104 -6
- package/src/kernel/runtime-assembly-view.ts +37 -0
- package/src/kernel/runtime-assembly.ts +41 -0
- package/src/kernel/runtime-config.ts +4 -0
- package/src/kernel/runtime-load.ts +191 -0
- package/src/kernel/state.ts +12 -1
- package/src/kernel/submission-lifecycle.ts +33 -2
- package/src/layers/orchestration/temporary-agent/core.ts +12 -1
- package/src/layers/orchestration/temporary-agent/runner.ts +1 -2
- package/src/lib/mcp.ts +7 -3
- package/src/lib/prompt.ts +1 -1
- package/src/lib/telemetry-dev.ts +7 -4
- package/src/pi/assembly/context.ts +3 -3
- package/src/pi/assembly/extensions.ts +11 -22
- package/src/pi/assembly/snapshot.ts +6 -3
- package/src/pi/message/contract.ts +7 -0
- package/src/pi/message/conversion.ts +9 -1
- package/src/pi/runtime-adapter/assembly.ts +26 -31
- package/src/pi/runtime-adapter/execution.ts +198 -15
- package/src/pi/runtime-adapter/index.ts +24 -8
- package/src/pi/runtime-adapter/models.ts +382 -35
- package/src/pi/runtime-adapter/recovery.ts +188 -1
- package/src/pi/runtime-adapter/transcript.ts +61 -3
- package/src/pi/tool/ai-adapter.ts +58 -1
- package/src/pi/tool/base.ts +190 -12
- package/src/pi/tool/compiler.ts +34 -1
- package/src/pi/tool/core-host.ts +19 -24
- package/src/pi/tool/core.ts +30 -120
- package/src/pi/tool/gateway.ts +54 -0
- package/src/pi/tool/index.ts +2 -0
- package/src/pi/tool/mcp.ts +96 -68
- package/src/pi/tool/schedule.ts +41 -19
- package/src/pi/tool/skill.ts +126 -420
- package/src/pi/tool/subagent.ts +14 -2
- package/src/pi/tool/web-fetch.ts +281 -0
- package/src/pi/tool/web-search/api.ts +34 -18
- package/src/pi/tool/web-search/web-search.ts +0 -1
- package/src/pi/tool/workspace-revision.ts +64 -0
- package/src/pi/tool/workspace-sandbox.ts +105 -263
- package/src/pi/turn/index.ts +20 -0
- package/src/pi/turn/interaction.ts +181 -0
- package/src/pi/turn/tool-recovery.ts +244 -1
- package/src/runtime-agent-context.ts +112 -0
- package/src/runtime-agent.ts +568 -321
- package/src/{plugins.ts → runtime-assembler.ts} +372 -398
- package/src/runtime-definition.ts +175 -0
- package/src/runtime.ts +835 -204
- package/src/tool-registry.ts +143 -0
- package/src/workspace-versioning.ts +46 -0
|
@@ -2,8 +2,10 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
|
|
|
2
2
|
import {
|
|
3
3
|
createModels,
|
|
4
4
|
createProvider,
|
|
5
|
+
isRetryableAssistantError,
|
|
5
6
|
lazyStream,
|
|
6
7
|
type Api,
|
|
8
|
+
type AssistantMessage,
|
|
7
9
|
type AssistantMessageEvent,
|
|
8
10
|
type Model,
|
|
9
11
|
type MutableModels,
|
|
@@ -35,6 +37,16 @@ import {
|
|
|
35
37
|
import {
|
|
36
38
|
openaiCodexProvider,
|
|
37
39
|
} from "@earendil-works/pi-ai/providers/openai-codex";
|
|
40
|
+
import {
|
|
41
|
+
cloudflareStreams,
|
|
42
|
+
} from "@earendil-works/pi-ai/providers/cloudflare-stream";
|
|
43
|
+
import {
|
|
44
|
+
ChatStreamStalledError,
|
|
45
|
+
} from "agents/chat";
|
|
46
|
+
import {
|
|
47
|
+
genericObservability,
|
|
48
|
+
type ObservabilityEvent,
|
|
49
|
+
} from "agents/observability";
|
|
38
50
|
import type {
|
|
39
51
|
RuntimeModelEndpoint,
|
|
40
52
|
RuntimeModelProtocol,
|
|
@@ -50,57 +62,375 @@ const CATALOGS = {
|
|
|
50
62
|
} satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
|
|
51
63
|
|
|
52
64
|
const PROVIDER_MAX_RETRIES = 2;
|
|
53
|
-
|
|
65
|
+
// 看门狗要抓的是「连接死了」,不是「模型想得慢」——这两件事在流上分不开:
|
|
66
|
+
// 推理模型在中转后面是「闷头想完再吐」,静默期一个字节都没有,也没有 keepalive。
|
|
67
|
+
//
|
|
68
|
+
// 2026-08-06 实测(gpt-5.6-sol @ api.sharkmelon.tech,一道需要真推理的题):
|
|
69
|
+
// 响应头 3.86s → 单段静默 **34.4s** → 1190 个 chunk 在 12s 内吐完,首个可见内容 38.3s。
|
|
70
|
+
// 而真实回合(长 transcript + 工具)比这道题重得多。原值 60s 卡在这条曲线的正中间:
|
|
71
|
+
// 简单回合(3~9s)不触发,一旦模型真开始想就必然超时 → abort → 从 transcript 整轮重跑
|
|
72
|
+
// → 同一个提示词又想同样久 → 再超时。**重试的对象正是那个「本来就要更久」的东西,
|
|
73
|
+
// 结构上不可能收敛**,表现为前端 think 转到恢复预算耗尽为止。
|
|
74
|
+
//
|
|
75
|
+
// 调到 240s:比实测静默期留约 7 倍余量。代价是连接真死时单次要等更久,
|
|
76
|
+
// 所以 runtime.ts 同时把 stall 的恢复次数单独收窄(见 CHAT_STALL_MAX_ATTEMPTS)。
|
|
77
|
+
export const MODEL_STREAM_STALL_TIMEOUT_MS = 240_000;
|
|
78
|
+
export const MODEL_STREAM_STALL_MESSAGE =
|
|
79
|
+
`Chat stream stalled: no activity for ${MODEL_STREAM_STALL_TIMEOUT_MS}ms; the turn was aborted by the stall watchdog.`;
|
|
80
|
+
const MODEL_STREAM_STALL_DETAILS_PREFIX = `${MODEL_STREAM_STALL_MESSAGE}\n`;
|
|
81
|
+
|
|
82
|
+
export interface ModelStreamStallDetails {
|
|
83
|
+
lastMeaningfulActivityAt: number;
|
|
84
|
+
lastMeaningfulActivityType:
|
|
85
|
+
| AssistantMessageEvent["type"]
|
|
86
|
+
| "model_stream_started";
|
|
87
|
+
idleMs: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isModelStreamStallMessage(message?: string): message is string {
|
|
91
|
+
return message === MODEL_STREAM_STALL_MESSAGE ||
|
|
92
|
+
message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readModelStreamStallDetails(
|
|
96
|
+
message?: string,
|
|
97
|
+
): ModelStreamStallDetails | undefined {
|
|
98
|
+
if (!message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX)) return;
|
|
99
|
+
try {
|
|
100
|
+
const value = JSON.parse(
|
|
101
|
+
message.slice(MODEL_STREAM_STALL_DETAILS_PREFIX.length),
|
|
102
|
+
) as Partial<ModelStreamStallDetails>;
|
|
103
|
+
if (
|
|
104
|
+
typeof value.lastMeaningfulActivityAt !== "number" ||
|
|
105
|
+
typeof value.lastMeaningfulActivityType !== "string" ||
|
|
106
|
+
typeof value.idleMs !== "number"
|
|
107
|
+
) return;
|
|
108
|
+
return value as ModelStreamStallDetails;
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isRecoverableAssistantError(
|
|
115
|
+
message: AssistantMessage,
|
|
116
|
+
): boolean {
|
|
117
|
+
if (
|
|
118
|
+
message.stopReason === "error" &&
|
|
119
|
+
/thought[_ ]signature/i.test(message.errorMessage ?? "")
|
|
120
|
+
) return false;
|
|
121
|
+
return isRetryableAssistantError(message) ||
|
|
122
|
+
(message.stopReason === "error" &&
|
|
123
|
+
/upstream http\/2 stream failed/i.test(message.errorMessage ?? ""));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pdfPayload(payload: unknown, api: Api): unknown {
|
|
127
|
+
if (Array.isArray(payload)) {
|
|
128
|
+
return payload.map((value) => pdfPayload(value, api));
|
|
129
|
+
}
|
|
130
|
+
if (typeof payload !== "object" || payload === null) return payload;
|
|
131
|
+
|
|
132
|
+
const value = payload as Record<string, unknown>;
|
|
133
|
+
if (api === "anthropic-messages" && value.type === "image") {
|
|
134
|
+
const source = value.source as Record<string, unknown> | undefined;
|
|
135
|
+
if (source?.media_type === "application/pdf") {
|
|
136
|
+
return { ...value, type: "document" };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (api === "openai-completions" && value.type === "image_url") {
|
|
140
|
+
const image = value.image_url as Record<string, unknown> | undefined;
|
|
141
|
+
if (
|
|
142
|
+
typeof image?.url === "string" &&
|
|
143
|
+
image.url.startsWith("data:application/pdf;base64,")
|
|
144
|
+
) {
|
|
145
|
+
return {
|
|
146
|
+
type: "file",
|
|
147
|
+
file: { filename: "workspace.pdf", file_data: image.url },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (api === "openai-codex-responses" && value.type === "input_image") {
|
|
152
|
+
if (
|
|
153
|
+
typeof value.image_url === "string" &&
|
|
154
|
+
value.image_url.startsWith("data:application/pdf;base64,")
|
|
155
|
+
) {
|
|
156
|
+
return {
|
|
157
|
+
type: "input_file",
|
|
158
|
+
filename: "workspace.pdf",
|
|
159
|
+
file_data: value.image_url,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Object.fromEntries(
|
|
165
|
+
Object.entries(value).map(([key, child]) => [
|
|
166
|
+
key,
|
|
167
|
+
pdfPayload(child, api),
|
|
168
|
+
]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function meaningfulModelProgress(
|
|
173
|
+
event: AssistantMessageEvent,
|
|
174
|
+
streamedContent: Set<string>,
|
|
175
|
+
): AssistantMessageEvent["type"] | undefined {
|
|
176
|
+
switch (event.type) {
|
|
177
|
+
case "text_delta":
|
|
178
|
+
case "thinking_delta": {
|
|
179
|
+
if (!event.delta.trim()) return undefined;
|
|
180
|
+
streamedContent.add(`${event.type}:${event.contentIndex}`);
|
|
181
|
+
return event.type;
|
|
182
|
+
}
|
|
183
|
+
case "text_end":
|
|
184
|
+
if (streamedContent.has(`text_delta:${event.contentIndex}`)) return;
|
|
185
|
+
return event.content.trim() ? event.type : undefined;
|
|
186
|
+
case "thinking_end":
|
|
187
|
+
if (streamedContent.has(`thinking_delta:${event.contentIndex}`)) return;
|
|
188
|
+
return event.content.trim() ? event.type : undefined;
|
|
189
|
+
case "toolcall_end":
|
|
190
|
+
return event.type;
|
|
191
|
+
default:
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
54
195
|
|
|
55
196
|
async function* stopStalledModelStream(
|
|
56
197
|
source: AsyncIterable<AssistantMessageEvent>,
|
|
57
198
|
watchdog: AbortController,
|
|
199
|
+
probe: (phase: string, details?: Record<string, unknown>) => void,
|
|
58
200
|
): AsyncGenerator<AssistantMessageEvent> {
|
|
59
201
|
const iterator = source[Symbol.asyncIterator]();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
202
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
let stalled = false;
|
|
204
|
+
let stallError: ChatStreamStalledError | undefined;
|
|
205
|
+
let idleWaitMs = 0;
|
|
206
|
+
let rawEventCount = 0;
|
|
207
|
+
let meaningfulEventCount = 0;
|
|
208
|
+
let lastRawEventAt: number | undefined;
|
|
209
|
+
let lastRawEventType: AssistantMessageEvent["type"] | undefined;
|
|
210
|
+
const streamedContent = new Set<string>();
|
|
211
|
+
let lastMeaningfulActivityAt = Date.now();
|
|
212
|
+
let lastMeaningfulActivityType:
|
|
213
|
+
ModelStreamStallDetails["lastMeaningfulActivityType"] =
|
|
214
|
+
"model_stream_started";
|
|
215
|
+
const stop = (idleMs: number) => {
|
|
216
|
+
stalled = true;
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
probe("stall", {
|
|
219
|
+
idleMs,
|
|
220
|
+
rawEventCount,
|
|
221
|
+
meaningfulEventCount,
|
|
222
|
+
lastRawEventType: lastRawEventType ?? "none",
|
|
223
|
+
sinceLastRawEventMs: lastRawEventAt === undefined
|
|
224
|
+
? null
|
|
225
|
+
: now - lastRawEventAt,
|
|
226
|
+
lastMeaningfulActivityType,
|
|
75
227
|
});
|
|
76
|
-
|
|
77
|
-
|
|
228
|
+
stallError = new ChatStreamStalledError(
|
|
229
|
+
MODEL_STREAM_STALL_DETAILS_PREFIX + JSON.stringify({
|
|
230
|
+
lastMeaningfulActivityAt,
|
|
231
|
+
lastMeaningfulActivityType,
|
|
232
|
+
idleMs,
|
|
233
|
+
} satisfies ModelStreamStallDetails),
|
|
234
|
+
);
|
|
235
|
+
watchdog.abort(stallError);
|
|
236
|
+
return stallError;
|
|
237
|
+
};
|
|
238
|
+
const arm = (
|
|
239
|
+
delayMs: number,
|
|
240
|
+
waitStartedAt: number,
|
|
241
|
+
) => new Promise<never>((_, reject) => {
|
|
242
|
+
timer = setTimeout(() => {
|
|
243
|
+
reject(stop(idleWaitMs + Date.now() - waitStartedAt));
|
|
244
|
+
}, delayMs);
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
while (true) {
|
|
248
|
+
const waitStartedAt = Date.now();
|
|
249
|
+
const nextPromise = iterator.next();
|
|
250
|
+
nextPromise.catch(() => {});
|
|
251
|
+
let next: IteratorResult<AssistantMessageEvent>;
|
|
252
|
+
try {
|
|
253
|
+
next = await Promise.race([
|
|
254
|
+
nextPromise,
|
|
255
|
+
arm(
|
|
256
|
+
Math.max(0, MODEL_STREAM_STALL_TIMEOUT_MS - idleWaitMs),
|
|
257
|
+
waitStartedAt,
|
|
258
|
+
),
|
|
259
|
+
]);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (stalled) throw stallError;
|
|
262
|
+
probe("iterator_error", {
|
|
263
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
264
|
+
rawEventCount,
|
|
265
|
+
meaningfulEventCount,
|
|
266
|
+
});
|
|
267
|
+
throw error;
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
timer = undefined;
|
|
271
|
+
}
|
|
272
|
+
if (stalled) throw stallError;
|
|
273
|
+
if (next.done) break;
|
|
274
|
+
const event = next.value;
|
|
275
|
+
rawEventCount += 1;
|
|
276
|
+
lastRawEventAt = Date.now();
|
|
277
|
+
lastRawEventType = event.type;
|
|
278
|
+
if (rawEventCount === 1) {
|
|
279
|
+
probe("first_raw_event", { eventType: event.type });
|
|
280
|
+
}
|
|
281
|
+
if (event.type === "done" || event.type === "error") {
|
|
282
|
+
probe(event.type, {
|
|
283
|
+
reason: event.reason,
|
|
284
|
+
stopReason: event.type === "done"
|
|
285
|
+
? event.message.stopReason
|
|
286
|
+
: event.error.stopReason,
|
|
287
|
+
rawEventCount,
|
|
288
|
+
meaningfulEventCount,
|
|
289
|
+
});
|
|
290
|
+
yield event;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
idleWaitMs += Date.now() - waitStartedAt;
|
|
294
|
+
const progress = meaningfulModelProgress(event, streamedContent);
|
|
295
|
+
if (progress) {
|
|
296
|
+
meaningfulEventCount += 1;
|
|
297
|
+
if (meaningfulEventCount === 1) {
|
|
298
|
+
probe("first_meaningful_event", { eventType: progress });
|
|
299
|
+
}
|
|
300
|
+
lastMeaningfulActivityAt = Date.now();
|
|
301
|
+
lastMeaningfulActivityType = progress;
|
|
302
|
+
idleWaitMs = 0;
|
|
303
|
+
}
|
|
304
|
+
yield event;
|
|
78
305
|
}
|
|
79
|
-
|
|
80
|
-
|
|
306
|
+
} finally {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
if (!stalled) await iterator.return?.().catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
probe("ended_without_terminal", {
|
|
311
|
+
rawEventCount,
|
|
312
|
+
meaningfulEventCount,
|
|
313
|
+
lastRawEventType: lastRawEventType ?? "none",
|
|
314
|
+
});
|
|
315
|
+
throw new Error("Model stream ended without a terminal event");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function trimTrailingSlash(value: string): string {
|
|
319
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 算出一个已解析模型真正会被请求的 URL。
|
|
324
|
+
*
|
|
325
|
+
* {@link withProviderRetry} 在每次派发模型请求前调用它写观测日志;诊断"这个 Agent 到底调了哪个 LLM"时也可以直接复用。
|
|
326
|
+
*
|
|
327
|
+
* 各协议的路径由底层 SDK 决定,这里必须与之逐条对齐:OpenAI 兼容 SDK 用 `baseURL + /chat/completions`,
|
|
328
|
+
* Anthropic SDK 用 `baseURL + /v1/messages`(纯字符串拼接,不会去重 `/v1`),Google 的 baseUrl 已含版本段,
|
|
329
|
+
* Codex 走 `resolveCodexUrl`。不要在这里"顺手规范化"路径,否则日志会与真实请求脱节,反而掩盖配置错误。
|
|
330
|
+
*/
|
|
331
|
+
export function modelRequestUrl(model: Model<Api>): string {
|
|
332
|
+
const base = trimTrailingSlash(model.baseUrl ?? "");
|
|
333
|
+
switch (model.api) {
|
|
334
|
+
case "openai-completions":
|
|
335
|
+
return `${base}/chat/completions`;
|
|
336
|
+
case "anthropic-messages":
|
|
337
|
+
return `${base}/v1/messages`;
|
|
338
|
+
case "google-generative-ai":
|
|
339
|
+
return `${base}/models/${model.id}:streamGenerateContent`;
|
|
340
|
+
case "openai-codex-responses":
|
|
341
|
+
return base.endsWith("/codex/responses") || base.endsWith("/responses")
|
|
342
|
+
? base
|
|
343
|
+
: `${base}/codex/responses`;
|
|
344
|
+
default:
|
|
345
|
+
return base;
|
|
81
346
|
}
|
|
82
347
|
}
|
|
83
348
|
|
|
84
349
|
/**
|
|
85
|
-
*
|
|
350
|
+
* 给模型请求补上可中断的空闲终止边界。
|
|
86
351
|
*
|
|
87
352
|
* Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
|
|
88
353
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
354
|
+
* 调用方可以覆盖默认的 2 次 Provider 重试;主 Turn 传 0,由 Submission
|
|
355
|
+
* 统一持有恢复预算。连续 {@link MODEL_STREAM_STALL_TIMEOUT_MS} 毫秒没有可展示进展时中止 provider。
|
|
356
|
+
*
|
|
357
|
+
* 每个相位发一条 `ua:model` 观测事件(dispatch / response_headers / first_raw_event /
|
|
358
|
+
* first_meaningful_event / stall / done…)。**只发事件、不直接 console.log**:
|
|
359
|
+
* 这样它和 `ua:tool`、`chat:*` 共用同一个消费面,被 `TELEMETRY_CONSOLE` 一个开关统一管,
|
|
360
|
+
* 关掉即零订阅 no-op;将来若接上 `tail_consumers`,这条也自动跟着走。
|
|
361
|
+
*
|
|
362
|
+
* 每条都带 `url`(而不是只在 dispatch 带一次):模型路由只存在于 secret 里,
|
|
363
|
+
* 线上排查时最需要回答的就是"这次打到哪个 URL",让每行自解释比省几十字节值。
|
|
364
|
+
* payload 只含路由与时序,不含 key 和消息内容。
|
|
365
|
+
*
|
|
366
|
+
* 注意这里用的是模块级 `genericObservability`,不是 Agent 实例的 `_emit` ——
|
|
367
|
+
* 纯模块拿不到实例,代价是事件不带 `agent` / `name` 字段;turn 的身份由
|
|
368
|
+
* payload 里的 `requestId` / `sessionId` 承担。
|
|
91
369
|
*/
|
|
92
|
-
export function withProviderRetry(
|
|
370
|
+
export function withProviderRetry(
|
|
371
|
+
streamFn: StreamFn,
|
|
372
|
+
defaultMaxRetries = PROVIDER_MAX_RETRIES,
|
|
373
|
+
defaultSessionId?: string,
|
|
374
|
+
): StreamFn {
|
|
93
375
|
return (model, context, options) =>
|
|
94
376
|
lazyStream(model, async () => {
|
|
377
|
+
const requestId = crypto.randomUUID();
|
|
378
|
+
const startedAt = Date.now();
|
|
379
|
+
const sessionId = options?.sessionId ?? defaultSessionId;
|
|
380
|
+
const url = modelRequestUrl(model);
|
|
381
|
+
const probe = (phase: string, details: Record<string, unknown> = {}) =>
|
|
382
|
+
// `ua:*` 是本仓自有的事件命名,不在上游的 ObservabilityEvent 联合里,
|
|
383
|
+
// 故整体断言一次(runtime.ts 的 `ua:tool` 是同一处上游类型缺口)。
|
|
384
|
+
// 不能只把 type 断言成 never——那会把联合窄成 never,连 payload 一起报错。
|
|
385
|
+
genericObservability.emit({
|
|
386
|
+
type: "ua:model",
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
payload: {
|
|
389
|
+
requestId,
|
|
390
|
+
sessionId,
|
|
391
|
+
phase,
|
|
392
|
+
elapsedMs: Date.now() - startedAt,
|
|
393
|
+
url,
|
|
394
|
+
api: model.api,
|
|
395
|
+
provider: model.provider,
|
|
396
|
+
model: model.id,
|
|
397
|
+
...details,
|
|
398
|
+
},
|
|
399
|
+
} as unknown as ObservabilityEvent);
|
|
400
|
+
probe("dispatch");
|
|
95
401
|
const watchdog = new AbortController();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
402
|
+
let responseCount = 0;
|
|
403
|
+
try {
|
|
404
|
+
const source = await streamFn(model, context, {
|
|
405
|
+
...options,
|
|
406
|
+
signal: options?.signal
|
|
407
|
+
? AbortSignal.any([options.signal, watchdog.signal])
|
|
408
|
+
: watchdog.signal,
|
|
409
|
+
maxRetries: options?.maxRetries ?? defaultMaxRetries,
|
|
410
|
+
sessionId,
|
|
411
|
+
onPayload: async (payload, activeModel) =>
|
|
412
|
+
pdfPayload(
|
|
413
|
+
await options?.onPayload?.(payload, activeModel) ?? payload,
|
|
414
|
+
activeModel.api,
|
|
415
|
+
),
|
|
416
|
+
onResponse: async (response, activeModel) => {
|
|
417
|
+
const upstreamRequestId = response.headers["x-request-id"] ??
|
|
418
|
+
response.headers["request-id"] ?? response.headers["cf-ray"];
|
|
419
|
+
probe("response_headers", {
|
|
420
|
+
responseCount: ++responseCount,
|
|
421
|
+
status: response.status,
|
|
422
|
+
...(upstreamRequestId ? { upstreamRequestId } : {}),
|
|
423
|
+
});
|
|
424
|
+
await options?.onResponse?.(response, activeModel);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
return stopStalledModelStream(source, watchdog, probe);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
probe("dispatch_error", {
|
|
430
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
431
|
+
});
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
104
434
|
});
|
|
105
435
|
}
|
|
106
436
|
|
|
@@ -183,13 +513,28 @@ function configuredModel(
|
|
|
183
513
|
...catalogHeaders,
|
|
184
514
|
...endpoint.headers,
|
|
185
515
|
};
|
|
516
|
+
const openRouterProviderPin = endpoint.openRouterProviderPins?.[modelId];
|
|
186
517
|
return {
|
|
187
518
|
...metadata,
|
|
188
519
|
api: apiFor(endpoint.protocol),
|
|
189
520
|
provider: providerId(endpoint, index),
|
|
190
521
|
baseUrl: endpoint.baseURL,
|
|
191
|
-
...(endpoint.protocol === "openrouter-chat"
|
|
192
|
-
? {
|
|
522
|
+
...(endpoint.protocol === "openrouter-chat"
|
|
523
|
+
? {
|
|
524
|
+
compat: {
|
|
525
|
+
...compat,
|
|
526
|
+
sendSessionAffinityHeaders: true,
|
|
527
|
+
sessionAffinityFormat: "openrouter" as const,
|
|
528
|
+
...(openRouterProviderPin
|
|
529
|
+
? {
|
|
530
|
+
openRouterRouting: {
|
|
531
|
+
order: [openRouterProviderPin],
|
|
532
|
+
allow_fallbacks: false,
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
: {}),
|
|
536
|
+
},
|
|
537
|
+
}
|
|
193
538
|
: {}),
|
|
194
539
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
195
540
|
};
|
|
@@ -265,7 +610,9 @@ export function configurePiModels(
|
|
|
265
610
|
models: endpoint.models.map((modelId) =>
|
|
266
611
|
configuredModel(endpoint, index, modelId),
|
|
267
612
|
),
|
|
268
|
-
|
|
613
|
+
// cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
|
|
614
|
+
// 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
|
|
615
|
+
api: cloudflareStreams(piApiFor(endpoint.protocol)),
|
|
269
616
|
})
|
|
270
617
|
);
|
|
271
618
|
models.clearProviders();
|