@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.
- package/package.json +11 -9
- package/src/adapter/cloudflare/agent.ts +233 -179
- package/src/adapter/cloudflare/define-agent.ts +252 -104
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +21 -2
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/activation.ts +13 -15
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +77 -49
- package/src/config/index.ts +1 -1
- package/src/config/model-guidance.ts +20 -0
- package/src/config/ports.ts +57 -7
- package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
- package/src/config/public-contracts.ts +2 -2
- package/src/config/runtime-config.ts +2 -1
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +28 -4
- package/src/conversation/message/conversion.ts +46 -5
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +269 -445
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +20 -30
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/lifecycle.ts +28 -15
- package/src/submission/repository.ts +97 -49
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/contract.ts +0 -1
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +0 -4
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +319 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
package/src/models/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
model.
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
...
|
|
154
|
-
|
|
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"
|
|
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:
|
|
494
|
-
|
|
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
|
-
):
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
} =
|
|
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
|
-
|
|
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
|
-
...(
|
|
908
|
+
...(isOpenRouter
|
|
790
909
|
? {
|
|
791
910
|
compat: {
|
|
792
|
-
...
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|