@mupt-ai/dari-router 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -0
  3. package/dist/anonymous_actions.d.ts +55 -0
  4. package/dist/anonymous_actions.js +277 -0
  5. package/dist/anonymous_actions.js.map +1 -0
  6. package/dist/auto_router.d.ts +10 -0
  7. package/dist/auto_router.js +56 -0
  8. package/dist/auto_router.js.map +1 -0
  9. package/dist/cache_behavior.d.ts +14 -0
  10. package/dist/cache_behavior.js +112 -0
  11. package/dist/cache_behavior.js.map +1 -0
  12. package/dist/cache_scope.d.ts +5 -0
  13. package/dist/cache_scope.js +49 -0
  14. package/dist/cache_scope.js.map +1 -0
  15. package/dist/cache_switch_decision.d.ts +24 -0
  16. package/dist/cache_switch_decision.js +21 -0
  17. package/dist/cache_switch_decision.js.map +1 -0
  18. package/dist/candidate_pruning.d.ts +45 -0
  19. package/dist/candidate_pruning.js +154 -0
  20. package/dist/candidate_pruning.js.map +1 -0
  21. package/dist/candidate_resolution.d.ts +41 -0
  22. package/dist/candidate_resolution.js +111 -0
  23. package/dist/candidate_resolution.js.map +1 -0
  24. package/dist/compatibility.d.ts +22 -0
  25. package/dist/compatibility.js +76 -0
  26. package/dist/compatibility.js.map +1 -0
  27. package/dist/continuation_state.d.ts +36 -0
  28. package/dist/continuation_state.js +249 -0
  29. package/dist/continuation_state.js.map +1 -0
  30. package/dist/cost.d.ts +31 -0
  31. package/dist/cost.js +314 -0
  32. package/dist/cost.js.map +1 -0
  33. package/dist/dari_policy.d.ts +34 -0
  34. package/dist/dari_policy.js +267 -0
  35. package/dist/dari_policy.js.map +1 -0
  36. package/dist/errors.d.ts +7 -0
  37. package/dist/errors.js +14 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/fingerprint.d.ts +13 -0
  40. package/dist/fingerprint.js +130 -0
  41. package/dist/fingerprint.js.map +1 -0
  42. package/dist/fixed_turn_cost_config.d.ts +9 -0
  43. package/dist/fixed_turn_cost_config.js +22 -0
  44. package/dist/fixed_turn_cost_config.js.map +1 -0
  45. package/dist/framework_error.d.ts +8 -0
  46. package/dist/framework_error.js +24 -0
  47. package/dist/framework_error.js.map +1 -0
  48. package/dist/framework_lifecycle.d.ts +11 -0
  49. package/dist/framework_lifecycle.js +57 -0
  50. package/dist/framework_lifecycle.js.map +1 -0
  51. package/dist/framework_models.d.ts +3 -0
  52. package/dist/framework_models.js +120 -0
  53. package/dist/framework_models.js.map +1 -0
  54. package/dist/framework_router.d.ts +2 -0
  55. package/dist/framework_router.js +384 -0
  56. package/dist/framework_router.js.map +1 -0
  57. package/dist/framework_streaming.d.ts +26 -0
  58. package/dist/framework_streaming.js +486 -0
  59. package/dist/framework_streaming.js.map +1 -0
  60. package/dist/framework_types.d.ts +272 -0
  61. package/dist/framework_types.js +5 -0
  62. package/dist/framework_types.js.map +1 -0
  63. package/dist/framework_validation.d.ts +7 -0
  64. package/dist/framework_validation.js +169 -0
  65. package/dist/framework_validation.js.map +1 -0
  66. package/dist/index.d.ts +35 -0
  67. package/dist/index.js +38 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/json.d.ts +10 -0
  70. package/dist/json.js +29 -0
  71. package/dist/json.js.map +1 -0
  72. package/dist/model_ids.d.ts +4 -0
  73. package/dist/model_ids.js +26 -0
  74. package/dist/model_ids.js.map +1 -0
  75. package/dist/pi_context.d.ts +4 -0
  76. package/dist/pi_context.js +206 -0
  77. package/dist/pi_context.js.map +1 -0
  78. package/dist/pi_options.d.ts +3 -0
  79. package/dist/pi_options.js +240 -0
  80. package/dist/pi_options.js.map +1 -0
  81. package/dist/pi_output.d.ts +4 -0
  82. package/dist/pi_output.js +328 -0
  83. package/dist/pi_output.js.map +1 -0
  84. package/dist/pi_reasoning_signature.d.ts +10 -0
  85. package/dist/pi_reasoning_signature.js +33 -0
  86. package/dist/pi_reasoning_signature.js.map +1 -0
  87. package/dist/pi_runtime.d.ts +3 -0
  88. package/dist/pi_runtime.js +155 -0
  89. package/dist/pi_runtime.js.map +1 -0
  90. package/dist/pi_types.d.ts +35 -0
  91. package/dist/pi_types.js +2 -0
  92. package/dist/pi_types.js.map +1 -0
  93. package/dist/portable_reasoning_state.d.ts +7 -0
  94. package/dist/portable_reasoning_state.js +81 -0
  95. package/dist/portable_reasoning_state.js.map +1 -0
  96. package/dist/previous_decision.d.ts +13 -0
  97. package/dist/previous_decision.js +82 -0
  98. package/dist/previous_decision.js.map +1 -0
  99. package/dist/prompts.d.ts +3 -0
  100. package/dist/prompts.js +65 -0
  101. package/dist/prompts.js.map +1 -0
  102. package/dist/protocol_anthropic.d.ts +18 -0
  103. package/dist/protocol_anthropic.js +443 -0
  104. package/dist/protocol_anthropic.js.map +1 -0
  105. package/dist/protocol_openai_chat.d.ts +25 -0
  106. package/dist/protocol_openai_chat.js +503 -0
  107. package/dist/protocol_openai_chat.js.map +1 -0
  108. package/dist/protocol_validation.d.ts +23 -0
  109. package/dist/protocol_validation.js +134 -0
  110. package/dist/protocol_validation.js.map +1 -0
  111. package/dist/route.d.ts +105 -0
  112. package/dist/route.js +226 -0
  113. package/dist/route.js.map +1 -0
  114. package/dist/routing_history.d.ts +7 -0
  115. package/dist/routing_history.js +132 -0
  116. package/dist/routing_history.js.map +1 -0
  117. package/dist/selector_input.d.ts +42 -0
  118. package/dist/selector_input.js +153 -0
  119. package/dist/selector_input.js.map +1 -0
  120. package/dist/selector_parse.d.ts +13 -0
  121. package/dist/selector_parse.js +95 -0
  122. package/dist/selector_parse.js.map +1 -0
  123. package/dist/selector_request.d.ts +26 -0
  124. package/dist/selector_request.js +85 -0
  125. package/dist/selector_request.js.map +1 -0
  126. package/dist/selector_truncation.d.ts +2 -0
  127. package/dist/selector_truncation.js +48 -0
  128. package/dist/selector_truncation.js.map +1 -0
  129. package/dist/selector_wire.d.ts +10 -0
  130. package/dist/selector_wire.js +113 -0
  131. package/dist/selector_wire.js.map +1 -0
  132. package/dist/types.d.ts +152 -0
  133. package/dist/types.js +14 -0
  134. package/dist/types.js.map +1 -0
  135. package/package.json +64 -0
  136. package/src/anonymous_actions.ts +434 -0
  137. package/src/auto_router.ts +95 -0
  138. package/src/cache_behavior.ts +130 -0
  139. package/src/cache_scope.ts +58 -0
  140. package/src/cache_switch_decision.ts +53 -0
  141. package/src/candidate_pruning.ts +257 -0
  142. package/src/candidate_resolution.ts +208 -0
  143. package/src/compatibility.ts +162 -0
  144. package/src/continuation_state.ts +368 -0
  145. package/src/cost.ts +455 -0
  146. package/src/dari_policy.ts +360 -0
  147. package/src/errors.ts +27 -0
  148. package/src/fingerprint.ts +148 -0
  149. package/src/fixed_turn_cost_config.ts +24 -0
  150. package/src/framework_error.ts +38 -0
  151. package/src/framework_lifecycle.ts +74 -0
  152. package/src/framework_models.ts +186 -0
  153. package/src/framework_router.ts +490 -0
  154. package/src/framework_streaming.ts +613 -0
  155. package/src/framework_types.ts +372 -0
  156. package/src/framework_validation.ts +235 -0
  157. package/src/index.ts +37 -0
  158. package/src/json.ts +43 -0
  159. package/src/model_ids.ts +29 -0
  160. package/src/pi_context.ts +251 -0
  161. package/src/pi_options.ts +295 -0
  162. package/src/pi_output.ts +387 -0
  163. package/src/pi_reasoning_signature.ts +41 -0
  164. package/src/pi_runtime.ts +238 -0
  165. package/src/pi_types.ts +71 -0
  166. package/src/portable_reasoning_state.ts +129 -0
  167. package/src/previous_decision.ts +109 -0
  168. package/src/prompts.ts +69 -0
  169. package/src/protocol_anthropic.ts +571 -0
  170. package/src/protocol_openai_chat.ts +645 -0
  171. package/src/protocol_validation.ts +202 -0
  172. package/src/route.ts +490 -0
  173. package/src/routing_history.ts +157 -0
  174. package/src/selector_input.ts +246 -0
  175. package/src/selector_parse.ts +138 -0
  176. package/src/selector_request.ts +130 -0
  177. package/src/selector_truncation.ts +48 -0
  178. package/src/selector_wire.ts +167 -0
  179. package/src/types.ts +231 -0
