@springbrand/agent-runtime 0.3.0-alpha.14 → 0.3.0-alpha.15
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 +1 -1
- package/src/adapter/cloudflare/agent.ts +9 -5
- package/src/adapter/cloudflare/define-agent.ts +35 -19
- package/src/config/assembler.ts +19 -0
- package/src/config/pinning.ts +17 -2
- package/src/config/ports.ts +28 -0
- package/src/index.ts +10 -1
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +156 -24
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +161 -0
- package/src/tools/subagent/temporary/core.ts +1 -2
- package/src/tools/web/search/api.ts +2 -0
package/package.json
CHANGED
|
@@ -156,11 +156,16 @@ export abstract class AgentRuntimeKernel<
|
|
|
156
156
|
};
|
|
157
157
|
|
|
158
158
|
protected abstract ensureRuntimeReady(): Promise<void>;
|
|
159
|
-
/**
|
|
159
|
+
/** Prepare per-Submission Host state before the first assembly pin. */
|
|
160
160
|
protected prepareRuntimeForSubmission(_submissionId: string): Promise<void> {
|
|
161
161
|
return this.ensureRuntimeReady();
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Runs before a new Submission records its Runtime key. */
|
|
165
|
+
protected prepareRuntimeForAdmission(): Promise<void> {
|
|
166
|
+
return this.ensureRuntimeReady();
|
|
167
|
+
}
|
|
168
|
+
|
|
164
169
|
protected ensureRuntimeVersion(_runtimeKey: string): Promise<void> {
|
|
165
170
|
return this.ensureRuntimeReady();
|
|
166
171
|
}
|
|
@@ -513,13 +518,12 @@ export abstract class AgentRuntimeKernel<
|
|
|
513
518
|
|
|
514
519
|
// #region Submission 准入与配置固定
|
|
515
520
|
|
|
516
|
-
/** Pin dynamic Context and execution policy before
|
|
521
|
+
/** Pin dynamic Context and execution policy before the first assembly is used. */
|
|
517
522
|
private async admissionPin(fresh: boolean, submissionId: string): Promise<{
|
|
518
523
|
revision: string;
|
|
519
524
|
descriptor: string;
|
|
520
525
|
}> {
|
|
521
526
|
if (fresh) await this.prepareRuntimeForSubmission(submissionId);
|
|
522
|
-
else await this.ensureRuntimeReady();
|
|
523
527
|
const submission = this.db.submissions.find(submissionId);
|
|
524
528
|
if (!submission) {
|
|
525
529
|
throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
|
|
@@ -571,7 +575,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
571
575
|
submissions: this.db.submissions,
|
|
572
576
|
transaction: (run) => this.db.transaction(run),
|
|
573
577
|
prepareRuntime: async () => {
|
|
574
|
-
await this.
|
|
578
|
+
await this.prepareRuntimeForAdmission();
|
|
575
579
|
return { runtimeKey: this.activeRuntime!.runtimeKey, admission: this.activeRuntime?.admission };
|
|
576
580
|
},
|
|
577
581
|
findUserMessage: (id) => this.transcript.findUserMessage(id),
|
|
@@ -777,7 +781,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
777
781
|
submission = await this.admission.pin(
|
|
778
782
|
submission,
|
|
779
783
|
() => this.admissionPin(mode === "fresh", submissionId),
|
|
780
|
-
|
|
784
|
+
(key) => this.ensureRuntimeVersion(key),
|
|
781
785
|
);
|
|
782
786
|
const activeRuntime = this.requireRuntime();
|
|
783
787
|
const harnessInput = this.pi.harnessInputs(
|
|
@@ -17,7 +17,7 @@ import type { RuntimeState } from "../../conversation";
|
|
|
17
17
|
import type {
|
|
18
18
|
TemporaryAgentApprovalDecision,
|
|
19
19
|
TemporaryAgentApprovalRequest,
|
|
20
|
-
|
|
20
|
+
TemporaryAgentLaunchSelection,
|
|
21
21
|
} from "../../tools";
|
|
22
22
|
import { TEMPORARY_AGENT_LAUNCH_KEY } from "../../tools";
|
|
23
23
|
import {
|
|
@@ -83,7 +83,7 @@ export interface LoadedRuntime<Config> {
|
|
|
83
83
|
export interface RuntimeConfigUpdate<Change> {
|
|
84
84
|
readonly changed: boolean;
|
|
85
85
|
readonly change: Change;
|
|
86
|
-
/** Restore
|
|
86
|
+
/** Restore committed state if an immediate replacement fails. */
|
|
87
87
|
readonly rollback?: () => Promise<void>;
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -96,11 +96,16 @@ interface RuntimeAgentDefinitionBase<
|
|
|
96
96
|
/** 必须等于 Worker 导出名(Wrangler `class_name`)。 */
|
|
97
97
|
readonly name: string;
|
|
98
98
|
|
|
99
|
-
/** Refresh Host-owned inputs before a new Submission
|
|
99
|
+
/** Refresh Host-owned inputs before a new Submission records its Runtime key. */
|
|
100
100
|
readonly beforeSubmission?: (
|
|
101
101
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
102
102
|
) => Promise<boolean>;
|
|
103
103
|
|
|
104
|
+
/** Prepare per-Submission Host state after admission and before its first assembly pin. */
|
|
105
|
+
readonly afterSubmission?: (
|
|
106
|
+
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
107
|
+
) => Promise<void>;
|
|
108
|
+
|
|
104
109
|
readonly load: (
|
|
105
110
|
context: RuntimeAgentPlanningContext<Env, Command, Change>,
|
|
106
111
|
requestedRuntimeKey?: string,
|
|
@@ -360,9 +365,7 @@ const TERMINAL_AGENT_TOOL_STATUSES = [
|
|
|
360
365
|
] as const;
|
|
361
366
|
const AGENT_TOOL_RETENTION_MS = 24 * 60 * 60 * 1_000;
|
|
362
367
|
|
|
363
|
-
function parseTemporaryAgentLaunch
|
|
364
|
-
input: unknown,
|
|
365
|
-
): TemporaryAgentLaunch<Config> {
|
|
368
|
+
function parseTemporaryAgentLaunch(input: unknown): TemporaryAgentLaunchSelection {
|
|
366
369
|
const record = input != null && typeof input === "object"
|
|
367
370
|
? input as Record<string, unknown>
|
|
368
371
|
: {};
|
|
@@ -380,17 +383,15 @@ function parseTemporaryAgentLaunch<Config>(
|
|
|
380
383
|
) {
|
|
381
384
|
throw new Error("invalid temporary agent executionLevel");
|
|
382
385
|
}
|
|
383
|
-
|
|
384
|
-
throw new Error("invalid temporary agent config");
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
386
|
+
const selection: TemporaryAgentLaunchSelection = {
|
|
387
387
|
subagentName: read("subagentName", 128),
|
|
388
388
|
instructions: read("instructions", 65_536),
|
|
389
389
|
task: read("task", 65_536),
|
|
390
390
|
runtimeKey: read("runtimeKey", 512),
|
|
391
|
-
config: record.config as Config,
|
|
392
391
|
executionLevel: executionLevel as ExecutionLevel,
|
|
393
392
|
};
|
|
393
|
+
if ("config" in record) throw new Error("unexpected temporary agent config");
|
|
394
|
+
return selection;
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
/**
|
|
@@ -428,7 +429,7 @@ export function defineRuntimeAgent<
|
|
|
428
429
|
private loading?: Promise<void>;
|
|
429
430
|
private telemetryResolved = false;
|
|
430
431
|
private resolvedTelemetry?: AgentTelemetryBinding;
|
|
431
|
-
private temporaryLaunch?:
|
|
432
|
+
private temporaryLaunch?: TemporaryAgentLaunchSelection;
|
|
432
433
|
private temporaryDispose?: () => Promise<void>;
|
|
433
434
|
private temporaryCleanup?: Promise<void>;
|
|
434
435
|
|
|
@@ -449,10 +450,10 @@ export function defineRuntimeAgent<
|
|
|
449
450
|
return this.ensureConfig(runtimeKey);
|
|
450
451
|
}
|
|
451
452
|
|
|
452
|
-
protected async
|
|
453
|
+
protected async prepareRuntimeForAdmission(): Promise<void> {
|
|
453
454
|
const beforeSubmission = definition.beforeSubmission as RuntimeAgentDefinitionBase<Env, Config, Command, Change>["beforeSubmission"];
|
|
454
455
|
try {
|
|
455
|
-
const changed = await beforeSubmission?.(this.createDefinitionContext(
|
|
456
|
+
const changed = await beforeSubmission?.(this.createDefinitionContext());
|
|
456
457
|
await this.ensureConfig(undefined, changed === true);
|
|
457
458
|
} catch (error) {
|
|
458
459
|
this.hasLoadedRuntime = false;
|
|
@@ -460,6 +461,17 @@ export function defineRuntimeAgent<
|
|
|
460
461
|
}
|
|
461
462
|
}
|
|
462
463
|
|
|
464
|
+
protected async prepareRuntimeForSubmission(submissionId: string): Promise<void> {
|
|
465
|
+
const afterSubmission = definition.afterSubmission as RuntimeAgentDefinitionBase<Env, Config, Command, Change>["afterSubmission"];
|
|
466
|
+
try {
|
|
467
|
+
await this.ensureRuntimeReady();
|
|
468
|
+
await afterSubmission?.(this.createDefinitionContext(undefined, submissionId));
|
|
469
|
+
} catch (error) {
|
|
470
|
+
this.hasLoadedRuntime = false;
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
463
475
|
// 作用:加载默认 Runtime,或切换到调用方指定的 key。
|
|
464
476
|
// 调用:RPC 控制面在业务配置需要刷新时调用。
|
|
465
477
|
// 原因:复用 `ensureConfig`,让手动重载遵守和首次启动相同的原子规则。
|
|
@@ -551,7 +563,7 @@ export function defineRuntimeAgent<
|
|
|
551
563
|
if (this.role !== "temporary") {
|
|
552
564
|
throw new Error("Agent Tool runs require a temporary child facet");
|
|
553
565
|
}
|
|
554
|
-
const launch = parseTemporaryAgentLaunch
|
|
566
|
+
const launch = parseTemporaryAgentLaunch(input);
|
|
555
567
|
this.temporaryLaunch = launch;
|
|
556
568
|
await this.ctx.storage.put(TEMPORARY_AGENT_LAUNCH_KEY, launch);
|
|
557
569
|
await this.reloadRuntime(launch.runtimeKey, { force: true });
|
|
@@ -624,6 +636,11 @@ export function defineRuntimeAgent<
|
|
|
624
636
|
if (!resolvedRuntimeKey) {
|
|
625
637
|
throw new Error("Resolved Runtime key must not be empty");
|
|
626
638
|
}
|
|
639
|
+
if (requestedRuntimeKey && resolvedRuntimeKey !== requestedRuntimeKey) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Requested Runtime key ${requestedRuntimeKey} resolved to ${resolvedRuntimeKey}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
627
644
|
const context = this.createDefinitionContext({
|
|
628
645
|
value: loaded.config,
|
|
629
646
|
});
|
|
@@ -723,7 +740,7 @@ export function defineRuntimeAgent<
|
|
|
723
740
|
resources: ResolvedResources,
|
|
724
741
|
): Promise<ResolvedResources> {
|
|
725
742
|
const launch = this.temporaryLaunch ??
|
|
726
|
-
await this.ctx.storage.get<
|
|
743
|
+
await this.ctx.storage.get<TemporaryAgentLaunchSelection>(
|
|
727
744
|
TEMPORARY_AGENT_LAUNCH_KEY,
|
|
728
745
|
);
|
|
729
746
|
if (!launch) throw new Error("Temporary Agent is not initialized");
|
|
@@ -754,7 +771,7 @@ export function defineRuntimeAgent<
|
|
|
754
771
|
}
|
|
755
772
|
|
|
756
773
|
private async requestParentTemporaryApproval(
|
|
757
|
-
launch:
|
|
774
|
+
launch: TemporaryAgentLaunchSelection,
|
|
758
775
|
request: {
|
|
759
776
|
toolCallId: string;
|
|
760
777
|
toolName: string;
|
|
@@ -820,7 +837,7 @@ export function defineRuntimeAgent<
|
|
|
820
837
|
this.temporaryCleanup = (async () => {
|
|
821
838
|
if (!this.temporaryDispose) {
|
|
822
839
|
const launch = this.temporaryLaunch ??
|
|
823
|
-
await this.ctx.storage.get<
|
|
840
|
+
await this.ctx.storage.get<TemporaryAgentLaunchSelection>(
|
|
824
841
|
TEMPORARY_AGENT_LAUNCH_KEY,
|
|
825
842
|
);
|
|
826
843
|
if (launch) await this.ensureConfig(launch.runtimeKey);
|
|
@@ -890,7 +907,6 @@ export function defineRuntimeAgent<
|
|
|
890
907
|
input: {
|
|
891
908
|
...normalized,
|
|
892
909
|
runtimeKey: agent.loadedRuntimeKey,
|
|
893
|
-
config: runtimeConfig.value,
|
|
894
910
|
executionLevel: agent.executionLevel(),
|
|
895
911
|
},
|
|
896
912
|
runId: `temporary-agent:${runContext.toolCallId}`,
|
package/src/config/assembler.ts
CHANGED
|
@@ -583,6 +583,9 @@ class RuntimeBuilder {
|
|
|
583
583
|
? { headers: Object.freeze({ ...endpoint.headers }) }
|
|
584
584
|
: {}),
|
|
585
585
|
models: Object.freeze([...endpoint.models]),
|
|
586
|
+
...(endpoint.openRouterProviderPins
|
|
587
|
+
? { openRouterProviderPins: Object.freeze({ ...endpoint.openRouterProviderPins }) }
|
|
588
|
+
: {}),
|
|
586
589
|
...(endpoint.modelInputModalities
|
|
587
590
|
? {
|
|
588
591
|
modelInputModalities: Object.freeze(Object.fromEntries(
|
|
@@ -592,6 +595,22 @@ class RuntimeBuilder {
|
|
|
592
595
|
)),
|
|
593
596
|
}
|
|
594
597
|
: {}),
|
|
598
|
+
...(endpoint.modelMetadata
|
|
599
|
+
? {
|
|
600
|
+
modelMetadata: Object.freeze(Object.fromEntries(
|
|
601
|
+
Object.entries(endpoint.modelMetadata).map(([modelId, metadata]) => [
|
|
602
|
+
modelId,
|
|
603
|
+
Object.freeze({
|
|
604
|
+
...metadata,
|
|
605
|
+
cost: Object.freeze({ ...metadata.cost }),
|
|
606
|
+
...(metadata.thinkingLevelMap
|
|
607
|
+
? { thinkingLevelMap: Object.freeze({ ...metadata.thinkingLevelMap }) }
|
|
608
|
+
: {}),
|
|
609
|
+
}),
|
|
610
|
+
]),
|
|
611
|
+
)),
|
|
612
|
+
}
|
|
613
|
+
: {}),
|
|
595
614
|
}),
|
|
596
615
|
)),
|
|
597
616
|
});
|
package/src/config/pinning.ts
CHANGED
|
@@ -283,10 +283,25 @@ function describeRuntime(
|
|
|
283
283
|
candidates: readonly PiToolCandidate[],
|
|
284
284
|
loadedExtensions: readonly PiLoadedExtension[],
|
|
285
285
|
): string {
|
|
286
|
+
const model = snapshot.pi.model;
|
|
287
|
+
const openRouterRouting = model.compat && "openRouterRouting" in model.compat
|
|
288
|
+
? model.compat.openRouterRouting
|
|
289
|
+
: undefined;
|
|
286
290
|
return JSON.stringify({
|
|
287
291
|
version: 1,
|
|
288
|
-
model:
|
|
289
|
-
provider:
|
|
292
|
+
model: model.id,
|
|
293
|
+
provider: model.provider,
|
|
294
|
+
modelConfig: {
|
|
295
|
+
api: model.api,
|
|
296
|
+
name: model.name,
|
|
297
|
+
reasoning: model.reasoning,
|
|
298
|
+
input: model.input,
|
|
299
|
+
contextWindow: model.contextWindow,
|
|
300
|
+
maxTokens: model.maxTokens,
|
|
301
|
+
cost: model.cost,
|
|
302
|
+
...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
|
|
303
|
+
...(openRouterRouting ? { openRouterRouting } : {}),
|
|
304
|
+
},
|
|
290
305
|
thinking: snapshot.pi.thinkingLevel,
|
|
291
306
|
systemPrompt: snapshot.pi.systemPrompt,
|
|
292
307
|
executionLevel: snapshot.profile.executionLevel,
|
package/src/config/ports.ts
CHANGED
|
@@ -950,6 +950,8 @@ export interface RuntimeBrowserPort {
|
|
|
950
950
|
export type RuntimeModelProtocol =
|
|
951
951
|
| "openai-chat"
|
|
952
952
|
| "openrouter-messages"
|
|
953
|
+
| "openrouter-chat"
|
|
954
|
+
| "openrouter-responses"
|
|
953
955
|
| "anthropic-messages"
|
|
954
956
|
| "google-generative-ai"
|
|
955
957
|
| "openai-codex-responses";
|
|
@@ -963,6 +965,31 @@ export type RuntimeModelProtocol =
|
|
|
963
965
|
*
|
|
964
966
|
* 凭据只留在 Port 对象,不进入 `RuntimeProfile` 或持久状态;术语见 `../index.ts`。
|
|
965
967
|
*/
|
|
968
|
+
export type RuntimeModelThinkingLevel =
|
|
969
|
+
| "off"
|
|
970
|
+
| "minimal"
|
|
971
|
+
| "low"
|
|
972
|
+
| "medium"
|
|
973
|
+
| "high"
|
|
974
|
+
| "xhigh"
|
|
975
|
+
| "max";
|
|
976
|
+
|
|
977
|
+
export interface RuntimeModelMetadata {
|
|
978
|
+
readonly name: string;
|
|
979
|
+
readonly reasoning: boolean;
|
|
980
|
+
readonly contextWindow: number;
|
|
981
|
+
readonly maxTokens: number;
|
|
982
|
+
readonly cost: Readonly<{
|
|
983
|
+
input: number;
|
|
984
|
+
output: number;
|
|
985
|
+
cacheRead: number;
|
|
986
|
+
cacheWrite: number;
|
|
987
|
+
}>;
|
|
988
|
+
readonly thinkingLevelMap?: Readonly<
|
|
989
|
+
Partial<Record<RuntimeModelThinkingLevel, string | null>>
|
|
990
|
+
>;
|
|
991
|
+
}
|
|
992
|
+
|
|
966
993
|
export interface RuntimeModelEndpoint {
|
|
967
994
|
protocol: RuntimeModelProtocol;
|
|
968
995
|
/** Resolved credential; never enters RuntimeProfile or persisted state. */
|
|
@@ -977,6 +1004,7 @@ export interface RuntimeModelEndpoint {
|
|
|
977
1004
|
>;
|
|
978
1005
|
/** OpenRouter upstream provider pins keyed by model ID; pinned requests never fall back. */
|
|
979
1006
|
openRouterProviderPins?: Readonly<Record<string, string>>;
|
|
1007
|
+
modelMetadata?: Readonly<Record<string, RuntimeModelMetadata>>;
|
|
980
1008
|
}
|
|
981
1009
|
|
|
982
1010
|
/**
|
package/src/index.ts
CHANGED
|
@@ -127,7 +127,7 @@ export {
|
|
|
127
127
|
export type {
|
|
128
128
|
TemporaryAgentApprovalDecision,
|
|
129
129
|
TemporaryAgentApprovalRequest,
|
|
130
|
-
|
|
130
|
+
TemporaryAgentLaunchSelection,
|
|
131
131
|
TemporaryAgentRequest,
|
|
132
132
|
TemporaryAgentRunContext,
|
|
133
133
|
} from "./tools/subagent/temporary/core";
|
|
@@ -144,3 +144,12 @@ export {
|
|
|
144
144
|
modelRequestUrl,
|
|
145
145
|
resolvePiModel,
|
|
146
146
|
} from "./models";
|
|
147
|
+
export {
|
|
148
|
+
openRouterRequestUrl,
|
|
149
|
+
requestOpenRouter,
|
|
150
|
+
} from "./model-protocol/openrouter";
|
|
151
|
+
export type { OpenRouterRequestOptions } from "./model-protocol/openrouter";
|
|
152
|
+
export type {
|
|
153
|
+
ModelProtocolResponse,
|
|
154
|
+
OpenRouterProtocol,
|
|
155
|
+
} from "./models/openrouter/protocol-response";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RuntimeModelEndpoint } from "../config/ports";
|
|
2
|
+
|
|
3
|
+
export interface OpenRouterRequestOptions {
|
|
4
|
+
readonly headers?: HeadersInit;
|
|
5
|
+
readonly signal?: AbortSignal;
|
|
6
|
+
readonly fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openRouterRequestUrl(
|
|
10
|
+
endpoint: Pick<RuntimeModelEndpoint, "protocol" | "baseURL">,
|
|
11
|
+
): string {
|
|
12
|
+
const baseURL = endpoint.baseURL.replace(/\/+$/, "");
|
|
13
|
+
switch (endpoint.protocol) {
|
|
14
|
+
case "openrouter-messages":
|
|
15
|
+
return `${baseURL}/v1/messages`;
|
|
16
|
+
case "openrouter-chat":
|
|
17
|
+
return `${baseURL}/chat/completions`;
|
|
18
|
+
case "openrouter-responses":
|
|
19
|
+
return `${baseURL}/responses`;
|
|
20
|
+
default:
|
|
21
|
+
throw new Error(`Not an OpenRouter protocol: ${endpoint.protocol}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requestOpenRouter(
|
|
26
|
+
endpoint: RuntimeModelEndpoint,
|
|
27
|
+
body: Readonly<Record<string, unknown>>,
|
|
28
|
+
options: OpenRouterRequestOptions = {},
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
const url = openRouterRequestUrl(endpoint);
|
|
31
|
+
const headers = new Headers(endpoint.headers);
|
|
32
|
+
new Headers(options.headers).forEach((value, name) => headers.set(name, value));
|
|
33
|
+
headers.set("content-type", "application/json");
|
|
34
|
+
headers.set("authorization", `Bearer ${endpoint.apiKey}`);
|
|
35
|
+
return (options.fetch ?? globalThis.fetch)(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/models/index.ts
CHANGED
|
@@ -48,12 +48,15 @@ import {
|
|
|
48
48
|
} from "agents/chat";
|
|
49
49
|
import type {
|
|
50
50
|
RuntimeModelEndpoint,
|
|
51
|
+
RuntimeModelMetadata,
|
|
51
52
|
RuntimeModelInputModality,
|
|
52
53
|
RuntimeModelProtocol,
|
|
53
54
|
RuntimeProviderPort,
|
|
54
55
|
} from "../config/ports";
|
|
55
56
|
import { modelInputMessages } from "../conversation/message/model-input";
|
|
56
57
|
import { openRouterMessagesApi } from "./openrouter/messages";
|
|
58
|
+
import { openRouterChatApi, openRouterResponsesApi } from "./openrouter/openai";
|
|
59
|
+
import { withModelProtocolResponse } from "./openrouter/protocol-response";
|
|
57
60
|
import {
|
|
58
61
|
overlayProviderStreams,
|
|
59
62
|
remapProviderStreamModels,
|
|
@@ -62,6 +65,8 @@ import {
|
|
|
62
65
|
const CATALOGS = {
|
|
63
66
|
"openai-chat": openaiProvider().getModels(),
|
|
64
67
|
"openrouter-messages": openrouterProvider().getModels(),
|
|
68
|
+
"openrouter-chat": openrouterProvider().getModels(),
|
|
69
|
+
"openrouter-responses": openrouterProvider().getModels(),
|
|
65
70
|
"anthropic-messages": anthropicProvider().getModels(),
|
|
66
71
|
"google-generative-ai": googleProvider().getModels(),
|
|
67
72
|
"openai-codex-responses": openaiCodexProvider().getModels(),
|
|
@@ -73,7 +78,7 @@ const NO_HOSTED_INPUT_MODALITIES: readonly RuntimeModelInputModality[] = [];
|
|
|
73
78
|
function adapterHostedInputModalities(
|
|
74
79
|
protocol: RuntimeModelProtocol,
|
|
75
80
|
): readonly RuntimeModelInputModality[] {
|
|
76
|
-
return protocol
|
|
81
|
+
return protocol.startsWith("openrouter-") || protocol === "anthropic-messages"
|
|
77
82
|
? MESSAGES_HOSTED_INPUT_MODALITIES
|
|
78
83
|
: NO_HOSTED_INPUT_MODALITIES;
|
|
79
84
|
}
|
|
@@ -148,10 +153,13 @@ export function structuredOutputStream(
|
|
|
148
153
|
model: Model<Api>,
|
|
149
154
|
schema: Record<string, unknown>,
|
|
150
155
|
): StreamFn | undefined {
|
|
151
|
-
|
|
152
|
-
model.
|
|
153
|
-
|
|
154
|
-
|
|
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;
|
|
155
163
|
|
|
156
164
|
return (activeModel, context, options) =>
|
|
157
165
|
streamFn(activeModel, context, {
|
|
@@ -161,14 +169,36 @@ export function structuredOutputStream(
|
|
|
161
169
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
162
170
|
throw new Error("Structured model payload must be an object");
|
|
163
171
|
}
|
|
164
|
-
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;
|
|
165
195
|
return {
|
|
166
|
-
...
|
|
167
|
-
|
|
196
|
+
...body,
|
|
197
|
+
text: {
|
|
168
198
|
...(current && typeof current === "object" && !Array.isArray(current)
|
|
169
199
|
? current
|
|
170
200
|
: {}),
|
|
171
|
-
format: { type: "json_schema", schema },
|
|
201
|
+
format: { type: "json_schema", name: "agent_output", strict: true, schema },
|
|
172
202
|
},
|
|
173
203
|
};
|
|
174
204
|
},
|
|
@@ -264,7 +294,8 @@ function pdfPayload(payload: unknown, api: Api): unknown {
|
|
|
264
294
|
};
|
|
265
295
|
}
|
|
266
296
|
}
|
|
267
|
-
if (api === "openai-codex-responses"
|
|
297
|
+
if ((api === "openai-codex-responses" || api === "openai-responses") &&
|
|
298
|
+
value.type === "input_image") {
|
|
268
299
|
if (
|
|
269
300
|
typeof value.image_url === "string" &&
|
|
270
301
|
value.image_url.startsWith("data:application/pdf;base64,")
|
|
@@ -483,6 +514,8 @@ export function modelRequestUrl(model: Model<Api>): string {
|
|
|
483
514
|
return `${base}/v1/messages`;
|
|
484
515
|
case "google-generative-ai":
|
|
485
516
|
return `${base}/models/${model.id}:streamGenerateContent`;
|
|
517
|
+
case "openai-responses":
|
|
518
|
+
return `${base}/responses`;
|
|
486
519
|
case "openai-codex-responses":
|
|
487
520
|
return base.endsWith("/codex/responses") || base.endsWith("/responses")
|
|
488
521
|
? base
|
|
@@ -697,7 +730,10 @@ function providerId(endpoint: RuntimeModelEndpoint, index: number): string {
|
|
|
697
730
|
function apiFor(protocol: RuntimeModelProtocol): Api {
|
|
698
731
|
switch (protocol) {
|
|
699
732
|
case "openai-chat":
|
|
733
|
+
case "openrouter-chat":
|
|
700
734
|
return "openai-completions";
|
|
735
|
+
case "openrouter-responses":
|
|
736
|
+
return "openai-responses";
|
|
701
737
|
case "openrouter-messages":
|
|
702
738
|
case "anthropic-messages":
|
|
703
739
|
return "anthropic-messages";
|
|
@@ -710,12 +746,16 @@ function apiFor(protocol: RuntimeModelProtocol): Api {
|
|
|
710
746
|
|
|
711
747
|
function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
|
|
712
748
|
const protocol = endpoint.protocol;
|
|
713
|
-
const
|
|
749
|
+
const implementation = (() => {
|
|
714
750
|
switch (protocol) {
|
|
715
751
|
case "openai-chat":
|
|
716
752
|
return openAICompletionsApi();
|
|
717
753
|
case "openrouter-messages":
|
|
718
754
|
return openRouterMessagesApi();
|
|
755
|
+
case "openrouter-chat":
|
|
756
|
+
return openRouterChatApi();
|
|
757
|
+
case "openrouter-responses":
|
|
758
|
+
return openRouterResponsesApi();
|
|
719
759
|
case "anthropic-messages":
|
|
720
760
|
return anthropicMessagesApi();
|
|
721
761
|
case "google-generative-ai":
|
|
@@ -724,6 +764,9 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
|
|
|
724
764
|
return openAICodexResponsesApi();
|
|
725
765
|
}
|
|
726
766
|
})();
|
|
767
|
+
const api = protocol.startsWith("openrouter-")
|
|
768
|
+
? withModelProtocolResponse(implementation, protocol as "openrouter-messages" | "openrouter-chat" | "openrouter-responses")
|
|
769
|
+
: implementation;
|
|
727
770
|
const contextFor = (
|
|
728
771
|
model: Model<Api>,
|
|
729
772
|
context: TranscriptContext,
|
|
@@ -760,6 +803,28 @@ function anthropicMessagesModelForOpenRouter(
|
|
|
760
803
|
// 把部署声明的模型改造成可由对应自定义端点执行的 Pi 模型。
|
|
761
804
|
// resolvePiModel 为单个运行快照调用它,configurePiModels 则在注册端点时为端点中的每个 modelId 调用它。
|
|
762
805
|
// Pi 的内置 OpenAI、Anthropic provider 提供上下文窗口、费用和推理能力等元数据;这里保留这些元数据,但以部署协议、provider 标识和 baseURL 覆盖内置路由,未知模型会在初始化阶段直接报错,而不是拖到请求阶段。
|
|
806
|
+
function assertModelMetadata(
|
|
807
|
+
metadata: RuntimeModelMetadata,
|
|
808
|
+
modelId: string,
|
|
809
|
+
): void {
|
|
810
|
+
if (
|
|
811
|
+
typeof metadata.name !== "string" || !metadata.name.trim() ||
|
|
812
|
+
typeof metadata.reasoning !== "boolean" ||
|
|
813
|
+
!Number.isSafeInteger(metadata.contextWindow) || metadata.contextWindow <= 0 ||
|
|
814
|
+
!Number.isSafeInteger(metadata.maxTokens) || metadata.maxTokens <= 0 ||
|
|
815
|
+
metadata.maxTokens > metadata.contextWindow ||
|
|
816
|
+
!metadata.cost ||
|
|
817
|
+
[metadata.cost.input, metadata.cost.output, metadata.cost.cacheRead, metadata.cost.cacheWrite]
|
|
818
|
+
.some((rate) => !Number.isFinite(rate) || rate < 0) ||
|
|
819
|
+
Object.entries(metadata.thinkingLevelMap ?? {}).some(([level, value]) =>
|
|
820
|
+
!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level) ||
|
|
821
|
+
(value !== null && (typeof value !== "string" || !value.trim()))
|
|
822
|
+
)
|
|
823
|
+
) {
|
|
824
|
+
throw new Error(`Invalid deployment model metadata: ${modelId}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
763
828
|
function configuredModel(
|
|
764
829
|
endpoint: RuntimeModelEndpoint,
|
|
765
830
|
index: number,
|
|
@@ -768,11 +833,28 @@ function configuredModel(
|
|
|
768
833
|
const catalogModel = CATALOGS[endpoint.protocol].find(
|
|
769
834
|
(model) => model.id === modelId,
|
|
770
835
|
);
|
|
771
|
-
|
|
836
|
+
const explicitMetadata = endpoint.modelMetadata?.[modelId];
|
|
837
|
+
if (!catalogModel && !explicitMetadata) {
|
|
772
838
|
throw new Error(
|
|
773
|
-
`Model is not in SpringBrand's built-in catalog for ${endpoint.protocol}: ${modelId}`,
|
|
839
|
+
`Model is not in SpringBrand's built-in catalog and has no deployment metadata for ${endpoint.protocol}: ${modelId}`,
|
|
774
840
|
);
|
|
775
841
|
}
|
|
842
|
+
if (explicitMetadata) assertModelMetadata(explicitMetadata, modelId);
|
|
843
|
+
if (!catalogModel && !endpoint.modelInputModalities?.[modelId]?.includes("text")) {
|
|
844
|
+
throw new Error(`Deployment model requires explicit text input capability: ${modelId}`);
|
|
845
|
+
}
|
|
846
|
+
const sourceModel: Model<Api> = catalogModel ?? {
|
|
847
|
+
id: modelId,
|
|
848
|
+
name: explicitMetadata!.name,
|
|
849
|
+
api: apiFor(endpoint.protocol),
|
|
850
|
+
provider: "openrouter",
|
|
851
|
+
baseUrl: endpoint.baseURL,
|
|
852
|
+
reasoning: explicitMetadata!.reasoning,
|
|
853
|
+
input: ["text"],
|
|
854
|
+
cost: explicitMetadata!.cost,
|
|
855
|
+
contextWindow: explicitMetadata!.contextWindow,
|
|
856
|
+
maxTokens: explicitMetadata!.maxTokens,
|
|
857
|
+
};
|
|
776
858
|
const {
|
|
777
859
|
api: catalogApi,
|
|
778
860
|
provider: _provider,
|
|
@@ -781,7 +863,7 @@ function configuredModel(
|
|
|
781
863
|
headers: catalogHeaders,
|
|
782
864
|
input: catalogInput,
|
|
783
865
|
...metadata
|
|
784
|
-
} =
|
|
866
|
+
} = sourceModel;
|
|
785
867
|
const headers = {
|
|
786
868
|
...catalogHeaders,
|
|
787
869
|
...endpoint.headers,
|
|
@@ -794,27 +876,70 @@ function configuredModel(
|
|
|
794
876
|
(modality): modality is "text" | "image" =>
|
|
795
877
|
modality === "text" || modality === "image",
|
|
796
878
|
);
|
|
797
|
-
|
|
798
|
-
// Anthropic catalog uses dashed base model ids.
|
|
879
|
+
const isOpenRouter = endpoint.protocol.startsWith("openrouter-");
|
|
799
880
|
const anthropicMessagesCompat = endpoint.protocol === "openrouter-messages"
|
|
800
881
|
? anthropicMessagesModelForOpenRouter(modelId)?.compat
|
|
801
882
|
: undefined;
|
|
883
|
+
const openAIResponsesCompat = endpoint.protocol === "openrouter-responses" &&
|
|
884
|
+
modelId.startsWith("openai/")
|
|
885
|
+
? CATALOGS["openai-chat"].find((model) =>
|
|
886
|
+
model.id === modelId.slice("openai/".length) && model.api === "openai-responses"
|
|
887
|
+
)?.compat
|
|
888
|
+
: undefined;
|
|
802
889
|
return {
|
|
803
890
|
...metadata,
|
|
891
|
+
...(explicitMetadata
|
|
892
|
+
? {
|
|
893
|
+
name: explicitMetadata.name,
|
|
894
|
+
reasoning: explicitMetadata.reasoning,
|
|
895
|
+
contextWindow: explicitMetadata.contextWindow,
|
|
896
|
+
maxTokens: explicitMetadata.maxTokens,
|
|
897
|
+
cost: { ...explicitMetadata.cost },
|
|
898
|
+
...(explicitMetadata.thinkingLevelMap
|
|
899
|
+
? { thinkingLevelMap: { ...explicitMetadata.thinkingLevelMap } }
|
|
900
|
+
: {}),
|
|
901
|
+
}
|
|
902
|
+
: {}),
|
|
804
903
|
input,
|
|
805
904
|
api: configuredApi,
|
|
806
905
|
provider: providerId(endpoint, index),
|
|
807
906
|
baseUrl: endpoint.baseURL,
|
|
808
|
-
...(
|
|
907
|
+
...(isOpenRouter
|
|
809
908
|
? {
|
|
810
909
|
compat: {
|
|
811
|
-
...
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
910
|
+
...(endpoint.protocol === "openrouter-messages"
|
|
911
|
+
? {
|
|
912
|
+
...anthropicMessagesCompat,
|
|
913
|
+
supportsMidConvoEffort: false,
|
|
914
|
+
supportsEagerToolInputStreaming: false,
|
|
915
|
+
supportsStrictTools:
|
|
916
|
+
anthropicMessagesCompat?.supportsStrictTools ?? false,
|
|
917
|
+
supportsToolReferences: true,
|
|
918
|
+
...(modelId === "anthropic/claude-sonnet-5.5" ||
|
|
919
|
+
modelId === "anthropic/claude-opus-5.5"
|
|
920
|
+
? { forceAdaptiveThinking: true }
|
|
921
|
+
: {}),
|
|
922
|
+
}
|
|
923
|
+
: endpoint.protocol === "openrouter-chat"
|
|
924
|
+
? {
|
|
925
|
+
...catalogCompat,
|
|
926
|
+
thinkingFormat: "openrouter" as const,
|
|
927
|
+
maxTokensField: "max_tokens" as const,
|
|
928
|
+
supportsStore: false,
|
|
929
|
+
sessionAffinityFormat: "openrouter" as const,
|
|
930
|
+
sendSessionAffinityHeaders: true,
|
|
931
|
+
...(modelId.startsWith("anthropic/")
|
|
932
|
+
? { cacheControlFormat: "anthropic" as const }
|
|
933
|
+
: {}),
|
|
934
|
+
}
|
|
935
|
+
: {
|
|
936
|
+
sessionAffinityFormat: "openrouter" as const,
|
|
937
|
+
supportsDeveloperRole: modelId.startsWith("openai/") ||
|
|
938
|
+
modelId.startsWith("anthropic/"),
|
|
939
|
+
supportsStrictMode: openAIResponsesCompat?.supportsStrictMode ?? false,
|
|
940
|
+
supportsOpenAIGrammarTools:
|
|
941
|
+
openAIResponsesCompat?.supportsOpenAIGrammarTools ?? false,
|
|
942
|
+
}),
|
|
818
943
|
openRouterRouting: {
|
|
819
944
|
require_parameters: true,
|
|
820
945
|
...(openRouterProviderPin
|
|
@@ -868,6 +993,13 @@ export function configurePiModels(
|
|
|
868
993
|
models: MutableModels,
|
|
869
994
|
provider: RuntimeProviderPort,
|
|
870
995
|
): MutableModels {
|
|
996
|
+
for (const endpoint of provider.endpoints) {
|
|
997
|
+
for (const modelId of Object.keys(endpoint.modelMetadata ?? {})) {
|
|
998
|
+
if (!endpoint.models.includes(modelId)) {
|
|
999
|
+
throw new Error(`Deployment metadata is not configured for this endpoint: ${modelId}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
871
1003
|
const configuredProviders = provider.endpoints.map((endpoint, index) =>
|
|
872
1004
|
createProvider({
|
|
873
1005
|
id: providerId(endpoint, index),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAssistantMessageEventStream,
|
|
3
|
+
fauxAssistantMessage,
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type AssistantMessageEventStream,
|
|
7
|
+
type Model,
|
|
8
|
+
type ProviderStreams,
|
|
9
|
+
type StreamOptions,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import type { RuntimeModelProtocol } from "../../config/ports";
|
|
12
|
+
import { overlayProviderStreams } from "../provider-streams";
|
|
13
|
+
|
|
14
|
+
export type OpenRouterProtocol = Extract<RuntimeModelProtocol, `openrouter-${string}`>;
|
|
15
|
+
|
|
16
|
+
/** Minimal native output retained for the next OpenRouter Responses request. */
|
|
17
|
+
export interface ModelProtocolResponse {
|
|
18
|
+
readonly output: readonly Readonly<Record<string, unknown>>[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ProtocolEvent {
|
|
22
|
+
readonly data: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module "@earendil-works/pi-ai" {
|
|
26
|
+
interface AssistantMessage {
|
|
27
|
+
protocolResponse?: ModelProtocolResponse;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
33
|
+
? value as Record<string, unknown>
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseProtocolEvents(body: string): ProtocolEvent[] {
|
|
38
|
+
return body.split(/\r?\n\r?\n/).flatMap((frame) => {
|
|
39
|
+
const lines = frame.split(/\r?\n/);
|
|
40
|
+
const dataLines = lines.filter((line) => line.startsWith("data:"))
|
|
41
|
+
.map((line) => line.slice(5).replace(/^ /, ""));
|
|
42
|
+
if (dataLines.length === 0) return [];
|
|
43
|
+
const dataText = dataLines.join("\n");
|
|
44
|
+
let data: unknown = dataText;
|
|
45
|
+
try {
|
|
46
|
+
data = JSON.parse(dataText);
|
|
47
|
+
} catch {
|
|
48
|
+
data = dataText;
|
|
49
|
+
}
|
|
50
|
+
return [{ data }];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function compactResponsesBody(body: string): ModelProtocolResponse | undefined {
|
|
55
|
+
const completedItems = new Map<number, Record<string, unknown>>();
|
|
56
|
+
let terminalItems: Record<string, unknown>[] | undefined;
|
|
57
|
+
let terminal = false;
|
|
58
|
+
for (const frame of parseProtocolEvents(body)) {
|
|
59
|
+
const event = record(frame.data);
|
|
60
|
+
if (
|
|
61
|
+
event?.type === "response.output_item.done" &&
|
|
62
|
+
typeof event.output_index === "number"
|
|
63
|
+
) {
|
|
64
|
+
const item = record(event.item);
|
|
65
|
+
if (item) completedItems.set(event.output_index, item);
|
|
66
|
+
}
|
|
67
|
+
if (event?.type === "response.completed" || event?.type === "response.incomplete") {
|
|
68
|
+
terminal = true;
|
|
69
|
+
const response = record(event.response);
|
|
70
|
+
if (Array.isArray(response?.output)) {
|
|
71
|
+
terminalItems = response.output.flatMap((value) => {
|
|
72
|
+
const output = record(value);
|
|
73
|
+
return output ? [output] : [];
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!terminal) return undefined;
|
|
79
|
+
const output = terminalItems?.length
|
|
80
|
+
? terminalItems.map((item, index) => ({ ...completedItems.get(index), ...item }))
|
|
81
|
+
: [...completedItems.entries()]
|
|
82
|
+
.sort(([left], [right]) => left - right)
|
|
83
|
+
.map(([, item]) => item);
|
|
84
|
+
return output.length > 0 ? { output } : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function streamWithProtocolResponse<T extends StreamOptions>(
|
|
88
|
+
model: Model<Api>,
|
|
89
|
+
options: T | undefined,
|
|
90
|
+
start: (options: T) => AssistantMessageEventStream,
|
|
91
|
+
): AssistantMessageEventStream {
|
|
92
|
+
const output = createAssistantMessageEventStream();
|
|
93
|
+
const fetcher = options?.fetch ?? globalThis.fetch;
|
|
94
|
+
let responseOutput: Promise<ModelProtocolResponse | undefined> | undefined;
|
|
95
|
+
let terminal = false;
|
|
96
|
+
const requestOptions = {
|
|
97
|
+
...options,
|
|
98
|
+
fetch: (async (...args: Parameters<typeof fetch>) => {
|
|
99
|
+
const response = await fetcher(...args);
|
|
100
|
+
responseOutput = response.clone().text()
|
|
101
|
+
.then((body) => compactResponsesBody(body))
|
|
102
|
+
.catch(() => undefined);
|
|
103
|
+
return response;
|
|
104
|
+
}) satisfies typeof fetch,
|
|
105
|
+
} as T;
|
|
106
|
+
|
|
107
|
+
void (async () => {
|
|
108
|
+
const source = start(requestOptions);
|
|
109
|
+
for await (const event of source) {
|
|
110
|
+
if (event.type === "done" || event.type === "error") {
|
|
111
|
+
terminal = true;
|
|
112
|
+
if (responseOutput) {
|
|
113
|
+
const message = event.type === "done" ? event.message : event.error;
|
|
114
|
+
const compact = await responseOutput;
|
|
115
|
+
if (compact) message.protocolResponse = compact;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
output.push(event);
|
|
119
|
+
}
|
|
120
|
+
if (!terminal) throw new Error("Provider stream ended without a terminal event");
|
|
121
|
+
output.end();
|
|
122
|
+
})().catch(async (error: unknown) => {
|
|
123
|
+
if (terminal) {
|
|
124
|
+
output.end();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const reason = options?.signal?.aborted ? "aborted" : "error";
|
|
128
|
+
const failure = fauxAssistantMessage("", {
|
|
129
|
+
stopReason: reason,
|
|
130
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
131
|
+
});
|
|
132
|
+
if (responseOutput) {
|
|
133
|
+
const compact = await responseOutput;
|
|
134
|
+
if (compact) failure.protocolResponse = compact;
|
|
135
|
+
}
|
|
136
|
+
output.push({
|
|
137
|
+
type: "error",
|
|
138
|
+
reason,
|
|
139
|
+
error: { ...failure, api: model.api, provider: model.provider, model: model.id },
|
|
140
|
+
});
|
|
141
|
+
output.end();
|
|
142
|
+
});
|
|
143
|
+
return output;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function withModelProtocolResponse(
|
|
147
|
+
api: ProviderStreams,
|
|
148
|
+
protocol: OpenRouterProtocol,
|
|
149
|
+
): ProviderStreams {
|
|
150
|
+
if (protocol !== "openrouter-responses") return api;
|
|
151
|
+
return overlayProviderStreams(api, {
|
|
152
|
+
stream: (model, context, options) =>
|
|
153
|
+
streamWithProtocolResponse(model, options, (requestOptions) =>
|
|
154
|
+
api.stream(model, context, requestOptions)
|
|
155
|
+
),
|
|
156
|
+
streamSimple: (model, context, options) =>
|
|
157
|
+
streamWithProtocolResponse(model, options, (requestOptions) =>
|
|
158
|
+
api.streamSimple(model, context, requestOptions)
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -9,10 +9,9 @@ export interface TemporaryAgentRequest {
|
|
|
9
9
|
task: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface TemporaryAgentLaunchSelection
|
|
13
13
|
extends TemporaryAgentRequest {
|
|
14
14
|
runtimeKey: string;
|
|
15
|
-
config: Config;
|
|
16
15
|
executionLevel: ExecutionLevel;
|
|
17
16
|
}
|
|
18
17
|
|
|
@@ -1128,6 +1128,8 @@ export interface WebSearchOptions {
|
|
|
1128
1128
|
function apiFor(endpoint: RuntimeModelEndpoint): NativeApi {
|
|
1129
1129
|
switch (endpoint.protocol) {
|
|
1130
1130
|
case "openrouter-messages":
|
|
1131
|
+
case "openrouter-chat":
|
|
1132
|
+
case "openrouter-responses":
|
|
1131
1133
|
return "openrouter-responses";
|
|
1132
1134
|
case "google-generative-ai":
|
|
1133
1135
|
return "google-generative-ai";
|