@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.11

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 (76) hide show
  1. package/package.json +12 -3
  2. package/src/adapter/cloudflare/index.ts +56 -0
  3. package/src/adapter/cloudflare/resources/runtime-resources.ts +89 -0
  4. package/src/adapter/cloudflare/sandbox/adapter.ts +1513 -0
  5. package/src/adapter/cloudflare/sandbox/id.ts +23 -0
  6. package/src/adapter/cloudflare/sandbox/policy.ts +15 -0
  7. package/src/adapter/cloudflare/subagent/definition.ts +574 -0
  8. package/src/adapter/cloudflare/subagent/runner.ts +175 -0
  9. package/src/adapter/cloudflare/subagent/tools.ts +254 -0
  10. package/src/adapter/cloudflare/universal-agent/hooks.ts +35 -0
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +277 -0
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +80 -0
  13. package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
  14. package/src/adapter/cloudflare/workspace/publisher.ts +31 -0
  15. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +376 -0
  16. package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
  17. package/src/agent-tool-runtime.ts +152 -0
  18. package/src/db/agent-tool.repo.ts +27 -0
  19. package/src/db/index.ts +33 -0
  20. package/src/db/interaction.repo.ts +185 -0
  21. package/src/db/schema.ts +25 -1
  22. package/src/db/submission.repo.ts +63 -1
  23. package/src/index.ts +57 -21
  24. package/src/kernel/approval-lifecycle.ts +41 -6
  25. package/src/kernel/bindings.ts +73 -9
  26. package/src/kernel/interaction-lifecycle.ts +395 -0
  27. package/src/kernel/public-contracts.ts +2 -0
  28. package/src/kernel/recoverable-chat-agent.ts +104 -6
  29. package/src/kernel/runtime-assembly-view.ts +37 -0
  30. package/src/kernel/runtime-assembly.ts +41 -0
  31. package/src/kernel/runtime-config.ts +4 -0
  32. package/src/kernel/runtime-load.ts +191 -0
  33. package/src/kernel/state.ts +12 -1
  34. package/src/kernel/submission-lifecycle.ts +33 -2
  35. package/src/layers/orchestration/temporary-agent/core.ts +12 -1
  36. package/src/layers/orchestration/temporary-agent/runner.ts +1 -2
  37. package/src/lib/mcp.ts +7 -3
  38. package/src/lib/prompt.ts +4 -1
  39. package/src/lib/telemetry-dev.ts +7 -4
  40. package/src/pi/assembly/context.ts +3 -3
  41. package/src/pi/assembly/extensions.ts +11 -22
  42. package/src/pi/assembly/snapshot.ts +6 -3
  43. package/src/pi/message/contract.ts +7 -0
  44. package/src/pi/message/conversion.ts +9 -1
  45. package/src/pi/runtime-adapter/assembly.ts +26 -31
  46. package/src/pi/runtime-adapter/execution.ts +198 -15
  47. package/src/pi/runtime-adapter/index.ts +24 -8
  48. package/src/pi/runtime-adapter/models.ts +382 -35
  49. package/src/pi/runtime-adapter/recovery.ts +188 -1
  50. package/src/pi/runtime-adapter/transcript.ts +61 -3
  51. package/src/pi/tool/ai-adapter.ts +58 -1
  52. package/src/pi/tool/base.ts +190 -12
  53. package/src/pi/tool/compiler.ts +34 -1
  54. package/src/pi/tool/core-host.ts +19 -24
  55. package/src/pi/tool/core.ts +30 -120
  56. package/src/pi/tool/gateway.ts +54 -0
  57. package/src/pi/tool/index.ts +2 -0
  58. package/src/pi/tool/mcp.ts +96 -68
  59. package/src/pi/tool/schedule.ts +41 -19
  60. package/src/pi/tool/skill.ts +126 -420
  61. package/src/pi/tool/subagent.ts +14 -2
  62. package/src/pi/tool/web-fetch.ts +281 -0
  63. package/src/pi/tool/web-search/api.ts +34 -18
  64. package/src/pi/tool/web-search/web-search.ts +0 -1
  65. package/src/pi/tool/workspace-revision.ts +64 -0
  66. package/src/pi/tool/workspace-sandbox.ts +105 -263
  67. package/src/pi/turn/index.ts +20 -0
  68. package/src/pi/turn/interaction.ts +181 -0
  69. package/src/pi/turn/tool-recovery.ts +244 -1
  70. package/src/runtime-agent-context.ts +112 -0
  71. package/src/runtime-agent.ts +568 -321
  72. package/src/{plugins.ts → runtime-assembler.ts} +372 -398
  73. package/src/runtime-definition.ts +175 -0
  74. package/src/runtime.ts +835 -204
  75. package/src/tool-registry.ts +143 -0
  76. 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
- const MODEL_STREAM_STALL_TIMEOUT_MS = 120_000;
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
- while (true) {
61
- let timer: ReturnType<typeof setTimeout> | undefined;
62
- const next = await Promise.race([
63
- iterator.next(),
64
- new Promise<never>((_, reject) => {
65
- timer = setTimeout(() => {
66
- const error = new Error(
67
- `Model stream stalled for ${MODEL_STREAM_STALL_TIMEOUT_MS} ms`,
68
- );
69
- watchdog.abort(error);
70
- reject(error);
71
- }, MODEL_STREAM_STALL_TIMEOUT_MS);
72
- }),
73
- ]).finally(() => {
74
- if (timer !== undefined) clearTimeout(timer);
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
- if (next.done) {
77
- throw new Error("Model stream ended without a terminal event");
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
- yield next.value;
80
- if (next.value.type === "done" || next.value.type === "error") return;
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
- * 给模型请求补上可中断的 provider 瞬时错误重试和空闲终止边界。
350
+ * 给模型请求补上可中断的空闲终止边界。
86
351
  *
87
352
  * Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
88
353
  *
89
- * Pi 0.83 默认 `maxRetries` 0,且流无事件时会无限等待。这里补 2 次默认重试,
90
- * 并在连续 120 秒没有模型事件时中止 provider、返回明确失败终态。
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(streamFn: StreamFn): StreamFn {
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
- const source = await streamFn(model, context, {
97
- ...options,
98
- signal: options?.signal
99
- ? AbortSignal.any([options.signal, watchdog.signal])
100
- : watchdog.signal,
101
- maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
102
- });
103
- return stopStalledModelStream(source, watchdog);
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" && compat
192
- ? { compat }
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
- api: piApiFor(endpoint.protocol),
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();