@valni/cli-darwin-x64 0.0.0-stage → 0.1.3

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 (183) hide show
  1. package/LICENSE +52 -0
  2. package/package.json +18 -5
  3. package/runtime/LICENSE +52 -0
  4. package/runtime/assets/clankolas.png +0 -0
  5. package/runtime/examples/extensions/auto-commit-on-exit.ts +49 -0
  6. package/runtime/examples/extensions/bash-spawn-hook.ts +30 -0
  7. package/runtime/examples/extensions/bookmark.ts +50 -0
  8. package/runtime/examples/extensions/border-status-editor.ts +145 -0
  9. package/runtime/examples/extensions/built-in-tool-renderer.ts +249 -0
  10. package/runtime/examples/extensions/claude-rules.ts +86 -0
  11. package/runtime/examples/extensions/commands.ts +72 -0
  12. package/runtime/examples/extensions/confirm-destructive.ts +59 -0
  13. package/runtime/examples/extensions/custom-footer.ts +64 -0
  14. package/runtime/examples/extensions/custom-header.ts +73 -0
  15. package/runtime/examples/extensions/custom-provider-anthropic/index.ts +604 -0
  16. package/runtime/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  17. package/runtime/examples/extensions/custom-provider-anthropic/package.json +19 -0
  18. package/runtime/examples/extensions/dirty-repo-guard.ts +56 -0
  19. package/runtime/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  20. package/runtime/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  21. package/runtime/examples/extensions/doom-overlay/doom/build.sh +152 -0
  22. package/runtime/examples/extensions/doom-overlay/doom/doomgeneric_valni.c +72 -0
  23. package/runtime/examples/extensions/doom-overlay/doom-component.ts +132 -0
  24. package/runtime/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  25. package/runtime/examples/extensions/doom-overlay/doom-keys.ts +104 -0
  26. package/runtime/examples/extensions/doom-overlay/index.ts +74 -0
  27. package/runtime/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  28. package/runtime/examples/extensions/dynamic-resources/dynamic.json +78 -0
  29. package/runtime/examples/extensions/dynamic-resources/index.ts +13 -0
  30. package/runtime/examples/extensions/dynamic-tools.ts +74 -0
  31. package/runtime/examples/extensions/entry-renderer.ts +41 -0
  32. package/runtime/examples/extensions/event-bus.ts +43 -0
  33. package/runtime/examples/extensions/file-trigger.ts +41 -0
  34. package/runtime/examples/extensions/git-checkpoint.ts +53 -0
  35. package/runtime/examples/extensions/git-merge-and-resolve.ts +115 -0
  36. package/runtime/examples/extensions/github-issue-autocomplete.ts +185 -0
  37. package/runtime/examples/extensions/hello.ts +26 -0
  38. package/runtime/examples/extensions/hidden-thinking-label.ts +53 -0
  39. package/runtime/examples/extensions/inline-bash.ts +94 -0
  40. package/runtime/examples/extensions/input-transform-streaming.ts +39 -0
  41. package/runtime/examples/extensions/input-transform.ts +43 -0
  42. package/runtime/examples/extensions/interactive-shell.ts +196 -0
  43. package/runtime/examples/extensions/mac-system-theme.ts +47 -0
  44. package/runtime/examples/extensions/message-renderer.ts +59 -0
  45. package/runtime/examples/extensions/minimal-mode.ts +426 -0
  46. package/runtime/examples/extensions/modal-editor.ts +85 -0
  47. package/runtime/examples/extensions/model-status.ts +31 -0
  48. package/runtime/examples/extensions/notify.ts +55 -0
  49. package/runtime/examples/extensions/overlay-qa-tests.ts +1450 -0
  50. package/runtime/examples/extensions/overlay-test.ts +153 -0
  51. package/runtime/examples/extensions/permission-gate.ts +34 -0
  52. package/runtime/examples/extensions/pirate.ts +47 -0
  53. package/runtime/examples/extensions/plan-mode/index.ts +390 -0
  54. package/runtime/examples/extensions/plan-mode/utils.ts +168 -0
  55. package/runtime/examples/extensions/preset.ts +436 -0
  56. package/runtime/examples/extensions/project-trust.ts +64 -0
  57. package/runtime/examples/extensions/prompt-customizer.ts +97 -0
  58. package/runtime/examples/extensions/protected-paths.ts +30 -0
  59. package/runtime/examples/extensions/provider-payload.ts +18 -0
  60. package/runtime/examples/extensions/question.ts +278 -0
  61. package/runtime/examples/extensions/questionnaire.ts +440 -0
  62. package/runtime/examples/extensions/rainbow-editor.ts +88 -0
  63. package/runtime/examples/extensions/reload-runtime.ts +37 -0
  64. package/runtime/examples/extensions/rpc-demo.ts +118 -0
  65. package/runtime/examples/extensions/sandbox/index.ts +321 -0
  66. package/runtime/examples/extensions/sandbox/package-lock.json +92 -0
  67. package/runtime/examples/extensions/sandbox/package.json +19 -0
  68. package/runtime/examples/extensions/send-user-message.ts +97 -0
  69. package/runtime/examples/extensions/session-name.ts +27 -0
  70. package/runtime/examples/extensions/shutdown-command.ts +63 -0
  71. package/runtime/examples/extensions/snake.ts +343 -0
  72. package/runtime/examples/extensions/space-invaders.ts +560 -0
  73. package/runtime/examples/extensions/ssh.ts +220 -0
  74. package/runtime/examples/extensions/status-line.ts +32 -0
  75. package/runtime/examples/extensions/structured-output.ts +65 -0
  76. package/runtime/examples/extensions/subagent/agents.ts +126 -0
  77. package/runtime/examples/extensions/subagent/index.ts +1009 -0
  78. package/runtime/examples/extensions/system-prompt-header.ts +17 -0
  79. package/runtime/examples/extensions/tic-tac-toe.ts +1008 -0
  80. package/runtime/examples/extensions/timed-confirm.ts +70 -0
  81. package/runtime/examples/extensions/titlebar-spinner.ts +58 -0
  82. package/runtime/examples/extensions/todo.ts +297 -0
  83. package/runtime/examples/extensions/tool-override.ts +144 -0
  84. package/runtime/examples/extensions/tools.ts +146 -0
  85. package/runtime/examples/extensions/trigger-compact.ts +50 -0
  86. package/runtime/examples/extensions/truncated-tool.ts +195 -0
  87. package/runtime/examples/extensions/widget-placement.ts +9 -0
  88. package/runtime/examples/extensions/with-deps/index.ts +32 -0
  89. package/runtime/examples/extensions/with-deps/package-lock.json +31 -0
  90. package/runtime/examples/extensions/with-deps/package.json +22 -0
  91. package/runtime/examples/extensions/working-indicator.ts +123 -0
  92. package/runtime/examples/extensions/working-message-test.ts +25 -0
  93. package/runtime/examples/rpc-extension-ui.ts +632 -0
  94. package/runtime/examples/sdk/01-minimal.ts +26 -0
  95. package/runtime/examples/sdk/02-custom-model.ts +43 -0
  96. package/runtime/examples/sdk/03-custom-prompt.ts +70 -0
  97. package/runtime/examples/sdk/04-skills.ts +55 -0
  98. package/runtime/examples/sdk/05-tools.ts +48 -0
  99. package/runtime/examples/sdk/06-extensions.ts +94 -0
  100. package/runtime/examples/sdk/07-context-files.ts +42 -0
  101. package/runtime/examples/sdk/08-prompt-templates.ts +51 -0
  102. package/runtime/examples/sdk/09-api-keys-and-oauth.ts +34 -0
  103. package/runtime/examples/sdk/10-settings.ts +53 -0
  104. package/runtime/examples/sdk/11-sessions.ts +52 -0
  105. package/runtime/examples/sdk/12-full-control.ts +71 -0
  106. package/runtime/examples/sdk/13-session-runtime.ts +67 -0
  107. package/runtime/export-html/ansi-to-html.js +249 -0
  108. package/runtime/export-html/index.js +226 -0
  109. package/runtime/export-html/template.css +1066 -0
  110. package/runtime/export-html/template.html +55 -0
  111. package/runtime/export-html/template.js +1864 -0
  112. package/runtime/export-html/tool-renderer.js +108 -0
  113. package/runtime/export-html/vendor/highlight.min.js +1213 -0
  114. package/runtime/export-html/vendor/marked.min.js +78 -0
  115. package/runtime/extensions/pi-switchboard/.github/workflows/ci.yml +51 -0
  116. package/runtime/extensions/pi-switchboard/LICENSE +22 -0
  117. package/runtime/extensions/pi-switchboard/README.md +85 -0
  118. package/runtime/extensions/pi-switchboard/extensions/switchboard/catalog.ts +183 -0
  119. package/runtime/extensions/pi-switchboard/extensions/switchboard/config.ts +97 -0
  120. package/runtime/extensions/pi-switchboard/extensions/switchboard/connections.ts +386 -0
  121. package/runtime/extensions/pi-switchboard/extensions/switchboard/constants.ts +12 -0
  122. package/runtime/extensions/pi-switchboard/extensions/switchboard/device-auth.ts +127 -0
  123. package/runtime/extensions/pi-switchboard/extensions/switchboard/envelope.ts +199 -0
  124. package/runtime/extensions/pi-switchboard/extensions/switchboard/errors.ts +180 -0
  125. package/runtime/extensions/pi-switchboard/extensions/switchboard/index.ts +125 -0
  126. package/runtime/extensions/pi-switchboard/extensions/switchboard/participant.ts +17 -0
  127. package/runtime/extensions/pi-switchboard/extensions/switchboard/sessionStream.ts +131 -0
  128. package/runtime/extensions/pi-switchboard/extensions/switchboard/toolProxy.ts +136 -0
  129. package/runtime/extensions/pi-switchboard/extensions/switchboard/tools.ts +16 -0
  130. package/runtime/extensions/pi-switchboard/extensions/switchboard/types.ts +37 -0
  131. package/runtime/extensions/pi-switchboard/package.json +30 -0
  132. package/runtime/extensions/pi-switchboard/tsconfig.json +13 -0
  133. package/runtime/extensions/switchboard-local/.github/workflows/ci.yml +80 -0
  134. package/runtime/extensions/switchboard-local/.github/workflows/release.yml +46 -0
  135. package/runtime/extensions/switchboard-local/README.md +98 -0
  136. package/runtime/extensions/switchboard-local/extensions/local/engine.ts +28 -0
  137. package/runtime/extensions/switchboard-local/extensions/local/engines/gguf.ts +324 -0
  138. package/runtime/extensions/switchboard-local/extensions/local/engines/llamaBuild.ts +62 -0
  139. package/runtime/extensions/switchboard-local/extensions/local/engines/mlx.ts +245 -0
  140. package/runtime/extensions/switchboard-local/extensions/local/engines/select.ts +38 -0
  141. package/runtime/extensions/switchboard-local/extensions/local/engines/support.ts +151 -0
  142. package/runtime/extensions/switchboard-local/extensions/local/hf.ts +106 -0
  143. package/runtime/extensions/switchboard-local/extensions/local/index.ts +67 -0
  144. package/runtime/extensions/switchboard-local/extensions/local/nativeStream.ts +602 -0
  145. package/runtime/extensions/switchboard-local/extensions/local/paths.ts +44 -0
  146. package/runtime/extensions/switchboard-local/extensions/local/provider.ts +57 -0
  147. package/runtime/extensions/switchboard-local/extensions/local/store.ts +82 -0
  148. package/runtime/extensions/switchboard-local/extensions/local/ui.ts +60 -0
  149. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/apiTypes.js +74 -0
  150. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/client.js +292 -0
  151. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/deviceAuth.js +155 -0
  152. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/errors.js +95 -0
  153. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/index.js +8 -0
  154. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/messages.js +253 -0
  155. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/models.js +19 -0
  156. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/native.js +886 -0
  157. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/dist/wire.js +85 -0
  158. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard/package.json +39 -0
  159. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/package.json +29 -0
  160. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/anthropic.ts +127 -0
  161. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/google.ts +111 -0
  162. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/index.ts +8 -0
  163. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/modelRecord.test.ts +25 -0
  164. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/modelRecord.ts +57 -0
  165. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/openaiGeneric.ts +103 -0
  166. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/openaiPro.ts +89 -0
  167. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/profile.ts +120 -0
  168. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/routing.test.ts +82 -0
  169. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/routing.ts +74 -0
  170. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.google.test.ts +137 -0
  171. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.openai.test.ts +241 -0
  172. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.test.ts +174 -0
  173. package/runtime/extensions/switchboard-local/node_modules/@valni/switchboard-native/src/validateConfig.ts +438 -0
  174. package/runtime/extensions/switchboard-local/package.json +34 -0
  175. package/runtime/extensions/switchboard-local/tsconfig.json +13 -0
  176. package/runtime/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  177. package/runtime/package.json +98 -0
  178. package/runtime/photon_rs_bg.wasm +0 -0
  179. package/runtime/theme/dark.json +86 -0
  180. package/runtime/theme/light.json +85 -0
  181. package/runtime/theme/theme-schema.json +340 -0
  182. package/runtime/valni +4 -0
  183. package/README.md +0 -3