@@ -0,0 +1,29 @@
1
+ import { RouterCoreError } from "./errors.js";
2
+ import type { Provider } from "./types.js";
3
+
4
+ const PROVIDER_PATTERN = /^[a-z][a-z0-9_.-]{0,119}$/;
5
+
6
+ export function providerForModel(model: string): Provider {
7
+ const slash = model.indexOf("/");
8
+ if (slash <= 0) {
9
+ throw new RouterCoreError("invalid_request", `Unsupported model provider for ${model}`, "unsupported_model", "model");
10
+ }
11
+ const provider = model.slice(0, slash).toLowerCase();
12
+ if (!PROVIDER_PATTERN.test(provider)) {
13
+ throw new RouterCoreError("invalid_request", `Unsupported model provider for ${model}`, "unsupported_model", "model");
14
+ }
15
+ return provider;
16
+ }
17
+
18
+ export function nativeModelId(model: string): string {
19
+ return model.slice(`${providerForModel(model)}/`.length);
20
+ }
21
+
22
+ // providerForModel() lowercases the provider prefix, so lookups keyed on
23
+ // model ids must use the same canonical spelling; the model segment stays
24
+ // case-sensitive (native ids can be), matching providerForModel semantics.
25
+ export function canonicalModelId(modelId: string): string {
26
+ const slash = modelId.indexOf("/");
27
+ if (slash <= 0) return modelId;
28
+ return modelId.slice(0, slash).toLowerCase() + modelId.slice(slash);
29
+ }
@@ -0,0 +1,251 @@
1
+ import type { AssistantMessage, Context, Tool } from "@mupt-ai/pi-ai";
2
+
3
+ import { RouterFrameworkError } from "./framework_error.js";
4
+ import { encodeOpenAIReasoningSignature } from "./pi_reasoning_signature.js";
5
+ import { isRecord } from "./types.js";
6
+ import type {
7
+ RouterContent,
8
+ RouterProviderIdentity,
9
+ RouterRequest,
10
+ } from "./framework_types.js";
11
+ import {
12
+ compatibleProviderContinuation,
13
+ isOpenAIResponsesApi,
14
+ type ProviderContinuationState,
15
+ } from "./continuation_state.js";
16
+ import type { PiModel } from "./pi_types.js";
17
+
18
+ export function piContext(request: RouterRequest, model: PiModel): Context {
19
+ const system: string[] = [];
20
+ const messages: Context["messages"] = [];
21
+ const calls = new Map<string, string>();
22
+ const timestamp = Date.now();
23
+ let assistant: Extract<Context["messages"][number], { role: "assistant" }> | undefined;
24
+
25
+ const flushAssistant = () => {
26
+ if (assistant === undefined) return;
27
+ if (assistant.content.length === 0) assistant.content.push({ type: "text", text: "" });
28
+ assistant.stopReason = assistant.content.some((part) => part.type === "toolCall")
29
+ ? "toolUse"
30
+ : "stop";
31
+ messages.push(assistant);
32
+ assistant = undefined;
33
+ };
34
+ const currentAssistant = () => {
35
+ assistant ??= {
36
+ role: "assistant",
37
+ content: [],
38
+ api: model.api,
39
+ provider: model.provider,
40
+ model: model.id,
41
+ usage: zeroPiUsage(),
42
+ stopReason: "stop",
43
+ timestamp,
44
+ };
45
+ return assistant;
46
+ };
47
+
48
+ for (const item of request.items) {
49
+ if (item.type === "message" && (item.role === "system" || item.role === "developer")) {
50
+ flushAssistant();
51
+ system.push(systemText(item.content));
52
+ continue;
53
+ }
54
+ if (item.type === "message" && item.role === "user") {
55
+ flushAssistant();
56
+ messages.push({
57
+ role: "user",
58
+ content: piContent(item.content, "user message"),
59
+ timestamp,
60
+ });
61
+ continue;
62
+ }
63
+ if (item.type === "message") {
64
+ const pending = currentAssistant();
65
+ pending.content.push(...item.content.map((part) => {
66
+ if (part.type !== "text") {
67
+ throw new RouterFrameworkError(
68
+ "invalid_request",
69
+ "Pi does not support image content in assistant messages.",
70
+ "pi_assistant_image_unsupported",
71
+ );
72
+ }
73
+ return { type: "text" as const, text: part.text };
74
+ }));
75
+ continue;
76
+ }
77
+ if (item.type === "tool_call") {
78
+ const argumentsValue = toolArguments(item.arguments, item.id);
79
+ currentAssistant().content.push({
80
+ type: "toolCall",
81
+ id: item.id,
82
+ name: item.name,
83
+ arguments: argumentsValue,
84
+ });
85
+ calls.set(item.id, item.name);
86
+ continue;
87
+ }
88
+ if (item.type === "reasoning") {
89
+ const target: RouterProviderIdentity = {
90
+ provider: model.provider,
91
+ api: model.api,
92
+ model: model.id,
93
+ };
94
+ const continuation = compatibleProviderContinuation(item.continuation, target);
95
+ if (continuation) {
96
+ currentAssistant().content.push(thinkingContent(item, continuation));
97
+ } else {
98
+ const readable = [...(item.summary ?? []), ...item.content].join("\n");
99
+ if (readable) {
100
+ // Cross-provider: the encrypted blob is not replayable, so carry the
101
+ // readable reasoning as plain text instead of dropping the prior
102
+ // turn's chain-of-thought entirely.
103
+ currentAssistant().content.push({ type: "text", text: readable });
104
+ }
105
+ }
106
+ continue;
107
+ }
108
+ if (item.type === "hosted_tool_call") {
109
+ if (!isOpenAIResponsesApi(model.api)) {
110
+ // Dropping the item would silently lose a conversation turn.
111
+ throw new RouterFrameworkError(
112
+ "configuration",
113
+ `Hosted tool calls are not representable on the '${model.api}' API; route hosted-tool conversations to a Responses-API model.`,
114
+ "hosted_tool_call_unrepresentable",
115
+ );
116
+ }
117
+ (currentAssistant().content as unknown as Array<Record<string, unknown>>).push({
118
+ type: "providerItem",
119
+ item: item.payload,
120
+ });
121
+ continue;
122
+ }
123
+ flushAssistant();
124
+ messages.push({
125
+ role: "toolResult",
126
+ toolCallId: item.toolCallId,
127
+ toolName: calls.get(item.toolCallId) ?? "tool",
128
+ content: piContent(item.content, `tool result '${item.toolCallId}'`),
129
+ isError: item.isError ?? false,
130
+ timestamp,
131
+ });
132
+ }
133
+ flushAssistant();
134
+
135
+ const tools: Tool[] = request.tools.map((tool) => ({
136
+ name: tool.name,
137
+ description: tool.description ?? "",
138
+ parameters: tool.inputSchema as Tool["parameters"],
139
+ }));
140
+ return {
141
+ ...(system.length === 0 ? {} : { systemPrompt: system.join("\n\n") }),
142
+ messages,
143
+ ...(tools.length === 0 ? {} : { tools }),
144
+ };
145
+ }
146
+
147
+ function thinkingContent(
148
+ item: Extract<RouterRequest["items"][number], { type: "reasoning" }>,
149
+ continuation: ProviderContinuationState,
150
+ ): { type: "thinking"; thinking: string; thinkingSignature?: string; redacted?: boolean } {
151
+ if (continuation.kind === "openai_reasoning") {
152
+ const providerItemId = continuation.providerItemId ?? item.id;
153
+ return {
154
+ type: "thinking",
155
+ thinking: [...(item.summary ?? []), ...item.content].join("\n"),
156
+ thinkingSignature: encodeOpenAIReasoningSignature({
157
+ ...(providerItemId ? { id: providerItemId } : {}),
158
+ summary: item.summary ?? [],
159
+ content: item.content,
160
+ encryptedContent: continuation.encryptedContent,
161
+ }),
162
+ };
163
+ }
164
+ if (continuation.kind === "anthropic_thinking") {
165
+ return {
166
+ type: "thinking",
167
+ thinking: continuation.thinking,
168
+ thinkingSignature: continuation.signature,
169
+ };
170
+ }
171
+ return {
172
+ type: "thinking",
173
+ thinking: "",
174
+ thinkingSignature: continuation.data,
175
+ redacted: true,
176
+ };
177
+ }
178
+
179
+ function systemText(content: readonly RouterContent[]): string {
180
+ return content.map((part) => {
181
+ if (part.type !== "text") {
182
+ throw new RouterFrameworkError(
183
+ "invalid_request",
184
+ "Pi does not support image content in system or developer messages.",
185
+ "pi_system_image_unsupported",
186
+ );
187
+ }
188
+ return part.text;
189
+ }).join("");
190
+ }
191
+
192
+ function piContent(
193
+ content: readonly RouterContent[],
194
+ location: string,
195
+ ): Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> {
196
+ return content.map((part) => {
197
+ if (part.type === "text") return { type: "text", text: part.text };
198
+ const image = dataImage(part.url);
199
+ if (image === null) {
200
+ throw new RouterFrameworkError(
201
+ "invalid_request",
202
+ `Pi ${location} images must use base64 data URLs.`,
203
+ "pi_remote_image_unsupported",
204
+ );
205
+ }
206
+ return { type: "image", data: image.data, mimeType: image.mimeType };
207
+ });
208
+ }
209
+
210
+ function dataImage(url: string): { data: string; mimeType: string } | null {
211
+ const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(url);
212
+ if (match === null) return null;
213
+ return { mimeType: match[1]!, data: match[2]!.replace(/\s/g, "") };
214
+ }
215
+
216
+ function toolArguments(value: string | Record<string, unknown>, id: string): Record<string, unknown> {
217
+ if (typeof value !== "string") return value;
218
+ // Providers emit "" for zero-argument tool calls; replay it as {}.
219
+ if (value === "") return {};
220
+ let parsed: unknown;
221
+ try {
222
+ parsed = JSON.parse(value);
223
+ } catch (error) {
224
+ throw new RouterFrameworkError(
225
+ "invalid_request",
226
+ `Tool call '${id}' arguments must be valid JSON.`,
227
+ "pi_tool_arguments_invalid",
228
+ undefined,
229
+ { cause: error },
230
+ );
231
+ }
232
+ if (!isRecord(parsed)) {
233
+ throw new RouterFrameworkError(
234
+ "invalid_request",
235
+ `Tool call '${id}' arguments must be a JSON object.`,
236
+ "pi_tool_arguments_invalid",
237
+ );
238
+ }
239
+ return parsed;
240
+ }
241
+
242
+ function zeroPiUsage(): AssistantMessage["usage"] {
243
+ return {
244
+ input: 0,
245
+ output: 0,
246
+ cacheRead: 0,
247
+ cacheWrite: 0,
248
+ totalTokens: 0,
249
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
250
+ };
251
+ }
@@ -0,0 +1,295 @@
1
+ import type { SimpleStreamOptions } from "@mupt-ai/pi-ai";
2
+
3
+ import { isOpenAIResponsesApi } from "./continuation_state.js";
4
+ import { RouterFrameworkError } from "./framework_error.js";
5
+ import { isRecord } from "./types.js";
6
+ import type { RouterContent, RouterRequest, RouterToolChoice } from "./framework_types.js";
7
+ import type {
8
+ CreatePiRuntimeOptions,
9
+ PiExecution,
10
+ PiModel,
11
+ } from "./pi_types.js";
12
+
13
+ const PAYLOAD_PATCH_APIS = new Set([
14
+ "anthropic-messages",
15
+ "azure-openai-responses",
16
+ "openai-completions",
17
+ "openai-responses",
18
+ ]);
19
+
20
+ export async function piOptions(
21
+ runtime: CreatePiRuntimeOptions,
22
+ execution: PiExecution,
23
+ model: PiModel,
24
+ ): Promise<SimpleStreamOptions> {
25
+ rejectUnsupportedOptions(execution.request, model);
26
+ const apiKey = typeof runtime.apiKey === "string"
27
+ ? runtime.apiKey
28
+ : await runtime.apiKey({
29
+ provider: model.provider,
30
+ model: execution.candidate.id,
31
+ api: model.api,
32
+ purpose: execution.purpose,
33
+ });
34
+ if (typeof apiKey !== "string" || !apiKey.trim()) {
35
+ throw new RouterFrameworkError(
36
+ "configuration",
37
+ `No API key was provided for Pi model '${execution.candidate.id}'.`,
38
+ "pi_api_key_missing",
39
+ );
40
+ }
41
+
42
+ const request = execution.request;
43
+ const options: SimpleStreamOptions = {
44
+ apiKey,
45
+ signal: execution.signal,
46
+ ...(runtime.timeoutMs === undefined ? {} : { timeoutMs: runtime.timeoutMs }),
47
+ ...(runtime.maxRetries === undefined ? {} : { maxRetries: runtime.maxRetries }),
48
+ ...(request.generation.temperature === undefined
49
+ ? {}
50
+ : { temperature: request.generation.temperature }),
51
+ ...(request.generation.topP === undefined ? {} : { topP: request.generation.topP }),
52
+ ...(request.generation.maxOutputTokens === undefined
53
+ ? {}
54
+ : { maxTokens: request.generation.maxOutputTokens }),
55
+ ...(request.generation.stop === undefined ? {} : { stop: request.generation.stop }),
56
+ ...(request.cacheKey === undefined ? {} : { sessionId: request.cacheKey }),
57
+ // Only anthropic-messages reads metadata (and only user_id); no other
58
+ // fork API consumes metadata fields.
59
+ ...(request.user !== undefined && model.api === "anthropic-messages"
60
+ ? { metadata: { user_id: request.user } }
61
+ : {}),
62
+ ...(execution.reasoningEffort === "off" ? {} : { reasoning: execution.reasoningEffort }),
63
+ ...(request.responseFormat === undefined
64
+ ? {}
65
+ : { responseFormat: piResponseFormat(request.responseFormat, model.api) }),
66
+ ...(request.toolChoice === undefined ? {} : { toolChoice: piToolChoice(request.toolChoice) }),
67
+ };
68
+
69
+ const budget = request.reasoning?.budgetTokens;
70
+ if (
71
+ budget !== undefined &&
72
+ execution.reasoningEffort !== "off" &&
73
+ execution.reasoningEffort !== "xhigh" &&
74
+ execution.reasoningEffort !== "max"
75
+ ) {
76
+ options.thinkingBudgets = { [execution.reasoningEffort]: budget };
77
+ }
78
+
79
+ const patch = payloadPatch(request, model);
80
+ if (patch !== undefined) options.onPayload = patch;
81
+ return options;
82
+ }
83
+
84
+ // The fork hard-throws for these (api, option) pairs inside streamSimple;
85
+ // reject them up front as client errors instead of surfacing 502s.
86
+ function rejectUnsupportedOptions(request: RouterRequest, model: PiModel): void {
87
+ if (request.generation.stop !== undefined && isOpenAIResponsesApi(model.api)) {
88
+ throw unsupportedPiOption("stop sequences", model.api);
89
+ }
90
+ if (request.generation.topP !== undefined && model.api === "anthropic-messages") {
91
+ throw unsupportedPiOption("top_p", model.api);
92
+ }
93
+ if (
94
+ typeof request.toolChoice === "object" &&
95
+ (model.api === "google-generative-ai" || model.api === "google-vertex")
96
+ ) {
97
+ throw unsupportedPiOption("forcing a specific tool with tool_choice", model.api);
98
+ }
99
+ if (request.responseFormat?.type === "json_object" && model.api === "bedrock-converse-stream") {
100
+ throw unsupportedPiOption("response_format json_object", model.api);
101
+ }
102
+ }
103
+
104
+ function unsupportedPiOption(option: string, api: string): RouterFrameworkError {
105
+ return new RouterFrameworkError(
106
+ "invalid_request",
107
+ `Pi API '${api}' does not support ${option}.`,
108
+ "pi_option_unsupported",
109
+ );
110
+ }
111
+
112
+ function piResponseFormat(
113
+ format: NonNullable<RouterRequest["responseFormat"]>,
114
+ api: string,
115
+ ): NonNullable<SimpleStreamOptions["responseFormat"]> {
116
+ if (format.type === "json_object" && api === "anthropic-messages") {
117
+ // Anthropic has no json_object mode; degrade to a permissive object
118
+ // schema so the request still produces JSON output.
119
+ return {
120
+ type: "json_schema",
121
+ jsonSchema: { name: "response", schema: { type: "object" } },
122
+ };
123
+ }
124
+ if (format.type !== "json_schema") return format;
125
+ return {
126
+ type: "json_schema",
127
+ jsonSchema: {
128
+ name: format.name,
129
+ schema: format.schema,
130
+ ...(format.description === undefined ? {} : { description: format.description }),
131
+ ...(format.strict === undefined ? {} : { strict: format.strict }),
132
+ },
133
+ };
134
+ }
135
+
136
+ function piToolChoice(choice: RouterToolChoice): NonNullable<SimpleStreamOptions["toolChoice"]> {
137
+ if (choice === "required") return "any";
138
+ if (typeof choice === "string") return choice;
139
+ return { type: "function", function: { name: choice.name } };
140
+ }
141
+
142
+ function payloadPatch(
143
+ request: RouterRequest,
144
+ model: PiModel,
145
+ ): SimpleStreamOptions["onPayload"] | undefined {
146
+ const strictTools = request.tools.some((tool) => tool.strict !== undefined);
147
+ const parallel = request.parallelToolCalls;
148
+ const imageDetails = imageDetailQueues(request);
149
+ const thinkingPatch = model.api === "anthropic-messages" && (
150
+ request.reasoning?.enabled === false || request.reasoning?.budgetTokens !== undefined
151
+ );
152
+ const imageDetailPatch = imageDetails.size > 0 &&
153
+ (isOpenAIResponsesApi(model.api) || model.api === "openai-completions");
154
+ if (
155
+ imageDetails.size > 0 &&
156
+ !imageDetailPatch &&
157
+ [...imageDetails.values()].some((queue) => queue.some((detail) => detail !== "auto"))
158
+ ) {
159
+ throw new RouterFrameworkError(
160
+ "invalid_request",
161
+ `Pi API '${model.api}' cannot preserve image detail controls.`,
162
+ "pi_image_detail_unsupported",
163
+ );
164
+ }
165
+ if (!strictTools && parallel === undefined && !thinkingPatch && !imageDetailPatch) {
166
+ return undefined;
167
+ }
168
+ if ((strictTools || parallel !== undefined) && !PAYLOAD_PATCH_APIS.has(model.api)) {
169
+ throw new RouterFrameworkError(
170
+ "invalid_request",
171
+ `Pi API '${model.api}' cannot preserve strict or parallel tool controls.`,
172
+ "pi_tool_control_unsupported",
173
+ );
174
+ }
175
+
176
+ return (value) => {
177
+ if (!isRecord(value)) return undefined;
178
+ let payload = value;
179
+ if (strictTools) payload = patchStrictTools(payload, request, model.api);
180
+ if (parallel !== undefined) payload = patchParallelTools(payload, parallel, model.api);
181
+ if (imageDetailPatch) payload = patchImageDetails(payload, imageDetails);
182
+ if (thinkingPatch) payload = patchAnthropicThinking(payload, request);
183
+ return payload;
184
+ };
185
+ }
186
+
187
+ function patchStrictTools(
188
+ payload: Record<string, unknown>,
189
+ request: RouterRequest,
190
+ api: string,
191
+ ): Record<string, unknown> {
192
+ if (!Array.isArray(payload.tools)) return payload;
193
+ const tools = new Map(request.tools.map((tool) => [tool.name, tool]));
194
+ return {
195
+ ...payload,
196
+ tools: payload.tools.map((entry) => {
197
+ if (!isRecord(entry)) return entry;
198
+ if (api === "anthropic-messages") {
199
+ const tool = typeof entry.name === "string" ? tools.get(entry.name) : undefined;
200
+ return tool?.strict === undefined ? entry : { ...entry, strict: tool.strict };
201
+ }
202
+ const definition = api === "openai-completions" && isRecord(entry.function)
203
+ ? entry.function
204
+ : entry;
205
+ const tool = typeof definition.name === "string" ? tools.get(definition.name) : undefined;
206
+ if (tool?.strict === undefined) return entry;
207
+ return api === "openai-completions"
208
+ ? { ...entry, function: { ...definition, strict: tool.strict } }
209
+ : { ...entry, strict: tool.strict };
210
+ }),
211
+ };
212
+ }
213
+
214
+ function patchParallelTools(
215
+ payload: Record<string, unknown>,
216
+ enabled: boolean,
217
+ api: string,
218
+ ): Record<string, unknown> {
219
+ if (api !== "anthropic-messages") return { ...payload, parallel_tool_calls: enabled };
220
+ if (enabled) return payload;
221
+ const current = isRecord(payload.tool_choice) ? payload.tool_choice : {};
222
+ return {
223
+ ...payload,
224
+ tool_choice: {
225
+ type: typeof current.type === "string" ? current.type : "auto",
226
+ ...current,
227
+ disable_parallel_tool_use: true,
228
+ },
229
+ };
230
+ }
231
+
232
+ function patchImageDetails(
233
+ payload: Record<string, unknown>,
234
+ details: Map<string, Array<"auto" | "low" | "high">>,
235
+ ): Record<string, unknown> {
236
+ const queues = new Map([...details].map(([url, queue]) => [url, [...queue]]));
237
+ const patch = (value: unknown): unknown => {
238
+ if (Array.isArray(value)) return value.map(patch);
239
+ if (!isRecord(value)) return value;
240
+ const mapped = Object.fromEntries(
241
+ Object.entries(value).map(([key, entry]) => [key, patch(entry)]),
242
+ );
243
+ const responseUrl = mapped.type === "input_image" && typeof mapped.image_url === "string"
244
+ ? mapped.image_url
245
+ : undefined;
246
+ const chatImage = mapped.type === "image_url" && isRecord(mapped.image_url)
247
+ ? mapped.image_url
248
+ : undefined;
249
+ const chatUrl = typeof chatImage?.url === "string" ? chatImage.url : undefined;
250
+ const detail = queues.get(responseUrl ?? chatUrl ?? "")?.shift();
251
+ if (detail === undefined) return mapped;
252
+ return responseUrl === undefined
253
+ ? { ...mapped, image_url: { ...chatImage, detail } }
254
+ : { ...mapped, detail };
255
+ };
256
+ const patched = patch(payload);
257
+ return isRecord(patched) ? patched : payload;
258
+ }
259
+
260
+ function imageDetailQueues(
261
+ request: RouterRequest,
262
+ ): Map<string, Array<"auto" | "low" | "high">> {
263
+ const details = new Map<string, Array<"auto" | "low" | "high">>();
264
+ const add = (content: readonly RouterContent[]) => {
265
+ for (const part of content) {
266
+ if (part.type !== "image" || part.detail === undefined) continue;
267
+ const queue = details.get(part.url) ?? [];
268
+ queue.push(part.detail);
269
+ details.set(part.url, queue);
270
+ }
271
+ };
272
+ for (const item of request.items) {
273
+ if (item.type === "message" || item.type === "tool_result") add(item.content);
274
+ }
275
+ return details;
276
+ }
277
+
278
+ function patchAnthropicThinking(
279
+ payload: Record<string, unknown>,
280
+ request: RouterRequest,
281
+ ): Record<string, unknown> {
282
+ if (request.reasoning?.enabled === false) {
283
+ return { ...payload, thinking: { type: "disabled" } };
284
+ }
285
+ const budget = request.reasoning?.budgetTokens;
286
+ if (budget === undefined) return payload;
287
+ return {
288
+ ...payload,
289
+ thinking: {
290
+ ...(isRecord(payload.thinking) ? payload.thinking : {}),
291
+ type: "enabled",
292
+ budget_tokens: budget,
293
+ },
294
+ };
295
+ }