@springbrand/agent-runtime 0.3.0-alpha.22 → 0.3.0-alpha.24

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/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export type { RuntimeAssemblyView } from "./config/assembly-view";
51
51
  export * from "./adapter/cloudflare/define-agent";
52
52
  export type { ToolInteractionResponsePreparation } from "./adapter/cloudflare/agent";
53
53
  export * from "./conversation/state";
54
+ export type { ModelProtocolResponse } from "./conversation/canonical-context";
54
55
  export * from "./tools/workspace/versioning";
55
56
  export * from "./lib/execution-level";
56
57
  export type {
@@ -144,12 +145,3 @@ export {
144
145
  modelRequestUrl,
145
146
  resolvePiModel,
146
147
  } 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";
@@ -1,4 +1,9 @@
1
1
  import type { StreamFn } from "@earendil-works/pi-agent-core";
2
+ import {
3
+ openRouterRequestUrl,
4
+ withOpenRouterStructuredOutput,
5
+ type OpenRouterGenerationProtocol,
6
+ } from "@springbrand/model-client";
2
7
  import {
3
8
  createModels,
4
9
  createProvider,
@@ -148,18 +153,23 @@ type ProviderBoundStream = (
148
153
  options?: SimpleStreamOptions,
149
154
  ) => ReturnType<StreamFn>;
150
155
 
156
+ function openRouterProtocolForModel(model: Model<Api>): OpenRouterGenerationProtocol | undefined {
157
+ if (model.api === "anthropic-messages" &&
158
+ model.provider.startsWith("model-api-openrouter-messages-")) return "openrouter-messages";
159
+ if (model.api === "openai-completions" &&
160
+ model.provider.startsWith("model-api-openrouter-chat-")) return "openrouter-chat";
161
+ if (model.api === "openai-responses" &&
162
+ model.provider.startsWith("model-api-openrouter-responses-")) return "openrouter-responses";
163
+ return undefined;
164
+ }
165
+
151
166
  export function structuredOutputStream(
152
167
  streamFn: StreamFn,
153
168
  model: Model<Api>,
154
169
  schema: Record<string, unknown>,
155
170
  ): StreamFn | 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;
171
+ const protocol = openRouterProtocolForModel(model);
172
+ if (!protocol) return undefined;
163
173
 
164
174
  return (activeModel, context, options) =>
165
175
  streamFn(activeModel, context, {
@@ -169,38 +179,9 @@ export function structuredOutputStream(
169
179
  if (!value || typeof value !== "object" || Array.isArray(value)) {
170
180
  throw new Error("Structured model payload must be an object");
171
181
  }
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;
195
- return {
196
- ...body,
197
- text: {
198
- ...(current && typeof current === "object" && !Array.isArray(current)
199
- ? current
200
- : {}),
201
- format: { type: "json_schema", name: "agent_output", strict: true, schema },
202
- },
203
- };
182
+ return withOpenRouterStructuredOutput(
183
+ value as Record<string, unknown>, protocol, schema, "agent_output",
184
+ );
204
185
  },
205
186
  });
206
187
  }
@@ -506,6 +487,8 @@ function trimTrailingSlash(value: string): string {
506
487
  * Codex 走 `resolveCodexUrl`。不要在这里"顺手规范化"路径,否则日志会与真实请求脱节,反而掩盖配置错误。
507
488
  */
508
489
  export function modelRequestUrl(model: Model<Api>): string {
490
+ const protocol = openRouterProtocolForModel(model);
491
+ if (protocol) return openRouterRequestUrl({ protocol, baseURL: model.baseUrl ?? "" });
509
492
  const base = trimTrailingSlash(model.baseUrl ?? "");
510
493
  switch (model.api) {
511
494
  case "openai-completions":
@@ -766,7 +749,7 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
766
749
  }
767
750
  })();
768
751
  const api = protocol.startsWith("openrouter-")
769
- ? withModelProtocolResponse(implementation, protocol as "openrouter-messages" | "openrouter-chat" | "openrouter-responses")
752
+ ? withModelProtocolResponse(implementation, protocol as OpenRouterGenerationProtocol)
770
753
  : implementation;
771
754
  const contextFor = (
772
755
  model: Model<Api>,
@@ -5,6 +5,7 @@ import type {
5
5
  TranscriptContext,
6
6
  } from "@earendil-works/pi-ai";
7
7
  import { getCurrentTools } from "@earendil-works/pi-ai";
8
+ import { createOpenRouterFetch, openRouterHeaders, withOpenRouterRouting } from "@springbrand/model-client";
8
9
  import {
9
10
  anthropicMessagesApi,
10
11
  } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
@@ -60,13 +61,7 @@ function decoratePayload(
60
61
  }
61
62
 
62
63
  return {
63
- ...body,
64
- ...(sessionId ? { session_id: sessionId } : {}),
65
- provider: {
66
- ...record(body.provider),
67
- ...model.compat?.openRouterRouting,
68
- require_parameters: true,
69
- },
64
+ ...withOpenRouterRouting(body, { ...model.compat?.openRouterRouting }, sessionId),
70
65
  ...(deferred.length > 0
71
66
  ? {
72
67
  tools: [
@@ -96,12 +91,14 @@ function openRouterOptions(
96
91
  return {
97
92
  ...options,
98
93
  apiKey: undefined,
94
+ fetch: createOpenRouterFetch({ apiKey, fetch: options?.fetch }),
99
95
  headers: {
96
+ // Pi uses null to suppress SDK/model defaults before building native headers.
100
97
  ...headers,
98
+ ...Object.fromEntries(openRouterHeaders(apiKey)),
101
99
  ...(model.compat?.supportsStrictTools === true
102
100
  ? { "x-anthropic-beta": STRUCTURED_OUTPUTS_BETA }
103
101
  : {}),
104
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
105
102
  "x-api-key": null,
106
103
  },
107
104
  onPayload: async (payload, activeModel) => {
@@ -6,6 +6,7 @@ import type {
6
6
  TranscriptContext,
7
7
  } from "@earendil-works/pi-ai";
8
8
  import { getCurrentTools } from "@earendil-works/pi-ai";
9
+ import { createOpenRouterFetch, withOpenRouterRouting } from "@springbrand/model-client";
9
10
  import {
10
11
  openAICompletionsApi,
11
12
  } from "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -27,6 +28,7 @@ function openRouterOptions(
27
28
  ): StreamOptions {
28
29
  return {
29
30
  ...options,
31
+ fetch: createOpenRouterFetch({ apiKey: options?.apiKey, fetch: options?.fetch }),
30
32
  onPayload: async (payload, activeModel) => {
31
33
  const callerPayload = await options?.onPayload?.(payload, activeModel) ?? payload;
32
34
  const body = record(callerPayload);
@@ -48,18 +50,12 @@ function openRouterOptions(
48
50
  const reasoning = record(replayed.reasoning);
49
51
  const include = Array.isArray(replayed.include) ? replayed.include : [];
50
52
  return {
51
- ...replayed,
53
+ ...withOpenRouterRouting(replayed, compat?.openRouterRouting, options?.sessionId),
52
54
  ...(model.api === "openai-responses" && reasoning?.effort !== undefined &&
53
55
  reasoning.effort !== null && reasoning.effort !== "none" &&
54
56
  !include.includes("reasoning.encrypted_content")
55
57
  ? { include: [...include, "reasoning.encrypted_content"] }
56
58
  : {}),
57
- ...(options?.sessionId ? { session_id: options.sessionId } : {}),
58
- provider: {
59
- ...record(replayed.provider),
60
- ...compat?.openRouterRouting,
61
- require_parameters: true,
62
- },
63
59
  };
64
60
  },
65
61
  };
@@ -7,21 +7,9 @@ import {
7
7
  type ProviderStreams,
8
8
  type StreamOptions,
9
9
  } from "@earendil-works/pi-ai";
10
- import type { RuntimeModelProtocol } from "../../config/ports";
10
+ import type { OpenRouterGenerationProtocol } from "@springbrand/model-client";
11
11
  import { overlayProviderStreams } from "../provider-streams";
12
-
13
- export type OpenRouterProtocol = Extract<RuntimeModelProtocol, `openrouter-${string}`>;
14
-
15
- /** Native output required by OpenRouter's stateless continuation. */
16
- export interface ModelProtocolResponse {
17
- readonly output: readonly Readonly<Record<string, unknown>>[];
18
- }
19
-
20
- declare module "@earendil-works/pi-ai" {
21
- interface AssistantMessage {
22
- protocolResponse?: ModelProtocolResponse;
23
- }
24
- }
12
+ import type { ModelProtocolResponse } from "../../conversation/canonical-context";
25
13
 
26
14
  function record(value: unknown): Record<string, unknown> | undefined {
27
15
  return value !== null && typeof value === "object" && !Array.isArray(value)
@@ -73,7 +61,7 @@ function streamWithProtocolResponse<T extends StreamOptions>(
73
61
 
74
62
  export function withModelProtocolResponse(
75
63
  api: ProviderStreams,
76
- protocol: OpenRouterProtocol,
64
+ protocol: OpenRouterGenerationProtocol,
77
65
  ): ProviderStreams {
78
66
  if (protocol !== "openrouter-responses") return api;
79
67
  return overlayProviderStreams(api, {
@@ -6,6 +6,7 @@ import type { SubmitMessageOptions } from "./admission";
6
6
  import type { SubmissionHandle } from "./lifecycle";
7
7
  import { submissionAbortReason, type StoredSubmission, type SubmissionRepository } from "./repository";
8
8
  import type { PiCanonicalUserInput } from "../conversation/message/contract";
9
+ import { captureUIUserSidecar } from "../conversation/message/conversion";
9
10
  import type { MessageUiRepository } from "../conversation/repository";
10
11
  import type { PendingSteer, SteerRepository } from "./steer-repository";
11
12
 
@@ -70,6 +71,7 @@ export async function finishPendingSteers(options: {
70
71
  const userMessage = steer.uiMessageJson
71
72
  ? JSON.parse(steer.uiMessageJson) as UIMessage & { role: "user" }
72
73
  : undefined;
74
+ const userSidecar = userMessage ? { id: userMessage.id, ...captureUIUserSidecar(userMessage) } : undefined;
73
75
  const requestId = `${options.submissionId}:steer:${steer.steerId}`;
74
76
  if (options.aborted) {
75
77
  const owner = options.transaction(() => {
@@ -81,7 +83,7 @@ export async function finishPendingSteers(options: {
81
83
  }
82
84
  return handoff?.status === "aborted" || handoff?.status === "skipped" ? handoff.submissionId : options.submissionId;
83
85
  });
84
- await options.conversation.append(steer.messageId, message, { submissionId: owner, ...(userMessage ? { userMessage } : {}) });
86
+ await options.conversation.append(steer.messageId, message, { submissionId: owner, ...(userSidecar ? { userSidecar } : {}) });
85
87
  options.transaction(() => {
86
88
  if (steer.harnessEntryId) options.messageUi.delete(steer.harnessEntryId);
87
89
  options.steers.deleteByMessageId(steer.messageId);
@@ -117,7 +119,7 @@ export async function finishPendingSteers(options: {
117
119
  }
118
120
  return current;
119
121
  });
120
- if (handoff.status === "skipped" || handoff.status === "aborted") await options.conversation.append(steer.messageId, message, { submissionId: handoff.submissionId, ...(userMessage ? { userMessage } : {}) });
122
+ if (handoff.status === "skipped" || handoff.status === "aborted") await options.conversation.append(steer.messageId, message, { submissionId: handoff.submissionId, ...(userSidecar ? { userSidecar } : {}) });
121
123
  options.transaction(() => options.steers.deleteByMessageId(steer.messageId));
122
124
  }
123
125
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { RuntimeModelEndpoint } from "../../../config/ports";
9
+ import { createOpenRouterFetch, openRouterRequestUrl } from "@springbrand/model-client";
9
10
 
10
11
  export interface WebSearchSource {
11
12
  title: string;
@@ -295,6 +296,9 @@ function isOpenRouterModel(model: NativeWebSearchModel): boolean {
295
296
  }
296
297
 
297
298
  function resolveOpenAIResponsesUrl(model: NativeWebSearchModel): string {
299
+ if (isOpenRouterModel(model)) {
300
+ return openRouterRequestUrl({ protocol: "openrouter-responses", baseURL: model.baseUrl });
301
+ }
298
302
  const base = trimTrailingSlash(model.baseUrl);
299
303
  if (!isOpenAICodexModel(model)) return `${base}/responses`;
300
304
  if (base.endsWith("/codex/responses")) return base;
@@ -733,7 +737,8 @@ async function callOpenAIStream(
733
737
  requestBody.parallel_tool_calls = true;
734
738
  }
735
739
 
736
- const response = await fetch(resolveOpenAIResponsesUrl(model), {
740
+ const fetcher = isOpenRouter ? createOpenRouterFetch({ apiKey: auth.apiKey }) : fetch;
741
+ const response = await fetcher(resolveOpenAIResponsesUrl(model), {
737
742
  method: "POST",
738
743
  headers: requestHeaders,
739
744
  body: JSON.stringify(requestBody),
@@ -1,41 +0,0 @@
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
- }