@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21

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 (131) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +233 -179
  3. package/src/adapter/cloudflare/define-agent.ts +252 -104
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +21 -2
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/activation.ts +13 -15
  15. package/src/config/agent-context.ts +1 -1
  16. package/src/config/assembler.ts +48 -31
  17. package/src/config/assembly-snapshot.ts +1 -1
  18. package/src/config/definition.ts +1 -1
  19. package/src/config/harness-runtime.ts +77 -49
  20. package/src/config/index.ts +1 -1
  21. package/src/config/model-guidance.ts +20 -0
  22. package/src/config/ports.ts +57 -7
  23. package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
  24. package/src/config/public-contracts.ts +2 -2
  25. package/src/config/runtime-config.ts +2 -1
  26. package/src/context/assembly.ts +1 -1
  27. package/src/context/extension/assembly.ts +2 -2
  28. package/src/context/memory/budget/gate.ts +19 -3
  29. package/src/conversation/harness-resume.ts +89 -238
  30. package/src/conversation/harness-stream.ts +87 -71
  31. package/src/conversation/history.ts +154 -152
  32. package/src/conversation/message/contract.ts +28 -4
  33. package/src/conversation/message/conversion.ts +46 -5
  34. package/src/conversation/message/projection.ts +61 -21
  35. package/src/conversation/session-entry.ts +29 -0
  36. package/src/conversation/state.ts +23 -2
  37. package/src/harness/governance.ts +206 -0
  38. package/src/harness/index.ts +3 -0
  39. package/src/harness/session-harness.ts +269 -445
  40. package/src/harness/submission-operation.ts +15 -40
  41. package/src/index.ts +18 -8
  42. package/src/lib/artifacts.ts +18 -0
  43. package/src/lib/execution-level.ts +13 -0
  44. package/src/lib/prompt.ts +9 -10
  45. package/src/lib/token-estimate.ts +8 -0
  46. package/src/model-protocol/openrouter.ts +41 -0
  47. package/src/models/index.ts +200 -35
  48. package/src/models/openrouter/messages.ts +13 -8
  49. package/src/models/openrouter/openai.ts +200 -0
  50. package/src/models/openrouter/protocol-response.ts +85 -0
  51. package/src/models/provider-streams.ts +45 -0
  52. package/src/session/cloudflare/migration.ts +382 -864
  53. package/src/session/cloudflare/storage.ts +13 -536
  54. package/src/session/index.ts +22 -35
  55. package/src/skills/springbrand-worker-website/index.ts +1 -1
  56. package/src/submission/admission.ts +20 -30
  57. package/src/submission/contracts.ts +42 -2
  58. package/src/submission/delivery.ts +1 -1
  59. package/src/submission/finalization.ts +36 -15
  60. package/src/submission/lifecycle.ts +28 -15
  61. package/src/submission/repository.ts +97 -49
  62. package/src/submission/steer-delivery.ts +32 -76
  63. package/src/submission/unattended.ts +40 -0
  64. package/src/telemetry/contract.ts +0 -1
  65. package/src/telemetry/coordinator.ts +1 -1
  66. package/src/telemetry/runtime-telemetry.ts +0 -4
  67. package/src/telemetry/subagent-lifecycle.ts +1 -1
  68. package/src/telemetry/turn-events.ts +35 -25
  69. package/src/tools/README.md +44 -0
  70. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  71. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  72. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  73. package/src/tools/assistant/ask-user.ts +319 -0
  74. package/src/tools/assistant/candidate.ts +15 -0
  75. package/src/tools/assistant/followups.ts +34 -0
  76. package/src/tools/assistant/index.ts +19 -0
  77. package/src/tools/assistant/memory.ts +73 -0
  78. package/src/tools/assistant/plan.ts +98 -0
  79. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  80. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  81. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  82. package/src/tools/codemode/workspace-connector.ts +198 -0
  83. package/src/tools/extension/index.ts +42 -0
  84. package/src/tools/index.ts +29 -26
  85. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  86. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  87. package/src/tools/runtime/activation.ts +40 -0
  88. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  89. package/src/tools/runtime/compiler.ts +246 -0
  90. package/src/tools/runtime/contracts.ts +140 -0
  91. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  92. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  93. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  94. package/src/tools/runtime/result.ts +22 -0
  95. package/src/tools/runtime/unattended-error.ts +4 -0
  96. package/src/tools/sandbox/index.ts +228 -0
  97. package/src/tools/schedule/index.ts +301 -0
  98. package/src/tools/skill/contracts.ts +20 -0
  99. package/src/tools/skill/index.ts +99 -0
  100. package/src/tools/skill/resources.ts +322 -0
  101. package/src/tools/skill/scripts.ts +168 -0
  102. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  103. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  104. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  105. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  106. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  107. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  108. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  109. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  110. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  111. package/src/tools/web/index.ts +2 -0
  112. package/src/tools/web/search/api.ts +1322 -0
  113. package/src/tools/web/search/index.ts +2 -0
  114. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  115. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  116. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  117. package/src/tools/base.ts +0 -534
  118. package/src/tools/compiler.ts +0 -327
  119. package/src/tools/schedule.ts +0 -261
  120. package/src/tools/skill.ts +0 -565
  121. package/src/tools/web-search/api.ts +0 -1318
  122. package/src/tools/web-search/index.ts +0 -2
  123. package/src/tools/workspace-state-connector.ts +0 -146
  124. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  125. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  126. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  127. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  128. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  129. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  130. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  131. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -2,6 +2,7 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import {
3
3
  createModels,
4
4
  createProvider,
5
+ getCurrentSystemPrompt,
5
6
  isRetryableAssistantError,
6
7
  lazyStream,
7
8
  type Api,
@@ -11,6 +12,8 @@ import {
11
12
  type Model,
12
13
  type MutableModels,
13
14
  type ProviderStreams,
15
+ type SimpleStreamOptions,
16
+ type TranscriptContext,
14
17
  } from "@earendil-works/pi-ai";
15
18
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
16
19
  import {
@@ -38,23 +41,32 @@ import {
38
41
  openaiCodexProvider,
39
42
  } from "@earendil-works/pi-ai/providers/openai-codex";
40
43
  import {
41
- cloudflareStreams,
44
+ resolveCloudflareModel,
42
45
  } from "@earendil-works/pi-ai/providers/cloudflare-stream";
43
46
  import {
44
47
  ChatStreamStalledError,
45
48
  } from "agents/chat";
46
49
  import type {
47
50
  RuntimeModelEndpoint,
51
+ RuntimeModelMetadata,
48
52
  RuntimeModelInputModality,
49
53
  RuntimeModelProtocol,
50
54
  RuntimeProviderPort,
51
55
  } from "../config/ports";
52
56
  import { modelInputMessages } from "../conversation/message/model-input";
53
57
  import { openRouterMessagesApi } from "./openrouter/messages";
58
+ import { openRouterChatApi, openRouterResponsesApi } from "./openrouter/openai";
59
+ import { withModelProtocolResponse } from "./openrouter/protocol-response";
60
+ import {
61
+ overlayProviderStreams,
62
+ remapProviderStreamModels,
63
+ } from "./provider-streams";
54
64
 
55
65
  const CATALOGS = {
56
66
  "openai-chat": openaiProvider().getModels(),
57
67
  "openrouter-messages": openrouterProvider().getModels(),
68
+ "openrouter-chat": openrouterProvider().getModels(),
69
+ "openrouter-responses": openrouterProvider().getModels(),
58
70
  "anthropic-messages": anthropicProvider().getModels(),
59
71
  "google-generative-ai": googleProvider().getModels(),
60
72
  "openai-codex-responses": openaiCodexProvider().getModels(),
@@ -66,7 +78,7 @@ const NO_HOSTED_INPUT_MODALITIES: readonly RuntimeModelInputModality[] = [];
66
78
  function adapterHostedInputModalities(
67
79
  protocol: RuntimeModelProtocol,
68
80
  ): readonly RuntimeModelInputModality[] {
69
- return protocol === "openrouter-messages" || protocol === "anthropic-messages"
81
+ return protocol.startsWith("openrouter-") || protocol === "anthropic-messages"
70
82
  ? MESSAGES_HOSTED_INPUT_MODALITIES
71
83
  : NO_HOSTED_INPUT_MODALITIES;
72
84
  }
@@ -130,15 +142,24 @@ export type PiGenerationLifecycleObserver = (
130
142
  event: PiGenerationLifecycleEvent,
131
143
  ) => void;
132
144
 
145
+ type ProviderBoundStream = (
146
+ model: Model<Api>,
147
+ context: Context | TranscriptContext,
148
+ options?: SimpleStreamOptions,
149
+ ) => ReturnType<StreamFn>;
150
+
133
151
  export function structuredOutputStream(
134
152
  streamFn: StreamFn,
135
153
  model: Model<Api>,
136
154
  schema: Record<string, unknown>,
137
155
  ): StreamFn | undefined {
138
- if (
139
- model.api !== "anthropic-messages" ||
140
- !model.provider.startsWith("model-api-openrouter-messages-")
141
- ) return undefined;
156
+ const messages = model.api === "anthropic-messages" &&
157
+ model.provider.startsWith("model-api-openrouter-messages-");
158
+ const chat = model.api === "openai-completions" &&
159
+ model.provider.startsWith("model-api-openrouter-chat-");
160
+ const responses = model.api === "openai-responses" &&
161
+ model.provider.startsWith("model-api-openrouter-responses-");
162
+ if (!messages && !chat && !responses) return undefined;
142
163
 
143
164
  return (activeModel, context, options) =>
144
165
  streamFn(activeModel, context, {
@@ -148,14 +169,36 @@ export function structuredOutputStream(
148
169
  if (!value || typeof value !== "object" || Array.isArray(value)) {
149
170
  throw new Error("Structured model payload must be an object");
150
171
  }
151
- const current = (value as Record<string, unknown>).output_config;
172
+ const body = value as Record<string, unknown>;
173
+ if (messages) {
174
+ const current = body.output_config;
175
+ return {
176
+ ...body,
177
+ output_config: {
178
+ ...(current && typeof current === "object" && !Array.isArray(current)
179
+ ? current
180
+ : {}),
181
+ format: { type: "json_schema", schema },
182
+ },
183
+ };
184
+ }
185
+ if (chat) {
186
+ return {
187
+ ...body,
188
+ response_format: {
189
+ type: "json_schema",
190
+ json_schema: { name: "agent_output", strict: true, schema },
191
+ },
192
+ };
193
+ }
194
+ const current = body.text;
152
195
  return {
153
- ...value,
154
- output_config: {
196
+ ...body,
197
+ text: {
155
198
  ...(current && typeof current === "object" && !Array.isArray(current)
156
199
  ? current
157
200
  : {}),
158
- format: { type: "json_schema", schema },
201
+ format: { type: "json_schema", name: "agent_output", strict: true, schema },
159
202
  },
160
203
  };
161
204
  },
@@ -251,7 +294,8 @@ function pdfPayload(payload: unknown, api: Api): unknown {
251
294
  };
252
295
  }
253
296
  }
254
- if (api === "openai-codex-responses" && value.type === "input_image") {
297
+ if ((api === "openai-codex-responses" || api === "openai-responses") &&
298
+ value.type === "input_image") {
255
299
  if (
256
300
  typeof value.image_url === "string" &&
257
301
  value.image_url.startsWith("data:application/pdf;base64,")
@@ -470,6 +514,8 @@ export function modelRequestUrl(model: Model<Api>): string {
470
514
  return `${base}/v1/messages`;
471
515
  case "google-generative-ai":
472
516
  return `${base}/models/${model.id}:streamGenerateContent`;
517
+ case "openai-responses":
518
+ return `${base}/responses`;
473
519
  case "openai-codex-responses":
474
520
  return base.endsWith("/codex/responses") || base.endsWith("/responses")
475
521
  ? base
@@ -490,16 +536,20 @@ export function modelRequestUrl(model: Model<Api>): string {
490
536
  * observer 的异常被隔离,不能改变模型执行结果。
491
537
  */
492
538
  export function withProviderBoundary(
493
- streamFn: StreamFn,
494
- defaultSessionId?: string,
539
+ streamFn: ProviderBoundStream,
540
+ /** Host-owned provider Session; it overrides the durable Harness's per-conversation identity. */
541
+ hostSessionId?: string | (() => string),
495
542
  onGeneration?: PiGenerationLifecycleObserver,
496
- ): StreamFn {
543
+ ): ProviderBoundStream {
497
544
  return (model, context, options) =>
498
545
  lazyStream(model, async () => {
499
546
  const requestId = crypto.randomUUID();
500
547
  const startedAt = Date.now();
501
- const sessionId = options?.sessionId ?? defaultSessionId;
548
+ const sessionId = (typeof hostSessionId === "function" ? hostSessionId() : hostSessionId) ?? options?.sessionId;
502
549
  const url = modelRequestUrl(model);
550
+ const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
551
+ ? context.systemPrompt
552
+ : getCurrentSystemPrompt(context.messages);
503
553
  const notify = (event: PiGenerationLifecycleEvent) => {
504
554
  try {
505
555
  onGeneration?.(event);
@@ -516,7 +566,7 @@ export function withProviderBoundary(
516
566
  model: model.id,
517
567
  url,
518
568
  input: {
519
- ...(context.systemPrompt ? { systemPrompt: context.systemPrompt } : {}),
569
+ ...(systemPrompt ? { systemPrompt } : {}),
520
570
  messages: context.messages,
521
571
  },
522
572
  });
@@ -681,7 +731,10 @@ function providerId(endpoint: RuntimeModelEndpoint, index: number): string {
681
731
  function apiFor(protocol: RuntimeModelProtocol): Api {
682
732
  switch (protocol) {
683
733
  case "openai-chat":
734
+ case "openrouter-chat":
684
735
  return "openai-completions";
736
+ case "openrouter-responses":
737
+ return "openai-responses";
685
738
  case "openrouter-messages":
686
739
  case "anthropic-messages":
687
740
  return "anthropic-messages";
@@ -694,12 +747,16 @@ function apiFor(protocol: RuntimeModelProtocol): Api {
694
747
 
695
748
  function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
696
749
  const protocol = endpoint.protocol;
697
- const api = (() => {
750
+ const implementation = (() => {
698
751
  switch (protocol) {
699
752
  case "openai-chat":
700
753
  return openAICompletionsApi();
701
754
  case "openrouter-messages":
702
755
  return openRouterMessagesApi();
756
+ case "openrouter-chat":
757
+ return openRouterChatApi();
758
+ case "openrouter-responses":
759
+ return openRouterResponsesApi();
703
760
  case "anthropic-messages":
704
761
  return anthropicMessagesApi();
705
762
  case "google-generative-ai":
@@ -708,7 +765,13 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
708
765
  return openAICodexResponsesApi();
709
766
  }
710
767
  })();
711
- const contextFor = (model: Model<Api>, context: Context): Context => ({
768
+ const api = protocol.startsWith("openrouter-")
769
+ ? withModelProtocolResponse(implementation, protocol as "openrouter-messages" | "openrouter-chat" | "openrouter-responses")
770
+ : implementation;
771
+ const contextFor = (
772
+ model: Model<Api>,
773
+ context: TranscriptContext,
774
+ ): TranscriptContext => ({
712
775
  ...context,
713
776
  messages: modelInputMessages(
714
777
  context.messages,
@@ -716,12 +779,12 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
716
779
  adapterHostedInputModalities(protocol),
717
780
  ),
718
781
  });
719
- return {
782
+ return overlayProviderStreams(api, {
720
783
  stream: (model, context, options) =>
721
784
  api.stream(model, contextFor(model, context), options),
722
785
  streamSimple: (model, context, options) =>
723
786
  api.streamSimple(model, contextFor(model, context), options),
724
- };
787
+ });
725
788
  }
726
789
 
727
790
  function anthropicMessagesModelForOpenRouter(
@@ -741,6 +804,28 @@ function anthropicMessagesModelForOpenRouter(
741
804
  // 把部署声明的模型改造成可由对应自定义端点执行的 Pi 模型。
742
805
  // resolvePiModel 为单个运行快照调用它,configurePiModels 则在注册端点时为端点中的每个 modelId 调用它。
743
806
  // Pi 的内置 OpenAI、Anthropic provider 提供上下文窗口、费用和推理能力等元数据;这里保留这些元数据,但以部署协议、provider 标识和 baseURL 覆盖内置路由,未知模型会在初始化阶段直接报错,而不是拖到请求阶段。
807
+ function assertModelMetadata(
808
+ metadata: RuntimeModelMetadata,
809
+ modelId: string,
810
+ ): void {
811
+ if (
812
+ typeof metadata.name !== "string" || !metadata.name.trim() ||
813
+ typeof metadata.reasoning !== "boolean" ||
814
+ !Number.isSafeInteger(metadata.contextWindow) || metadata.contextWindow <= 0 ||
815
+ !Number.isSafeInteger(metadata.maxTokens) || metadata.maxTokens <= 0 ||
816
+ metadata.maxTokens > metadata.contextWindow ||
817
+ !metadata.cost ||
818
+ [metadata.cost.input, metadata.cost.output, metadata.cost.cacheRead, metadata.cost.cacheWrite]
819
+ .some((rate) => !Number.isFinite(rate) || rate < 0) ||
820
+ Object.entries(metadata.thinkingLevelMap ?? {}).some(([level, value]) =>
821
+ !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level) ||
822
+ (value !== null && (typeof value !== "string" || !value.trim()))
823
+ )
824
+ ) {
825
+ throw new Error(`Invalid deployment model metadata: ${modelId}`);
826
+ }
827
+ }
828
+
744
829
  function configuredModel(
745
830
  endpoint: RuntimeModelEndpoint,
746
831
  index: number,
@@ -749,11 +834,28 @@ function configuredModel(
749
834
  const catalogModel = CATALOGS[endpoint.protocol].find(
750
835
  (model) => model.id === modelId,
751
836
  );
752
- if (!catalogModel) {
837
+ const explicitMetadata = endpoint.modelMetadata?.[modelId];
838
+ if (!catalogModel && !explicitMetadata) {
753
839
  throw new Error(
754
- `Model is not in SpringBrand's built-in catalog for ${endpoint.protocol}: ${modelId}`,
840
+ `Model is not in SpringBrand's built-in catalog and has no deployment metadata for ${endpoint.protocol}: ${modelId}`,
755
841
  );
756
842
  }
843
+ if (explicitMetadata) assertModelMetadata(explicitMetadata, modelId);
844
+ if (!catalogModel && !endpoint.modelInputModalities?.[modelId]?.includes("text")) {
845
+ throw new Error(`Deployment model requires explicit text input capability: ${modelId}`);
846
+ }
847
+ const sourceModel: Model<Api> = catalogModel ?? {
848
+ id: modelId,
849
+ name: explicitMetadata!.name,
850
+ api: apiFor(endpoint.protocol),
851
+ provider: "openrouter",
852
+ baseUrl: endpoint.baseURL,
853
+ reasoning: explicitMetadata!.reasoning,
854
+ input: ["text"],
855
+ cost: explicitMetadata!.cost,
856
+ contextWindow: explicitMetadata!.contextWindow,
857
+ maxTokens: explicitMetadata!.maxTokens,
858
+ };
757
859
  const {
758
860
  api: catalogApi,
759
861
  provider: _provider,
@@ -762,7 +864,7 @@ function configuredModel(
762
864
  headers: catalogHeaders,
763
865
  input: catalogInput,
764
866
  ...metadata
765
- } = catalogModel;
867
+ } = sourceModel;
766
868
  const headers = {
767
869
  ...catalogHeaders,
768
870
  ...endpoint.headers,
@@ -775,28 +877,82 @@ function configuredModel(
775
877
  (modality): modality is "text" | "image" =>
776
878
  modality === "text" || modality === "image",
777
879
  );
778
- // OpenRouter uses dotted versions and optional transport suffixes while the
779
- // Anthropic catalog uses dashed base model ids.
880
+ const isOpenRouter = endpoint.protocol.startsWith("openrouter-");
780
881
  const anthropicMessagesCompat = endpoint.protocol === "openrouter-messages"
781
882
  ? anthropicMessagesModelForOpenRouter(modelId)?.compat
782
883
  : undefined;
884
+ const openAIResponsesCompat = endpoint.protocol === "openrouter-responses" &&
885
+ modelId.startsWith("openai/")
886
+ ? CATALOGS["openai-chat"].find((model) =>
887
+ model.id === modelId.slice("openai/".length) && model.api === "openai-responses"
888
+ )?.compat
889
+ : undefined;
783
890
  return {
784
891
  ...metadata,
892
+ ...(explicitMetadata
893
+ ? {
894
+ name: explicitMetadata.name,
895
+ reasoning: explicitMetadata.reasoning,
896
+ contextWindow: explicitMetadata.contextWindow,
897
+ maxTokens: explicitMetadata.maxTokens,
898
+ cost: { ...explicitMetadata.cost },
899
+ ...(explicitMetadata.thinkingLevelMap
900
+ ? { thinkingLevelMap: { ...explicitMetadata.thinkingLevelMap } }
901
+ : {}),
902
+ }
903
+ : {}),
785
904
  input,
786
905
  api: configuredApi,
787
906
  provider: providerId(endpoint, index),
788
907
  baseUrl: endpoint.baseURL,
789
- ...(endpoint.protocol === "openrouter-messages"
908
+ ...(isOpenRouter
790
909
  ? {
791
910
  compat: {
792
- ...anthropicMessagesCompat,
793
- supportsMidConvoEffort: false,
794
- supportsEagerToolInputStreaming: false,
795
- supportsStrictTools:
796
- anthropicMessagesCompat?.supportsStrictTools ?? false,
797
- supportsToolReferences: true,
911
+ ...(endpoint.protocol === "openrouter-messages"
912
+ ? {
913
+ ...anthropicMessagesCompat,
914
+ // The direct Anthropic catalog enables positional system and Tool updates, but
915
+ // OpenRouter Messages rejects `tool_addition` definitions. Fold them into the
916
+ // leading system and top-level tools so deferred Tool decoration still applies.
917
+ supportsMidConvoSystemMessages: false,
918
+ supportsMidConvoToolChanges: false,
919
+ supportsMidConvoEffort: false,
920
+ supportsEagerToolInputStreaming: false,
921
+ supportsStrictTools:
922
+ anthropicMessagesCompat?.supportsStrictTools ?? false,
923
+ ...(modelId === "anthropic/claude-sonnet-5.5" ||
924
+ modelId === "anthropic/claude-opus-5.5"
925
+ ? { forceAdaptiveThinking: true }
926
+ : {}),
927
+ }
928
+ : endpoint.protocol === "openrouter-chat"
929
+ ? {
930
+ ...catalogCompat,
931
+ // OpenRouter Chat has no verified positional system or Tool update support.
932
+ supportsMidConvoSystemMessages: false,
933
+ supportsMidConvoToolChanges: false,
934
+ thinkingFormat: "openrouter" as const,
935
+ maxTokensField: "max_tokens" as const,
936
+ supportsStore: false,
937
+ sessionAffinityFormat: "openrouter" as const,
938
+ sendSessionAffinityHeaders: true,
939
+ ...(modelId.startsWith("anthropic/")
940
+ ? { cacheControlFormat: "anthropic" as const }
941
+ : {}),
942
+ }
943
+ : {
944
+ sessionAffinityFormat: "openrouter" as const,
945
+ supportsDeveloperRole: modelId.startsWith("openai/") ||
946
+ modelId.startsWith("anthropic/"),
947
+ supportsStrictMode: openAIResponsesCompat?.supportsStrictMode ?? false,
948
+ supportsOpenAIGrammarTools:
949
+ openAIResponsesCompat?.supportsOpenAIGrammarTools ?? false,
950
+ }),
798
951
  openRouterRouting: {
799
952
  require_parameters: true,
953
+ ...(endpoint.openRouterProviderSort
954
+ ? { sort: endpoint.openRouterProviderSort }
955
+ : {}),
800
956
  ...(openRouterProviderPin
801
957
  ? {
802
958
  order: [openRouterProviderPin],
@@ -848,6 +1004,13 @@ export function configurePiModels(
848
1004
  models: MutableModels,
849
1005
  provider: RuntimeProviderPort,
850
1006
  ): MutableModels {
1007
+ for (const endpoint of provider.endpoints) {
1008
+ for (const modelId of Object.keys(endpoint.modelMetadata ?? {})) {
1009
+ if (!endpoint.models.includes(modelId)) {
1010
+ throw new Error(`Deployment metadata is not configured for this endpoint: ${modelId}`);
1011
+ }
1012
+ }
1013
+ }
851
1014
  const configuredProviders = provider.endpoints.map((endpoint, index) =>
852
1015
  createProvider({
853
1016
  id: providerId(endpoint, index),
@@ -865,9 +1028,11 @@ export function configurePiModels(
865
1028
  models: endpoint.models.map((modelId) =>
866
1029
  configuredModel(endpoint, index, modelId),
867
1030
  ),
868
- // cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
869
- // 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
870
- api: cloudflareStreams(piApiFor(endpoint)),
1031
+ // remapProviderStreamModels 在运行时把 baseURL 里的
1032
+ // {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID} 占位符替换成真实值;
1033
+ // 如果 baseURL 不含占位符则直接透传。Pi 自带的 cloudflareStreams 只保留
1034
+ // stream / streamSimple,这里 spread 其余方法以免丢掉 deferred 入口。
1035
+ api: remapProviderStreamModels(piApiFor(endpoint), resolveCloudflareModel),
871
1036
  })
872
1037
  );
873
1038
  models.clearProviders();
@@ -1,12 +1,14 @@
1
1
  import type {
2
- Context,
3
2
  Model,
4
3
  ProviderStreams,
5
4
  StreamOptions,
5
+ TranscriptContext,
6
6
  } from "@earendil-works/pi-ai";
7
+ import { getCurrentTools } from "@earendil-works/pi-ai";
7
8
  import {
8
9
  anthropicMessagesApi,
9
10
  } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
11
+ import { overlayProviderStreams } from "../provider-streams";
10
12
 
11
13
  type Payload = Record<string, unknown>;
12
14
 
@@ -21,14 +23,16 @@ function record(value: unknown): Payload | undefined {
21
23
  function decoratePayload(
22
24
  payload: unknown,
23
25
  model: Model<"anthropic-messages">,
24
- context: Context,
26
+ context: TranscriptContext,
25
27
  sessionId?: string,
26
28
  ): unknown {
27
29
  const body = record(payload);
28
30
  if (!body) throw new Error("OpenRouter Messages payload must be an object");
29
31
 
30
32
  const deferredNames = new Set(
31
- context.tools?.filter((tool) => tool.deferLoading).map((tool) => tool.name),
33
+ getCurrentTools(context.messages)
34
+ .filter((tool) => tool.deferLoading)
35
+ .map((tool) => tool.name),
32
36
  );
33
37
  const tools = Array.isArray(body.tools) ? body.tools : [];
34
38
  const immediate: unknown[] = [];
@@ -80,7 +84,7 @@ function decoratePayload(
80
84
 
81
85
  function openRouterOptions(
82
86
  model: Model<"anthropic-messages">,
83
- context: Context,
87
+ context: TranscriptContext,
84
88
  options: StreamOptions | undefined,
85
89
  ): StreamOptions {
86
90
  const apiKey = options?.apiKey;
@@ -107,9 +111,10 @@ function openRouterOptions(
107
111
  };
108
112
  }
109
113
 
110
- export function openRouterMessagesApi(): ProviderStreams {
111
- const anthropic = anthropicMessagesApi();
112
- return {
114
+ export function openRouterMessagesApi(
115
+ anthropic: ProviderStreams = anthropicMessagesApi(),
116
+ ): ProviderStreams {
117
+ return overlayProviderStreams(anthropic, {
113
118
  stream: (model, context, options) =>
114
119
  anthropic.stream(
115
120
  model,
@@ -130,5 +135,5 @@ export function openRouterMessagesApi(): ProviderStreams {
130
135
  options,
131
136
  ),
132
137
  ),
133
- };
138
+ });
134
139
  }
@@ -0,0 +1,200 @@
1
+ import type {
2
+ Context,
3
+ Model,
4
+ ProviderStreams,
5
+ StreamOptions,
6
+ TranscriptContext,
7
+ } from "@earendil-works/pi-ai";
8
+ import { getCurrentTools } from "@earendil-works/pi-ai";
9
+ import {
10
+ openAICompletionsApi,
11
+ } from "@earendil-works/pi-ai/api/openai-completions.lazy";
12
+ import {
13
+ openAIResponsesApi,
14
+ } from "@earendil-works/pi-ai/api/openai-responses.lazy";
15
+ import { overlayProviderStreams } from "../provider-streams";
16
+
17
+ function record(value: unknown): Record<string, unknown> | undefined {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value)
19
+ ? value as Record<string, unknown>
20
+ : undefined;
21
+ }
22
+
23
+ function openRouterOptions(
24
+ model: Model<"openai-completions" | "openai-responses">,
25
+ context: TranscriptContext,
26
+ options?: StreamOptions,
27
+ ): StreamOptions {
28
+ return {
29
+ ...options,
30
+ onPayload: async (payload, activeModel) => {
31
+ const callerPayload = await options?.onPayload?.(payload, activeModel) ?? payload;
32
+ const body = record(callerPayload);
33
+ if (!body) throw new Error("OpenRouter payload must be an object");
34
+ const replayed = model.api === "openai-responses"
35
+ ? responsesToolSearch(replayResponsesMessages(body, model, context), context)
36
+ : body;
37
+ if (model.api === "openai-responses") {
38
+ if (replayed.store !== false || replayed.previous_response_id != null) {
39
+ throw new Error("OpenRouter Responses requires store: false and full input history");
40
+ }
41
+ if (replayed.background === true) {
42
+ throw new Error("Background Responses cannot run in a streaming Agent turn");
43
+ }
44
+ }
45
+ const compat = model.compat as {
46
+ openRouterRouting?: Readonly<Record<string, unknown>>;
47
+ } | undefined;
48
+ const reasoning = record(replayed.reasoning);
49
+ const include = Array.isArray(replayed.include) ? replayed.include : [];
50
+ return {
51
+ ...replayed,
52
+ ...(model.api === "openai-responses" && reasoning?.effort !== undefined &&
53
+ reasoning.effort !== null && reasoning.effort !== "none" &&
54
+ !include.includes("reasoning.encrypted_content")
55
+ ? { include: [...include, "reasoning.encrypted_content"] }
56
+ : {}),
57
+ ...(options?.sessionId ? { session_id: options.sessionId } : {}),
58
+ provider: {
59
+ ...record(replayed.provider),
60
+ ...compat?.openRouterRouting,
61
+ require_parameters: true,
62
+ },
63
+ };
64
+ },
65
+ };
66
+ }
67
+
68
+ function responsesToolSearch(
69
+ body: Record<string, unknown>,
70
+ context: TranscriptContext,
71
+ ): Record<string, unknown> {
72
+ const deferredNames = new Set(
73
+ getCurrentTools(context.messages)
74
+ .filter((tool) => tool.deferLoading).map((tool) => tool.name),
75
+ );
76
+ if (deferredNames.size === 0) return body;
77
+ const tools = Array.isArray(body.tools) ? body.tools : [];
78
+ const toolName = (tool: unknown) => {
79
+ const name = record(tool)?.name;
80
+ return typeof name === "string" ? name : "";
81
+ };
82
+ const deferred = tools.filter((tool) => deferredNames.has(toolName(tool)));
83
+ if (deferred.length !== deferredNames.size) {
84
+ throw new Error("Deferred Runtime tools are missing from the Responses payload");
85
+ }
86
+ const choice = record(body.tool_choice);
87
+ if (body.tool_choice !== undefined && body.tool_choice !== "auto" &&
88
+ choice?.type !== "allowed_tools") {
89
+ throw new Error("tool_choice conflicts with openrouter:tool_search");
90
+ }
91
+ const hasSearch = tools.some((tool) =>
92
+ ["openrouter:tool_search", "tool_search"].includes(record(tool)?.type as string)
93
+ );
94
+ return {
95
+ ...body,
96
+ tools: [
97
+ ...(hasSearch ? [] : [{ type: "openrouter:tool_search", parameters: { max_results: 5 } }]),
98
+ ...tools.map((tool) => deferredNames.has(toolName(tool))
99
+ ? { ...record(tool), defer_loading: true }
100
+ : tool),
101
+ ],
102
+ };
103
+ }
104
+
105
+ function responsesOutput(message: Context["messages"][number]): Record<string, unknown>[] {
106
+ if (message.role !== "assistant") return [];
107
+ if (!message.protocolResponse?.output) return [];
108
+ return message.protocolResponse.output.map((item) => ({ ...item }));
109
+ }
110
+
111
+ function sameResponseItem(
112
+ generated: Record<string, unknown> | undefined,
113
+ original: Record<string, unknown>,
114
+ ): boolean {
115
+ if (!generated || generated.type !== original.type ||
116
+ generated.id !== original.id) return false;
117
+ if (original.type === "message") {
118
+ const text = (content: unknown) => Array.isArray(content)
119
+ ? content.map((block) => {
120
+ const part = record(block);
121
+ return part?.type === "output_text" ? part.text : part?.refusal;
122
+ }).join("")
123
+ : content;
124
+ return text(generated.content) === text(original.content);
125
+ }
126
+ if (original.type === "function_call" || original.type === "custom_tool_call") {
127
+ return generated.call_id === original.call_id &&
128
+ generated.name === original.name;
129
+ }
130
+ return original.type === "reasoning";
131
+ }
132
+
133
+ function replayResponsesMessages(
134
+ body: Record<string, unknown>,
135
+ model: Model<"openai-completions" | "openai-responses">,
136
+ context: TranscriptContext,
137
+ ): Record<string, unknown> {
138
+ if (!Array.isArray(body.input)) return body;
139
+ const input = [...body.input];
140
+ for (const message of context.messages) {
141
+ if (
142
+ message.role !== "assistant" ||
143
+ message.provider !== model.provider ||
144
+ message.model !== model.id ||
145
+ message.api !== "openai-responses" ||
146
+ !message.protocolResponse?.output
147
+ ) continue;
148
+ const output = responsesOutput(message);
149
+ const anchors = output.filter((item) => [
150
+ "message", "reasoning", "function_call", "custom_tool_call",
151
+ ].includes(item.type as string));
152
+ if (anchors.length === 0) continue;
153
+ const start = input.findIndex((item) =>
154
+ sameResponseItem(record(item), anchors[0]!)
155
+ );
156
+ if (start < 0 || !anchors.every((item, index) =>
157
+ sameResponseItem(record(input[start + index]), item)
158
+ )) {
159
+ for (const item of output) {
160
+ if (item.type !== "message") continue;
161
+ const index = input.findIndex((value) => sameResponseItem(record(value), item));
162
+ if (index >= 0) input[index] = item;
163
+ }
164
+ continue;
165
+ }
166
+ input.splice(start, anchors.length, ...output.map((item) => {
167
+ if (item.type !== "reasoning" || item.encrypted_content) return item;
168
+ const original = record(input[start + anchors.indexOf(item)]);
169
+ return original?.encrypted_content
170
+ ? { ...item, encrypted_content: original.encrypted_content }
171
+ : item;
172
+ }));
173
+ }
174
+ return { ...body, input };
175
+ }
176
+
177
+ function routedApi(api: ProviderStreams): ProviderStreams {
178
+ return overlayProviderStreams(api, {
179
+ stream: (model, context, options) =>
180
+ api.stream(model, context, openRouterOptions(
181
+ model as Model<"openai-completions" | "openai-responses">,
182
+ context,
183
+ options,
184
+ )),
185
+ streamSimple: (model, context, options) =>
186
+ api.streamSimple(model, context, openRouterOptions(
187
+ model as Model<"openai-completions" | "openai-responses">,
188
+ context,
189
+ options,
190
+ )),
191
+ });
192
+ }
193
+
194
+ export function openRouterChatApi(): ProviderStreams {
195
+ return routedApi(openAICompletionsApi());
196
+ }
197
+
198
+ export function openRouterResponsesApi(): ProviderStreams {
199
+ return routedApi(openAIResponsesApi());
200
+ }