@@ -0,0 +1,602 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ type AssistantMessageEventStream,
5
+ createAssistantMessageEventStream,
6
+ type Context,
7
+ type ImageContent,
8
+ type Message,
9
+ type Model,
10
+ parseStreamingJson,
11
+ type SimpleStreamOptions,
12
+ type StopReason,
13
+ type TextContent,
14
+ type ThinkingContent,
15
+ type Tool,
16
+ type ToolCall,
17
+ type ToolResultMessage,
18
+ } from "@earendil-works/pi-ai";
19
+ import { messages as sdkMessages, native } from "@valni/switchboard";
20
+
21
+ const SSE_DATA_PREFIX = "data:";
22
+ const COMPLETIONS_PATH = "chat/completions";
23
+ const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
24
+ const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
25
+ const NO_TOOL_OUTPUT_PLACEHOLDER = "(no tool output)";
26
+ const IMAGE_ONLY_PLACEHOLDER = "(see attached image)";
27
+ const TOOL_IMAGE_PREAMBLE = "Attached image(s) from tool result:";
28
+
29
+ type KnownApi = "openai-completions";
30
+ type WireObject = Record<string, unknown>;
31
+ type StreamingToolCall = ToolCall & { partialJson?: string };
32
+
33
+ type StreamingBlock = (TextContent | ThinkingContent | StreamingToolCall) & { index?: number };
34
+
35
+ interface StreamState {
36
+ output: AssistantMessage;
37
+ stream: AssistantMessageEventStream;
38
+ sawTerminalEvent: boolean;
39
+ }
40
+
41
+ interface StreamState {
42
+ output: AssistantMessage;
43
+ stream: AssistantMessageEventStream;
44
+ sawTerminalEvent: boolean;
45
+ }
46
+
47
+ interface OpenAIChatStreamScratch {
48
+ textIndex: number | null;
49
+ thinkingIndex: number | null;
50
+ toolCallsByStreamIndex: Map<number, number>;
51
+ sawFinishReason: boolean;
52
+ }
53
+
54
+ export function sanitizeText(text: string): string {
55
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
56
+ }
57
+
58
+ function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] {
59
+ const result: TextContent[] = [];
60
+ let previousWasPlaceholder = false;
61
+ for (const block of content) {
62
+ if (block.type === "image") {
63
+ if (!previousWasPlaceholder) {
64
+ result.push({ type: "text", text: placeholder });
65
+ }
66
+ previousWasPlaceholder = true;
67
+ continue;
68
+ }
69
+ result.push(block);
70
+ previousWasPlaceholder = block.text === placeholder;
71
+ }
72
+ return result;
73
+ }
74
+
75
+ function downgradeUnsupportedImages(model: Model<KnownApi>, messages: Message[]): Message[] {
76
+ if (model.input.includes("image")) {
77
+ return messages;
78
+ }
79
+ return messages.map((message) => {
80
+ if (message.role === "user" && Array.isArray(message.content)) {
81
+ return {
82
+ ...message,
83
+ content: replaceImagesWithPlaceholder(message.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
84
+ };
85
+ }
86
+ if (message.role === "toolResult") {
87
+ return {
88
+ ...message,
89
+ content: replaceImagesWithPlaceholder(message.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER),
90
+ };
91
+ }
92
+ return message;
93
+ });
94
+ }
95
+
96
+ function normalizeToolCallId(api: KnownApi, id: string): string {
97
+ if (api === "openai-completions") {
98
+ const callId = id.includes("|") ? id.split("|")[0] : id;
99
+ return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
100
+ }
101
+ const callId = id.includes("|") ? id.split("|")[0] : id;
102
+ return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
103
+ }
104
+
105
+ export function normalizeContextMessages(model: Model<KnownApi>, contextMessages: Message[]): Message[] {
106
+ const toolCallIdMap = new Map<string, string>();
107
+ const withContent = contextMessages.map((message) =>
108
+ message.content == null ? ({ ...message, content: [] } as Message) : message,
109
+ );
110
+ const imageAware = downgradeUnsupportedImages(model, withContent);
111
+
112
+ const transformed: Message[] = [];
113
+ for (const message of imageAware) {
114
+ if (message.role === "user") {
115
+ transformed.push(message);
116
+ continue;
117
+ }
118
+ if (message.role === "toolResult") {
119
+ const mappedId = toolCallIdMap.get(message.toolCallId);
120
+ transformed.push(mappedId && mappedId !== message.toolCallId ? { ...message, toolCallId: mappedId } : message);
121
+ continue;
122
+ }
123
+ const assistantMessage = message as AssistantMessage;
124
+ if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
125
+ continue;
126
+ }
127
+ const isSameModel =
128
+ assistantMessage.provider === model.provider &&
129
+ assistantMessage.api === model.api &&
130
+ assistantMessage.model === model.id;
131
+ const content = assistantMessage.content.flatMap((block): (TextContent | ThinkingContent | ToolCall)[] => {
132
+ if (block.type === "thinking") {
133
+ if (block.redacted) {
134
+ return isSameModel ? [block] : [];
135
+ }
136
+ if (isSameModel && block.thinkingSignature) return [block];
137
+ if (!block.thinking || block.thinking.trim() === "") return [];
138
+ if (isSameModel) return [block];
139
+ return [{ type: "text" as const, text: block.thinking }];
140
+ }
141
+ if (block.type === "toolCall") {
142
+ let toolCall: ToolCall = block;
143
+ if (!isSameModel && toolCall.thoughtSignature) {
144
+ const { thoughtSignature: _thoughtSignature, ...rest } = toolCall;
145
+ toolCall = rest;
146
+ }
147
+ const normalizedId = normalizeToolCallId(model.api, toolCall.id);
148
+ if (normalizedId !== toolCall.id) {
149
+ toolCallIdMap.set(toolCall.id, normalizedId);
150
+ toolCall = { ...toolCall, id: normalizedId };
151
+ }
152
+ return [toolCall];
153
+ }
154
+ return [block];
155
+ });
156
+ transformed.push({ ...assistantMessage, content });
157
+ }
158
+
159
+ const result: Message[] = [];
160
+ let pendingToolCalls: ToolCall[] = [];
161
+ let seenToolResultIds = new Set<string>();
162
+ const flushSyntheticToolResults = (): void => {
163
+ for (const toolCall of pendingToolCalls) {
164
+ if (seenToolResultIds.has(toolCall.id)) continue;
165
+ result.push({
166
+ role: "toolResult",
167
+ toolCallId: toolCall.id,
168
+ toolName: toolCall.name,
169
+ content: [{ type: "text", text: "No result provided" }],
170
+ isError: true,
171
+ timestamp: Date.now(),
172
+ } as ToolResultMessage);
173
+ }
174
+ pendingToolCalls = [];
175
+ seenToolResultIds = new Set();
176
+ };
177
+
178
+ for (const message of transformed) {
179
+ if (message.role === "assistant") {
180
+ flushSyntheticToolResults();
181
+ const toolCalls = message.content.filter((block): block is ToolCall => block.type === "toolCall");
182
+ if (toolCalls.length > 0) {
183
+ pendingToolCalls = toolCalls;
184
+ seenToolResultIds = new Set();
185
+ }
186
+ result.push(message);
187
+ } else if (message.role === "toolResult") {
188
+ seenToolResultIds.add(message.toolCallId);
189
+ result.push(message);
190
+ } else {
191
+ flushSyntheticToolResults();
192
+ result.push(message);
193
+ }
194
+ }
195
+ flushSyntheticToolResults();
196
+
197
+ return result;
198
+ }
199
+
200
+ function resolveMaxTokens(model: Model<KnownApi>, options: SimpleStreamOptions | undefined): number {
201
+ const requested = options?.maxTokens ?? model.maxTokens;
202
+ return Math.min(Math.max(1, requested), model.maxTokens);
203
+ }
204
+
205
+ function toolInputSchema(tool: Tool): Record<string, unknown> {
206
+ const schema = tool.parameters as { properties?: unknown; required?: string[] };
207
+ return {
208
+ type: "object",
209
+ properties: schema.properties ?? {},
210
+ required: schema.required ?? [],
211
+ };
212
+ }
213
+
214
+ class JsonSchemaTool extends sdkMessages.Tool {
215
+ private readonly schema: Record<string, unknown>;
216
+
217
+ constructor(tool: Tool) {
218
+ super(tool.name, tool.description, new sdkMessages.ToolParameters({}));
219
+ this.schema = tool.parameters as unknown as Record<string, unknown>;
220
+ }
221
+
222
+ override toWire(): Record<string, unknown> {
223
+ return {
224
+ type: "function",
225
+ function: {
226
+ name: this.name,
227
+ description: this.description,
228
+ parameters: this.schema,
229
+ },
230
+ };
231
+ }
232
+ }
233
+
234
+ function chatUserParts(content: (TextContent | ImageContent)[]): sdkMessages.Part[] {
235
+ const parts: sdkMessages.Part[] = [];
236
+ for (const item of content) {
237
+ if (item.type === "text") {
238
+ if (item.text.trim().length === 0) continue;
239
+ parts.push(new sdkMessages.TextPart(sanitizeText(item.text)));
240
+ } else {
241
+ parts.push(new sdkMessages.ImagePart(new sdkMessages.ImageData(item.mimeType, item.data)));
242
+ }
243
+ }
244
+ return parts;
245
+ }
246
+
247
+ function buildOpenAIChatRequest(
248
+ model: Model<KnownApi>,
249
+ context: Context,
250
+ options: SimpleStreamOptions | undefined,
251
+ ): native.OpenAIChatRequest {
252
+ const normalized = normalizeContextMessages(model, context.messages);
253
+ const requestMessages: sdkMessages.Message[] = [];
254
+
255
+ if (context.systemPrompt) {
256
+ requestMessages.push(sdkMessages.Message.system(sanitizeText(context.systemPrompt)));
257
+ }
258
+
259
+ for (let index = 0; index < normalized.length; index++) {
260
+ const message = normalized[index];
261
+ if (message.role === "user") {
262
+ if (typeof message.content === "string") {
263
+ requestMessages.push(sdkMessages.Message.user(sanitizeText(message.content)));
264
+ } else {
265
+ const parts = chatUserParts(message.content);
266
+ if (parts.length === 0) continue;
267
+ requestMessages.push(sdkMessages.Message.userParts(parts));
268
+ }
269
+ } else if (message.role === "assistant") {
270
+ const text = message.content
271
+ .filter((block): block is TextContent => block.type === "text")
272
+ .map((block) => sanitizeText(block.text))
273
+ .join("");
274
+ const toolCalls = message.content
275
+ .filter((block): block is ToolCall => block.type === "toolCall")
276
+ .map(
277
+ (block) =>
278
+ new sdkMessages.ToolCall(
279
+ block.id,
280
+ new sdkMessages.ToolCallFunction(block.name, JSON.stringify(block.arguments ?? {})),
281
+ ),
282
+ );
283
+ if (text.length === 0 && toolCalls.length === 0) continue;
284
+ requestMessages.push(
285
+ new sdkMessages.Message(sdkMessages.Role.ASSISTANT, text, {
286
+ toolCalls: toolCalls.length > 0 ? toolCalls : null,
287
+ }),
288
+ );
289
+ } else {
290
+ const imageParts: sdkMessages.Part[] = [];
291
+ while (index < normalized.length && normalized[index].role === "toolResult") {
292
+ const toolResult = normalized[index] as ToolResultMessage;
293
+ const text = toolResult.content
294
+ .filter((block): block is TextContent => block.type === "text")
295
+ .map((block) => block.text)
296
+ .join("\n");
297
+ const images = toolResult.content.filter((block): block is ImageContent => block.type === "image");
298
+ const output =
299
+ text.length > 0 ? text : images.length > 0 ? IMAGE_ONLY_PLACEHOLDER : NO_TOOL_OUTPUT_PLACEHOLDER;
300
+ requestMessages.push(sdkMessages.Message.tool(toolResult.toolCallId, sanitizeText(output)));
301
+ if (model.input.includes("image")) {
302
+ for (const image of images) {
303
+ imageParts.push(new sdkMessages.ImagePart(new sdkMessages.ImageData(image.mimeType, image.data)));
304
+ }
305
+ }
306
+ index++;
307
+ }
308
+ index--;
309
+ if (imageParts.length > 0) {
310
+ requestMessages.push(
311
+ sdkMessages.Message.userParts([new sdkMessages.TextPart(TOOL_IMAGE_PREAMBLE), ...imageParts]),
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ const reasoningLevel = model.reasoning ? options?.reasoning : undefined;
318
+ return new native.OpenAIChatRequest({
319
+ model: model.id,
320
+ messages: requestMessages,
321
+ max_tokens: resolveMaxTokens(model, options),
322
+ temperature: options?.temperature ?? null,
323
+ tools: context.tools && context.tools.length > 0 ? context.tools.map((tool) => new JsonSchemaTool(tool)) : null,
324
+ reasoning_effort: reasoningLevel ?? null,
325
+ });
326
+ }
327
+
328
+ function emptyUsage(): AssistantMessage["usage"] {
329
+ return {
330
+ input: 0,
331
+ output: 0,
332
+ cacheRead: 0,
333
+ cacheWrite: 0,
334
+ totalTokens: 0,
335
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
336
+ };
337
+ }
338
+
339
+ function readInteger(fields: WireObject | null | undefined, key: string): number | null {
340
+ const value = fields?.[key];
341
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
342
+ }
343
+
344
+ function finalizeUsageTotals(output: AssistantMessage): void {
345
+ output.usage.totalTokens =
346
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
347
+ }
348
+
349
+ function applyOpenAIUsage(output: AssistantMessage, usage: native.OpenAIUsage): void {
350
+ const cacheRead = readInteger(usage.prompt_tokens_details, "cached_tokens") ?? 0;
351
+ const cacheWrite = readInteger(usage.prompt_tokens_details, "cache_write_tokens") ?? 0;
352
+ output.usage.input = Math.max(0, usage.prompt_tokens - cacheRead - cacheWrite);
353
+ output.usage.output = usage.completion_tokens;
354
+ output.usage.cacheRead = cacheRead;
355
+ output.usage.cacheWrite = cacheWrite;
356
+ const reasoning = readInteger(usage.completion_tokens_details, "reasoning_tokens");
357
+ if (reasoning !== null) output.usage.reasoning = reasoning;
358
+ finalizeUsageTotals(output);
359
+ }
360
+
361
+ function mapOpenAIFinishReason(reason: string): { stopReason: StopReason; errorMessage?: string } {
362
+ switch (reason) {
363
+ case "stop":
364
+ case "end":
365
+ return { stopReason: "stop" };
366
+ case "length":
367
+ return { stopReason: "length" };
368
+ case "tool_calls":
369
+ case "function_call":
370
+ return { stopReason: "toolUse" };
371
+ default:
372
+ return { stopReason: "error", errorMessage: `Provider finish_reason: ${reason}` };
373
+ }
374
+ }
375
+
376
+ async function* sseLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string, void, void> {
377
+ const decoder = new TextDecoder();
378
+ let buffered = "";
379
+ for await (const chunk of body) {
380
+ buffered += decoder.decode(chunk, { stream: true });
381
+ const lines = buffered.split(/\r\n|\r|\n/);
382
+ buffered = lines.pop() ?? "";
383
+ yield* lines;
384
+ }
385
+ buffered += decoder.decode();
386
+ if (buffered.length > 0) {
387
+ yield buffered;
388
+ }
389
+ }
390
+
391
+ function ssePayload(line: string): string | null {
392
+ if (!line.startsWith(SSE_DATA_PREFIX)) return null;
393
+ const payload = line.slice(SSE_DATA_PREFIX.length).trim();
394
+ if (payload.length === 0 || payload === native.DONE_SENTINEL) return null;
395
+ return payload;
396
+ }
397
+
398
+ function pushBlockStart(state: StreamState, block: StreamingBlock): number {
399
+ state.output.content.push(block);
400
+ const contentIndex = state.output.content.length - 1;
401
+ if (block.type === "text") {
402
+ state.stream.push({ type: "text_start", contentIndex, partial: state.output });
403
+ } else if (block.type === "thinking") {
404
+ state.stream.push({ type: "thinking_start", contentIndex, partial: state.output });
405
+ } else {
406
+ state.stream.push({ type: "toolcall_start", contentIndex, partial: state.output });
407
+ }
408
+ return contentIndex;
409
+ }
410
+
411
+ function pushBlockEnd(state: StreamState, block: StreamingBlock, contentIndex: number): void {
412
+ if (block.type === "text") {
413
+ state.stream.push({ type: "text_end", contentIndex, content: block.text, partial: state.output });
414
+ } else if (block.type === "thinking") {
415
+ state.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: state.output });
416
+ } else {
417
+ block.arguments = parseStreamingJson(block.partialJson);
418
+ delete block.partialJson;
419
+ delete block.index;
420
+ state.stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: state.output });
421
+ }
422
+ }
423
+
424
+ function handleOpenAIChatChunk(
425
+ state: StreamState,
426
+ scratch: OpenAIChatStreamScratch,
427
+ chunk: native.OpenAIChatChunk,
428
+ ): void {
429
+ const { output, stream } = state;
430
+ const blocks = output.content as StreamingBlock[];
431
+
432
+ output.responseId ||= chunk.id;
433
+ if (chunk.model.length > 0 && chunk.model !== output.model) {
434
+ output.responseModel ||= chunk.model;
435
+ }
436
+ if (chunk.usage) {
437
+ applyOpenAIUsage(output, chunk.usage);
438
+ }
439
+
440
+ const choice = chunk.choices[0];
441
+ if (!choice) return;
442
+
443
+ if (choice.finish_reason) {
444
+ const mapped = mapOpenAIFinishReason(choice.finish_reason);
445
+ output.stopReason = mapped.stopReason;
446
+ if (mapped.errorMessage) output.errorMessage = mapped.errorMessage;
447
+ scratch.sawFinishReason = true;
448
+ }
449
+
450
+ const delta = choice.delta;
451
+ const content = delta.content;
452
+ if (typeof content === "string" && content.length > 0) {
453
+ if (scratch.textIndex === null) {
454
+ scratch.textIndex = pushBlockStart(state, { type: "text", text: "" });
455
+ }
456
+ const block = blocks[scratch.textIndex] as TextContent;
457
+ block.text += content;
458
+ stream.push({ type: "text_delta", contentIndex: scratch.textIndex, delta: content, partial: output });
459
+ }
460
+
461
+ for (const field of ["reasoning_content", "reasoning", "reasoning_text"]) {
462
+ const reasoningDelta = delta[field];
463
+ if (typeof reasoningDelta === "string" && reasoningDelta.length > 0) {
464
+ if (scratch.thinkingIndex === null) {
465
+ scratch.thinkingIndex = pushBlockStart(state, { type: "thinking", thinking: "" });
466
+ }
467
+ const block = blocks[scratch.thinkingIndex] as ThinkingContent;
468
+ block.thinking += reasoningDelta;
469
+ stream.push({
470
+ type: "thinking_delta",
471
+ contentIndex: scratch.thinkingIndex,
472
+ delta: reasoningDelta,
473
+ partial: output,
474
+ });
475
+ break;
476
+ }
477
+ }
478
+
479
+ const toolCalls = delta.tool_calls;
480
+ if (Array.isArray(toolCalls)) {
481
+ for (const rawToolCall of toolCalls) {
482
+ if (rawToolCall === null || typeof rawToolCall !== "object") continue;
483
+ const toolCallWire = rawToolCall as WireObject;
484
+ const streamIndex = typeof toolCallWire.index === "number" ? toolCallWire.index : 0;
485
+ let contentIndex = scratch.toolCallsByStreamIndex.get(streamIndex);
486
+ if (contentIndex === undefined) {
487
+ contentIndex = pushBlockStart(state, {
488
+ type: "toolCall",
489
+ id: typeof toolCallWire.id === "string" ? toolCallWire.id : "",
490
+ name: "",
491
+ arguments: {},
492
+ partialJson: "",
493
+ });
494
+ scratch.toolCallsByStreamIndex.set(streamIndex, contentIndex);
495
+ }
496
+ const block = blocks[contentIndex] as StreamingToolCall;
497
+ if (!block.id && typeof toolCallWire.id === "string") {
498
+ block.id = toolCallWire.id;
499
+ }
500
+ const functionWire = (toolCallWire.function ?? null) as WireObject | null;
501
+ if (functionWire) {
502
+ if (!block.name && typeof functionWire.name === "string") {
503
+ block.name = functionWire.name;
504
+ }
505
+ let deltaText = "";
506
+ if (typeof functionWire.arguments === "string" && functionWire.arguments.length > 0) {
507
+ deltaText = functionWire.arguments;
508
+ block.partialJson = (block.partialJson ?? "") + deltaText;
509
+ block.arguments = parseStreamingJson(block.partialJson);
510
+ }
511
+ stream.push({ type: "toolcall_delta", contentIndex, delta: deltaText, partial: output });
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ function completionsUrl(baseUrl: string): string {
518
+ return `${baseUrl.replace(/\/+$/u, "")}/${COMPLETIONS_PATH}`;
519
+ }
520
+
521
+ export function streamLocalModel(
522
+ model: Model<Api>,
523
+ context: Context,
524
+ options?: SimpleStreamOptions,
525
+ ): AssistantMessageEventStream {
526
+ const stream = createAssistantMessageEventStream();
527
+
528
+ (async () => {
529
+ const output: AssistantMessage = {
530
+ role: "assistant",
531
+ content: [],
532
+ api: model.api,
533
+ provider: model.provider,
534
+ model: model.id,
535
+ usage: emptyUsage(),
536
+ stopReason: "stop",
537
+ timestamp: Date.now(),
538
+ };
539
+ const state: StreamState = { output, stream, sawTerminalEvent: false };
540
+
541
+ try {
542
+ const request = buildOpenAIChatRequest(model as Model<KnownApi>, context, options).streaming();
543
+ const headers: Record<string, string> = { "content-type": "application/json", ...model.headers };
544
+ if (options?.apiKey) headers.authorization = `Bearer ${options.apiKey}`;
545
+
546
+ const response = await fetch(completionsUrl(model.baseUrl), {
547
+ method: "POST",
548
+ headers,
549
+ body: JSON.stringify(request.toWire()),
550
+ signal: options?.signal,
551
+ });
552
+ if (!response.ok) {
553
+ const detail = (await response.text().catch(() => "")).trim().slice(0, 500);
554
+ throw new Error(`${model.baseUrl} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
555
+ }
556
+ if (response.body === null) throw new Error(`${model.baseUrl} returned no response body`);
557
+
558
+ stream.push({ type: "start", partial: output });
559
+
560
+ const scratch: OpenAIChatStreamScratch = {
561
+ textIndex: null,
562
+ thinkingIndex: null,
563
+ toolCallsByStreamIndex: new Map(),
564
+ sawFinishReason: false,
565
+ };
566
+
567
+ for await (const line of sseLines(response.body)) {
568
+ const payload = ssePayload(line);
569
+ if (payload === null) continue;
570
+ const chunk = native.OpenAIChatChunk.fromWire(JSON.parse(payload));
571
+ if (chunk === null) continue;
572
+ handleOpenAIChatChunk(state, scratch, chunk);
573
+ if (scratch.sawFinishReason) state.sawTerminalEvent = true;
574
+ }
575
+
576
+ const blocks = output.content as StreamingBlock[];
577
+ for (let contentIndex = 0; contentIndex < blocks.length; contentIndex++) {
578
+ pushBlockEnd(state, blocks[contentIndex], contentIndex);
579
+ }
580
+
581
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
582
+ if (output.stopReason === "aborted" || output.stopReason === "error") {
583
+ throw new Error(output.errorMessage || "An unknown error occurred");
584
+ }
585
+ if (!state.sawTerminalEvent) throw new Error("The stream ended before a terminal event");
586
+
587
+ stream.push({ type: "done", reason: output.stopReason, message: output });
588
+ stream.end();
589
+ } catch (error) {
590
+ for (const block of output.content) {
591
+ delete (block as { index?: number }).index;
592
+ delete (block as { partialJson?: string }).partialJson;
593
+ }
594
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
595
+ output.errorMessage = error instanceof Error ? error.message : String(error);
596
+ stream.push({ type: "error", reason: output.stopReason, error: output });
597
+ stream.end();
598
+ }
599
+ })();
600
+
601
+ return stream;
602
+ }
@@ -0,0 +1,44 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const MANAGED_DIR_NAME = ".switchboard-local";
5
+
6
+ export function managedDir(): string {
7
+ return join(homedir(), MANAGED_DIR_NAME);
8
+ }
9
+
10
+ export function stateFile(): string {
11
+ return join(managedDir(), "state.json");
12
+ }
13
+
14
+ export function mlxVenvDir(): string {
15
+ return join(managedDir(), "mlx-venv");
16
+ }
17
+
18
+ export function mlxVenvBin(executable: string): string {
19
+ return join(mlxVenvDir(), "bin", executable);
20
+ }
21
+
22
+ export function llamaDir(): string {
23
+ return join(managedDir(), "llama");
24
+ }
25
+
26
+ export function llamaBinDir(): string {
27
+ return join(llamaDir(), "bin");
28
+ }
29
+
30
+ export function llamaModelsDir(): string {
31
+ return join(llamaDir(), "models");
32
+ }
33
+
34
+ export function huggingFaceCacheDir(): string {
35
+ const explicit = process.env.HF_HOME?.trim();
36
+ if (explicit) return join(explicit, "hub");
37
+ const xdg = process.env.XDG_CACHE_HOME?.trim();
38
+ if (xdg) return join(xdg, "huggingface", "hub");
39
+ return join(homedir(), ".cache", "huggingface", "hub");
40
+ }
41
+
42
+ export function repoCacheDir(repoId: string): string {
43
+ return join(huggingFaceCacheDir(), `models--${repoId.replace(/\//gu, "--")}`);
44
+ }
@@ -0,0 +1,57 @@
1
+ import type { LocalModelInfo } from "./engine.ts";
2
+
3
+ export const PROVIDER_ID = "switchboard-local";
4
+ export const PROVIDER_NAME = "Switchboard Local";
5
+
6
+ const MAX_TOKENS_CEILING = 16_384;
7
+ const FREE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
8
+
9
+ export interface LocalModelConfig {
10
+ id: string;
11
+ name: string;
12
+ api: "openai-completions";
13
+ baseUrl: string;
14
+ reasoning: boolean;
15
+ input: ("text" | "image")[];
16
+ cost: typeof FREE;
17
+ contextWindow: number;
18
+ maxTokens: number;
19
+ compat: {
20
+ supportsStore: false;
21
+ supportsDeveloperRole: false;
22
+ supportsReasoningEffort: false;
23
+ supportsUsageInStreaming: true;
24
+ supportsStrictMode: false;
25
+ maxTokensField: "max_tokens";
26
+ };
27
+ }
28
+
29
+ export function displayName(repoId: string): string {
30
+ return `${repoId.split("/").at(-1) ?? repoId} (local)`;
31
+ }
32
+
33
+ export function toModelConfig(model: LocalModelInfo): LocalModelConfig {
34
+ return {
35
+ id: model.id,
36
+ name: displayName(model.id),
37
+ api: "openai-completions",
38
+ baseUrl: model.baseUrl,
39
+ reasoning: false,
40
+ input: model.vision === true ? ["text", "image"] : ["text"],
41
+ cost: FREE,
42
+ contextWindow: model.contextWindow,
43
+ maxTokens: Math.min(MAX_TOKENS_CEILING, model.contextWindow),
44
+ compat: {
45
+ supportsStore: false,
46
+ supportsDeveloperRole: false,
47
+ supportsReasoningEffort: false,
48
+ supportsUsageInStreaming: true,
49
+ supportsStrictMode: false,
50
+ maxTokensField: "max_tokens",
51
+ },
52
+ };
53
+ }
54
+
55
+ export function toModelConfigs(models: readonly LocalModelInfo[]): LocalModelConfig[] {
56
+ return models.map(toModelConfig);
57
+ }