@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2

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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,166 @@
1
+ /* Ported from BlackBeltTechnology/pi-model-proxy@179d450 test suite.
2
+ * See model-proxy/convert/UPSTREAM.md for divergences.
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { ToolCallIndexTracker, eventToSSEChunks, eventToNonStreamingResponse } from "../openai-out.js";
6
+
7
+ function parseSSE(chunk: string): { data: any } {
8
+ const lines = chunk.trim().split("\n");
9
+ const dataLine = lines.find((l) => l.startsWith("data: "));
10
+ if (!dataLine) throw new Error(`No data line in chunk: ${chunk}`);
11
+ const raw = dataLine.slice(6);
12
+ if (raw === "[DONE]") return { data: "[DONE]" };
13
+ return { data: JSON.parse(raw) };
14
+ }
15
+
16
+ const MODEL = "anthropic/claude-3-5-sonnet";
17
+ const MSG_ID = "test-id";
18
+
19
+ function makeTracker() {
20
+ return new ToolCallIndexTracker();
21
+ }
22
+
23
+ describe("ToolCallIndexTracker", () => {
24
+ it("assigns sequential indices", () => {
25
+ const t = makeTracker();
26
+ expect(t.getIndex("a")).toBe(0);
27
+ expect(t.getIndex("b")).toBe(1);
28
+ expect(t.getIndex("a")).toBe(0); // idempotent
29
+ });
30
+
31
+ it("getIndexByContentIndex resolves via toolCall id", () => {
32
+ const t = makeTracker();
33
+ t.getIndex("tc1");
34
+ const content = [{ type: "toolCall", id: "tc1" }];
35
+ expect(t.getIndexByContentIndex(0, content)).toBe(0);
36
+ });
37
+
38
+ it("getIndexByContentIndex returns 0 for non-toolCall", () => {
39
+ const t = makeTracker();
40
+ expect(t.getIndexByContentIndex(0, [{ type: "text" }])).toBe(0);
41
+ });
42
+ });
43
+
44
+ describe("eventToSSEChunks", () => {
45
+ it("start event emits assistant role chunk", () => {
46
+ const chunks = eventToSSEChunks({ type: "start" }, MODEL, MSG_ID, makeTracker());
47
+ expect(chunks).toHaveLength(1);
48
+ const { data } = parseSSE(chunks[0]);
49
+ expect(data.choices[0].delta.role).toBe("assistant");
50
+ expect(data.object).toBe("chat.completion.chunk");
51
+ });
52
+
53
+ it("text_delta emits content chunk", () => {
54
+ const chunks = eventToSSEChunks({ type: "text_delta", delta: "hello" }, MODEL, MSG_ID, makeTracker());
55
+ expect(chunks).toHaveLength(1);
56
+ const { data } = parseSSE(chunks[0]);
57
+ expect(data.choices[0].delta.content).toBe("hello");
58
+ });
59
+
60
+ it("thinking_delta emits reasoning_content chunk", () => {
61
+ const chunks = eventToSSEChunks({ type: "thinking_delta", delta: "thinking..." }, MODEL, MSG_ID, makeTracker());
62
+ expect(chunks).toHaveLength(1);
63
+ const { data } = parseSSE(chunks[0]);
64
+ expect(data.choices[0].delta.reasoning_content).toBe("thinking...");
65
+ });
66
+
67
+ it("toolcall_start emits tool_calls chunk with index 0", () => {
68
+ const tracker = makeTracker();
69
+ const event = {
70
+ type: "toolcall_start",
71
+ contentIndex: 0,
72
+ partial: { content: [{ type: "toolCall", id: "tc1", name: "my_fn" }] },
73
+ };
74
+ const chunks = eventToSSEChunks(event, MODEL, MSG_ID, tracker);
75
+ expect(chunks).toHaveLength(1);
76
+ const { data } = parseSSE(chunks[0]);
77
+ const tc = data.choices[0].delta.tool_calls[0];
78
+ expect(tc.index).toBe(0);
79
+ expect(tc.function.name).toBe("my_fn");
80
+ });
81
+
82
+ it("toolcall_delta emits argument delta", () => {
83
+ const tracker = makeTracker();
84
+ tracker.getIndex("tc1");
85
+ const event = {
86
+ type: "toolcall_delta",
87
+ contentIndex: 0,
88
+ delta: '{"x":',
89
+ partial: { content: [{ type: "toolCall", id: "tc1" }] },
90
+ };
91
+ const chunks = eventToSSEChunks(event, MODEL, MSG_ID, tracker);
92
+ expect(chunks).toHaveLength(1);
93
+ const { data } = parseSSE(chunks[0]);
94
+ expect(data.choices[0].delta.tool_calls[0].function.arguments).toBe('{"x":');
95
+ });
96
+
97
+ it("done event emits finish chunk + [DONE]", () => {
98
+ const msg = {
99
+ stopReason: "stop",
100
+ usage: { input: 10, output: 5 },
101
+ content: [],
102
+ };
103
+ const chunks = eventToSSEChunks({ type: "done", message: msg }, MODEL, MSG_ID, makeTracker());
104
+ expect(chunks).toHaveLength(2);
105
+ const { data } = parseSSE(chunks[0]);
106
+ expect(data.choices[0].finish_reason).toBe("stop");
107
+ expect(data.usage.prompt_tokens).toBe(10);
108
+ expect(chunks[1].trim()).toBe("data: [DONE]");
109
+ });
110
+
111
+ it("done with stopReason=toolUse → finish_reason=tool_calls", () => {
112
+ const msg = { stopReason: "toolUse", usage: { input: 0, output: 0 }, content: [] };
113
+ const chunks = eventToSSEChunks({ type: "done", message: msg }, MODEL, MSG_ID, makeTracker());
114
+ const { data } = parseSSE(chunks[0]);
115
+ expect(data.choices[0].finish_reason).toBe("tool_calls");
116
+ });
117
+
118
+ it("done with stopReason=length → finish_reason=length", () => {
119
+ const msg = { stopReason: "length", usage: { input: 0, output: 0 }, content: [] };
120
+ const chunks = eventToSSEChunks({ type: "done", message: msg }, MODEL, MSG_ID, makeTracker());
121
+ const { data } = parseSSE(chunks[0]);
122
+ expect(data.choices[0].finish_reason).toBe("length");
123
+ });
124
+
125
+ it("error event emits stop chunk + [DONE]", () => {
126
+ const chunks = eventToSSEChunks({ type: "error", error: { errorMessage: "fail" } }, MODEL, MSG_ID, makeTracker());
127
+ expect(chunks).toHaveLength(2);
128
+ const { data } = parseSSE(chunks[0]);
129
+ expect(data.choices[0].finish_reason).toBe("stop");
130
+ expect(chunks[1].trim()).toBe("data: [DONE]");
131
+ });
132
+ });
133
+
134
+ describe("eventToNonStreamingResponse", () => {
135
+ it("text-only response", () => {
136
+ const msg = {
137
+ content: [{ type: "text", text: "hello" }],
138
+ stopReason: "stop",
139
+ usage: { input: 5, output: 3 },
140
+ };
141
+ const response = eventToNonStreamingResponse(msg, MODEL, MSG_ID);
142
+ expect(response.object).toBe("chat.completion");
143
+ expect(response.choices[0].message.content).toBe("hello");
144
+ expect(response.choices[0].finish_reason).toBe("stop");
145
+ expect(response.usage.prompt_tokens).toBe(5);
146
+ expect(response.usage.completion_tokens).toBe(3);
147
+ });
148
+
149
+ it("tool call response", () => {
150
+ const msg = {
151
+ content: [{ type: "toolCall", id: "tc1", name: "fn", arguments: { x: 1 } }],
152
+ stopReason: "toolUse",
153
+ usage: { input: 5, output: 3 },
154
+ };
155
+ const response = eventToNonStreamingResponse(msg, MODEL, MSG_ID);
156
+ expect(response.choices[0].finish_reason).toBe("tool_calls");
157
+ expect(response.choices[0].message.tool_calls[0].function.name).toBe("fn");
158
+ expect(response.choices[0].message.content).toBeNull();
159
+ });
160
+
161
+ it("length stop reason", () => {
162
+ const msg = { content: [], stopReason: "length", usage: { input: 1, output: 1 } };
163
+ const response = eventToNonStreamingResponse(msg, MODEL, MSG_ID);
164
+ expect(response.choices[0].finish_reason).toBe("length");
165
+ });
166
+ });
@@ -0,0 +1,129 @@
1
+ /* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
2
+ * See model-proxy/convert/UPSTREAM.md for divergences.
3
+ */
4
+ import type { AnthropicMessagesRequest, AnthropicMessage, AnthropicContentBlock, AnthropicTool } from "./types.js";
5
+
6
+ /**
7
+ * Convert Anthropic Messages request into pi-ai Context.
8
+ */
9
+ export function convertAnthropicMessages(request: AnthropicMessagesRequest): { systemPrompt?: string; messages: any[] } {
10
+ const systemPrompt = extractSystemPrompt(request.system);
11
+ const messages: any[] = [];
12
+
13
+ for (const msg of request.messages) {
14
+ if (msg.role === "user") {
15
+ messages.push(...convertUserMessage(msg));
16
+ } else if (msg.role === "assistant") {
17
+ messages.push(convertAssistantMessage(msg));
18
+ }
19
+ }
20
+
21
+ return { systemPrompt: systemPrompt || undefined, messages };
22
+ }
23
+
24
+ export function convertAnthropicTools(tools: AnthropicTool[]): any[] {
25
+ return tools.map((t) => ({
26
+ name: t.name,
27
+ description: t.description || "",
28
+ parameters: t.input_schema || { type: "object", properties: {} },
29
+ }));
30
+ }
31
+
32
+ function extractSystemPrompt(system: string | AnthropicContentBlock[] | undefined): string | undefined {
33
+ if (!system) return undefined;
34
+ if (typeof system === "string") return system;
35
+ return (system as any[])
36
+ .filter((b) => b.type === "text")
37
+ .map((b) => b.text)
38
+ .join("\n") || undefined;
39
+ }
40
+
41
+ function convertUserMessage(msg: AnthropicMessage): any[] {
42
+ if (typeof msg.content === "string") {
43
+ return [{ role: "user", content: msg.content, timestamp: Date.now() }];
44
+ }
45
+
46
+ const results: any[] = [];
47
+ const userParts: any[] = [];
48
+
49
+ for (const block of msg.content as AnthropicContentBlock[]) {
50
+ if (block.type === "text") {
51
+ userParts.push({ type: "text", text: (block as any).text });
52
+ } else if (block.type === "image") {
53
+ userParts.push({
54
+ type: "image",
55
+ mimeType: (block as any).source.media_type,
56
+ data: (block as any).source.data,
57
+ });
58
+ } else if (block.type === "tool_result") {
59
+ if (userParts.length > 0) {
60
+ results.push({
61
+ role: "user",
62
+ content: userParts.length === 1 && userParts[0].type === "text" ? userParts[0].text : [...userParts],
63
+ timestamp: Date.now(),
64
+ });
65
+ userParts.length = 0;
66
+ }
67
+ const toolBlock = block as any;
68
+ const toolContent = typeof toolBlock.content === "string"
69
+ ? toolBlock.content
70
+ : Array.isArray(toolBlock.content)
71
+ ? toolBlock.content.map((b: any) => b.text).join("")
72
+ : "";
73
+ results.push({
74
+ role: "toolResult",
75
+ toolCallId: toolBlock.tool_use_id,
76
+ toolName: "",
77
+ content: [{ type: "text", text: toolContent }],
78
+ isError: toolBlock.is_error || false,
79
+ timestamp: Date.now(),
80
+ });
81
+ }
82
+ }
83
+
84
+ if (userParts.length > 0) {
85
+ results.push({
86
+ role: "user",
87
+ content: userParts.length === 1 && userParts[0].type === "text" ? userParts[0].text : [...userParts],
88
+ timestamp: Date.now(),
89
+ });
90
+ }
91
+
92
+ if (results.length === 0) {
93
+ results.push({ role: "user", content: "", timestamp: Date.now() });
94
+ }
95
+
96
+ return results;
97
+ }
98
+
99
+ function convertAssistantMessage(msg: AnthropicMessage): any {
100
+ const content: any[] = [];
101
+ if (typeof msg.content === "string") {
102
+ if (msg.content) content.push({ type: "text", text: msg.content });
103
+ } else {
104
+ for (const block of msg.content as AnthropicContentBlock[]) {
105
+ if (block.type === "text") {
106
+ content.push({ type: "text", text: (block as any).text });
107
+ } else if (block.type === "tool_use") {
108
+ const tb = block as any;
109
+ content.push({
110
+ type: "toolCall",
111
+ id: tb.id,
112
+ name: tb.name,
113
+ arguments: tb.input,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ return {
120
+ role: "assistant",
121
+ content,
122
+ api: "anthropic",
123
+ provider: "proxy",
124
+ model: "proxy",
125
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
126
+ stopReason: "stop",
127
+ timestamp: Date.now(),
128
+ };
129
+ }
@@ -0,0 +1,173 @@
1
+ /* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
2
+ * See model-proxy/convert/UPSTREAM.md for divergences.
3
+ */
4
+
5
+ /**
6
+ * Track content block indices for Anthropic SSE events.
7
+ */
8
+ export class AnthropicBlockTracker {
9
+ private currentIndex = -1;
10
+
11
+ nextIndex(): number {
12
+ return ++this.currentIndex;
13
+ }
14
+
15
+ getCurrentIndex(): number {
16
+ return this.currentIndex;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Convert a pi-ai event to Anthropic SSE event strings.
22
+ */
23
+ export function eventToAnthropicSSE(
24
+ event: any, // pi-ai AssistantMessageEvent
25
+ model: string,
26
+ msgId: string,
27
+ tracker: AnthropicBlockTracker,
28
+ ): string[] {
29
+ const chunks: string[] = [];
30
+
31
+ const makeSSE = (eventType: string, data: any) =>
32
+ `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
33
+
34
+ switch (event.type) {
35
+ case "start":
36
+ chunks.push(makeSSE("message_start", {
37
+ type: "message_start",
38
+ message: {
39
+ id: msgId,
40
+ type: "message",
41
+ role: "assistant",
42
+ content: [],
43
+ model,
44
+ stop_reason: null,
45
+ stop_sequence: null,
46
+ usage: { input_tokens: 0, output_tokens: 0 },
47
+ },
48
+ }));
49
+ break;
50
+
51
+ case "text_delta": {
52
+ const idx = tracker.getCurrentIndex();
53
+ if (idx < 0) {
54
+ const newIdx = tracker.nextIndex();
55
+ chunks.push(makeSSE("content_block_start", {
56
+ type: "content_block_start",
57
+ index: newIdx,
58
+ content_block: { type: "text", text: "" },
59
+ }));
60
+ }
61
+ chunks.push(makeSSE("content_block_delta", {
62
+ type: "content_block_delta",
63
+ index: Math.max(0, tracker.getCurrentIndex()),
64
+ delta: { type: "text_delta", text: event.delta },
65
+ }));
66
+ break;
67
+ }
68
+
69
+ case "thinking_delta": {
70
+ const idx = tracker.getCurrentIndex();
71
+ if (idx < 0) {
72
+ const newIdx = tracker.nextIndex();
73
+ chunks.push(makeSSE("content_block_start", {
74
+ type: "content_block_start",
75
+ index: newIdx,
76
+ content_block: { type: "thinking", thinking: "" },
77
+ }));
78
+ }
79
+ chunks.push(makeSSE("content_block_delta", {
80
+ type: "content_block_delta",
81
+ index: Math.max(0, tracker.getCurrentIndex()),
82
+ delta: { type: "thinking_delta", thinking: event.delta },
83
+ }));
84
+ break;
85
+ }
86
+
87
+ case "toolcall_start": {
88
+ const tc = event.partial.content[event.contentIndex];
89
+ if (tracker.getCurrentIndex() >= 0) {
90
+ chunks.push(makeSSE("content_block_stop", {
91
+ type: "content_block_stop",
92
+ index: tracker.getCurrentIndex(),
93
+ }));
94
+ }
95
+ const newIdx = tracker.nextIndex();
96
+ chunks.push(makeSSE("content_block_start", {
97
+ type: "content_block_start",
98
+ index: newIdx,
99
+ content_block: { type: "tool_use", id: tc.id, name: tc.name, input: {} },
100
+ }));
101
+ break;
102
+ }
103
+
104
+ case "toolcall_delta":
105
+ chunks.push(makeSSE("content_block_delta", {
106
+ type: "content_block_delta",
107
+ index: tracker.getCurrentIndex(),
108
+ delta: { type: "input_json_delta", partial_json: event.delta },
109
+ }));
110
+ break;
111
+
112
+ case "done": {
113
+ const msg = event.message;
114
+ if (tracker.getCurrentIndex() >= 0) {
115
+ chunks.push(makeSSE("content_block_stop", {
116
+ type: "content_block_stop",
117
+ index: tracker.getCurrentIndex(),
118
+ }));
119
+ }
120
+ const stopReason = msg.stopReason === "toolUse" ? "tool_use"
121
+ : msg.stopReason === "length" ? "max_tokens"
122
+ : "end_turn";
123
+ chunks.push(makeSSE("message_delta", {
124
+ type: "message_delta",
125
+ delta: { stop_reason: stopReason, stop_sequence: null },
126
+ usage: { output_tokens: msg.usage.output },
127
+ }));
128
+ chunks.push(makeSSE("message_stop", { type: "message_stop" }));
129
+ break;
130
+ }
131
+
132
+ case "error":
133
+ chunks.push(makeSSE("error", {
134
+ type: "error",
135
+ error: { type: "api_error", message: event.error?.errorMessage || "Provider error" },
136
+ }));
137
+ break;
138
+ }
139
+
140
+ return chunks;
141
+ }
142
+
143
+ /**
144
+ * Convert a completed pi-ai AssistantMessage to a non-streaming Anthropic response.
145
+ */
146
+ export function eventToAnthropicResponse(finalMsg: any, model: string, msgId: string): any {
147
+ const content: any[] = [];
148
+ for (const item of finalMsg.content) {
149
+ if (item.type === "text") {
150
+ content.push({ type: "text", text: item.text });
151
+ } else if (item.type === "toolCall") {
152
+ content.push({ type: "tool_use", id: item.id, name: item.name, input: item.arguments });
153
+ }
154
+ }
155
+
156
+ const stopReason = finalMsg.stopReason === "toolUse" ? "tool_use"
157
+ : finalMsg.stopReason === "length" ? "max_tokens"
158
+ : "end_turn";
159
+
160
+ return {
161
+ id: msgId,
162
+ type: "message",
163
+ role: "assistant",
164
+ content,
165
+ model,
166
+ stop_reason: stopReason,
167
+ stop_sequence: null,
168
+ usage: {
169
+ input_tokens: finalMsg.usage.input,
170
+ output_tokens: finalMsg.usage.output,
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,8 @@
1
+ /* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
2
+ * See model-proxy/convert/UPSTREAM.md for divergences.
3
+ */
4
+ export { convertOpenAIMessages, convertOpenAITools } from "./openai-in.js";
5
+ export { eventToSSEChunks, eventToNonStreamingResponse, ToolCallIndexTracker } from "./openai-out.js";
6
+ export { convertAnthropicMessages, convertAnthropicTools } from "./anthropic-in.js";
7
+ export { eventToAnthropicSSE, eventToAnthropicResponse, AnthropicBlockTracker } from "./anthropic-out.js";
8
+ export type { OpenAIMessage, OpenAITool, AnthropicMessagesRequest, AnthropicTool } from "./types.js";
@@ -0,0 +1,119 @@
1
+ /* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
2
+ * See model-proxy/convert/UPSTREAM.md for divergences.
3
+ */
4
+ import type { OpenAIMessage, OpenAIContentPart, OpenAITool } from "./types.js";
5
+
6
+ /**
7
+ * Convert an array of OpenAI messages into a pi-ai Context
8
+ * (systemPrompt + messages array).
9
+ *
10
+ * Returns generic objects compatible with pi-ai's Message types.
11
+ */
12
+ export function convertOpenAIMessages(openaiMessages: OpenAIMessage[]): { systemPrompt?: string; messages: any[] } {
13
+ let systemPrompt: string | undefined;
14
+ const messages: any[] = [];
15
+
16
+ for (const msg of openaiMessages) {
17
+ if (msg.role === "system") {
18
+ const text = extractText(msg.content);
19
+ systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
20
+ continue;
21
+ }
22
+ if (msg.role === "user") {
23
+ messages.push(convertUserMessage(msg));
24
+ continue;
25
+ }
26
+ if (msg.role === "assistant") {
27
+ messages.push(convertAssistantMessage(msg));
28
+ continue;
29
+ }
30
+ if (msg.role === "tool") {
31
+ messages.push(convertToolResult(msg));
32
+ continue;
33
+ }
34
+ }
35
+
36
+ return { systemPrompt, messages };
37
+ }
38
+
39
+ export function convertOpenAITools(tools: OpenAITool[]): any[] {
40
+ return tools.map((t) => ({
41
+ name: t.function.name,
42
+ description: t.function.description || "",
43
+ parameters: t.function.parameters || { type: "object", properties: {} },
44
+ }));
45
+ }
46
+
47
+ function convertUserMessage(msg: OpenAIMessage): any {
48
+ return {
49
+ role: "user",
50
+ content: convertUserContent(msg.content),
51
+ timestamp: Date.now(),
52
+ };
53
+ }
54
+
55
+ function convertAssistantMessage(msg: OpenAIMessage): any {
56
+ const content: any[] = [];
57
+ if (msg.content) {
58
+ const text = extractText(msg.content);
59
+ if (text) content.push({ type: "text", text });
60
+ }
61
+ if (msg.tool_calls) {
62
+ for (const tc of msg.tool_calls) {
63
+ content.push({
64
+ type: "toolCall",
65
+ id: tc.id,
66
+ name: tc.function.name,
67
+ arguments: tryParseJSON(tc.function.arguments),
68
+ });
69
+ }
70
+ }
71
+ return {
72
+ role: "assistant",
73
+ content,
74
+ api: "openai-completions",
75
+ provider: "proxy",
76
+ model: "proxy",
77
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
78
+ stopReason: "stop",
79
+ timestamp: Date.now(),
80
+ };
81
+ }
82
+
83
+ function convertToolResult(msg: OpenAIMessage): any {
84
+ return {
85
+ role: "toolResult",
86
+ toolCallId: msg.tool_call_id || "",
87
+ toolName: msg.name || "",
88
+ content: [{ type: "text", text: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content) }],
89
+ isError: false,
90
+ timestamp: Date.now(),
91
+ };
92
+ }
93
+
94
+ function convertUserContent(content: string | OpenAIContentPart[] | null): string | any[] {
95
+ if (!content) return "";
96
+ if (typeof content === "string") return content;
97
+ const parts: any[] = [];
98
+ for (const part of content) {
99
+ if (part.type === "text" && part.text) {
100
+ parts.push({ type: "text", text: part.text });
101
+ } else if (part.type === "image_url" && part.image_url?.url) {
102
+ const match = part.image_url.url.match(/^data:([^;]+);base64,(.+)$/);
103
+ if (match) {
104
+ parts.push({ type: "image", mimeType: match[1], data: match[2] });
105
+ }
106
+ }
107
+ }
108
+ return parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
109
+ }
110
+
111
+ function extractText(content: string | OpenAIContentPart[] | null): string {
112
+ if (!content) return "";
113
+ if (typeof content === "string") return content;
114
+ return content.filter((p) => p.type === "text").map((p) => p.text!).join("");
115
+ }
116
+
117
+ function tryParseJSON(s: string): Record<string, any> {
118
+ try { return JSON.parse(s); } catch { return {}; }
119
+ }