@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,330 @@
1
+ /**
2
+ * Model proxy route handlers: /v1/models, /v1/chat/completions, /v1/messages.
3
+ *
4
+ * OpenAI- and Anthropic-compatible endpoints fronting the dashboard's
5
+ * model registry via pi-ai's streamSimple.
6
+ *
7
+ * See change: add-dashboard-model-proxy.
8
+ */
9
+ import crypto from "node:crypto";
10
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
11
+ import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
12
+ import {
13
+ convertOpenAIMessages,
14
+ convertOpenAITools,
15
+ eventToSSEChunks,
16
+ eventToNonStreamingResponse,
17
+ ToolCallIndexTracker,
18
+ convertAnthropicMessages,
19
+ convertAnthropicTools,
20
+ eventToAnthropicSSE,
21
+ eventToAnthropicResponse,
22
+ AnthropicBlockTracker,
23
+ } from "../model-proxy/convert/index.js";
24
+ import { ConcurrencyTracker, ConcurrencyError } from "../model-proxy/concurrency.js";
25
+ import { logRequest, type RequestLogEntry } from "../model-proxy/request-log.js";
26
+
27
+ export interface ModelProxyRouteDeps {
28
+ getConfig: () => ModelProxyConfig;
29
+ /** Resolve the model registry. Returns null when pi-ai is unavailable. */
30
+ getRegistry: () => Promise<ModelProxyRegistry | null>;
31
+ }
32
+
33
+ /** Minimal interface for the model registry consumed by route handlers. */
34
+ export interface ModelProxyRegistry {
35
+ getAvailable(): Promise<any[]>;
36
+ find(provider: string, modelId: string): Promise<any | null>;
37
+ getApiKeyAndHeaders(model: any): Promise<{ apiKey: string; headers: Record<string, string> }>;
38
+ }
39
+
40
+ /** Minimal interface for pi-ai's streamSimple. */
41
+ export type StreamSimpleFn = (opts: any) => AsyncIterable<any>;
42
+
43
+ const concurrency = new ConcurrencyTracker();
44
+
45
+ export function registerModelProxyRoutes(
46
+ fastify: FastifyInstance,
47
+ deps: ModelProxyRouteDeps & { streamSimple?: StreamSimpleFn },
48
+ ): void {
49
+ const { getConfig, getRegistry } = deps;
50
+
51
+ // ── GET /v1/models ──────────────────────────────────────────────────
52
+ fastify.get("/v1/models", async (request, reply) => {
53
+ const registry = await getRegistry();
54
+ if (!registry) {
55
+ return reply.code(503).send({
56
+ code: "MODEL_PROXY_RUNTIME_MISSING",
57
+ message: "pi-ai is not installed or cannot be resolved",
58
+ });
59
+ }
60
+
61
+ const models = await registry.getAvailable();
62
+ const data = models.map((m: any) => ({
63
+ id: `${m.provider}/${m.id}`,
64
+ object: "model" as const,
65
+ created: Math.floor(Date.now() / 1000),
66
+ owned_by: m.provider,
67
+ "x-pi": {
68
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
69
+ ...(m.maxTokens ? { maxTokens: m.maxTokens } : {}),
70
+ ...(m.reasoning != null ? { reasoning: m.reasoning } : {}),
71
+ ...(m.cost ? { cost: m.cost } : {}),
72
+ ...(m.input ? { input: m.input } : {}),
73
+ },
74
+ }));
75
+
76
+ return { object: "list", data };
77
+ });
78
+
79
+ // ── POST /v1/chat/completions ───────────────────────────────────────
80
+ fastify.post("/v1/chat/completions", {
81
+ config: { compress: false },
82
+ }, async (request, reply) => {
83
+ const body = request.body as any;
84
+ if (!body?.messages) {
85
+ return reply.code(400).send({ error: { message: "messages is required", type: "invalid_request_error" } });
86
+ }
87
+
88
+ const config = getConfig();
89
+ const modelId = body.model || config.defaultModel;
90
+ if (!modelId) {
91
+ return reply.code(400).send({ error: { message: "model is required", type: "invalid_request_error" } });
92
+ }
93
+
94
+ const registry = await getRegistry();
95
+ if (!registry) {
96
+ return reply.code(503).send({ code: "MODEL_PROXY_RUNTIME_MISSING", message: "pi-ai unavailable" });
97
+ }
98
+
99
+ const stream = body.stream === true;
100
+ const apiKeyId = (request as any).proxyApiKeyId;
101
+ const [provider] = modelId.includes("/") ? modelId.split("/", 2) : ["unknown", modelId];
102
+
103
+ // Acquire concurrency
104
+ let release: (() => void) | undefined;
105
+ try {
106
+ release = concurrency.acquire({ apiKeyId, provider }, config);
107
+ } catch (e) {
108
+ if (e instanceof ConcurrencyError) {
109
+ const status = e.code === "SERVER_FULL" ? 503 : 429;
110
+ reply.header("Retry-After", String(Math.ceil(e.retryAfterMs / 1000)));
111
+ return reply.code(status).send({ code: e.code });
112
+ }
113
+ throw e;
114
+ }
115
+
116
+ const startTime = Date.now();
117
+ const requestId = crypto.randomUUID();
118
+ const msgId = crypto.randomUUID().slice(0, 8);
119
+
120
+ try {
121
+ const { systemPrompt, messages } = convertOpenAIMessages(body.messages);
122
+ const tools = body.tools ? convertOpenAITools(body.tools) : undefined;
123
+
124
+ // Resolve model
125
+ const [prov, mid] = modelId.includes("/") ? modelId.split("/", 2) : [undefined, modelId];
126
+ const model = prov ? await registry.find(prov, mid) : null;
127
+ if (!model) {
128
+ return reply.code(404).send({ error: { message: `Model not found: ${modelId}`, type: "invalid_request_error" } });
129
+ }
130
+
131
+ const creds = await registry.getApiKeyAndHeaders(model);
132
+ const controller = new AbortController();
133
+
134
+ // Abort on client disconnect
135
+ request.raw.on("close", () => controller.abort());
136
+
137
+ const streamSimple = deps.streamSimple;
138
+ if (!streamSimple) {
139
+ return reply.code(503).send({ code: "MODEL_PROXY_RUNTIME_MISSING", message: "streamSimple unavailable" });
140
+ }
141
+
142
+ const streamOpts: any = {
143
+ model,
144
+ messages,
145
+ ...(systemPrompt ? { system: systemPrompt } : {}),
146
+ ...(tools ? { tools } : {}),
147
+ ...(body.max_tokens != null ? { maxTokens: body.max_tokens } : {}),
148
+ ...(body.temperature != null ? { temperature: body.temperature } : {}),
149
+ signal: controller.signal,
150
+ apiKey: creds.apiKey,
151
+ headers: creds.headers,
152
+ };
153
+
154
+ const eventStream = streamSimple(streamOpts);
155
+
156
+ if (stream) {
157
+ // Streaming SSE response
158
+ if (typeof request.raw.setTimeout === "function") request.raw.setTimeout(0);
159
+ reply.raw.writeHead(200, {
160
+ "Content-Type": "text/event-stream",
161
+ "Cache-Control": "no-cache",
162
+ "Connection": "keep-alive",
163
+ });
164
+
165
+ const tracker = new ToolCallIndexTracker();
166
+ let lastMsg: any;
167
+
168
+ for await (const event of eventStream) {
169
+ if (event.type === "done") lastMsg = event.message;
170
+ const sseChunks = eventToSSEChunks(event, modelId, msgId, tracker);
171
+ for (const chunk of sseChunks) {
172
+ reply.raw.write(chunk);
173
+ }
174
+ }
175
+
176
+ reply.raw.end();
177
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "openai", status: 200, durationMs: Date.now() - startTime, inputTokens: lastMsg?.usage?.input, outputTokens: lastMsg?.usage?.output });
178
+ } else {
179
+ // Non-streaming response
180
+ let finalMsg: any;
181
+ for await (const event of eventStream) {
182
+ if (event.type === "done") finalMsg = event.message;
183
+ if (event.type === "error") {
184
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "openai", status: 500, durationMs: Date.now() - startTime, error: event.error?.errorMessage });
185
+ return reply.code(500).send({ error: { message: event.error?.errorMessage || "Provider error", type: "api_error" } });
186
+ }
187
+ }
188
+
189
+ if (!finalMsg) {
190
+ return reply.code(500).send({ error: { message: "No response from model", type: "api_error" } });
191
+ }
192
+
193
+ const response = eventToNonStreamingResponse(finalMsg, modelId, msgId);
194
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "openai", status: 200, durationMs: Date.now() - startTime, inputTokens: finalMsg.usage?.input, outputTokens: finalMsg.usage?.output });
195
+ return response;
196
+ }
197
+ } catch (err: any) {
198
+ if (err.name === "AbortError") return; // Client disconnected
199
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "openai", status: 500, durationMs: Date.now() - startTime, error: err.message });
200
+ return reply.code(500).send({ error: { message: err.message || "Internal error", type: "api_error" } });
201
+ } finally {
202
+ release?.();
203
+ }
204
+ });
205
+
206
+ // ── POST /v1/messages ───────────────────────────────────────────────
207
+ fastify.post("/v1/messages", {
208
+ config: { compress: false },
209
+ }, async (request, reply) => {
210
+ const body = request.body as any;
211
+ if (!body?.messages || !body?.max_tokens) {
212
+ return reply.code(400).send({ error: { type: "invalid_request_error", message: "messages and max_tokens are required" } });
213
+ }
214
+
215
+ const config = getConfig();
216
+ const modelId = body.model || config.defaultModel;
217
+ if (!modelId) {
218
+ return reply.code(400).send({ error: { type: "invalid_request_error", message: "model is required" } });
219
+ }
220
+
221
+ const registry = await getRegistry();
222
+ if (!registry) {
223
+ return reply.code(503).send({ code: "MODEL_PROXY_RUNTIME_MISSING", message: "pi-ai unavailable" });
224
+ }
225
+
226
+ const stream = body.stream === true;
227
+ const apiKeyId = (request as any).proxyApiKeyId;
228
+ const [provider] = modelId.includes("/") ? modelId.split("/", 2) : ["unknown", modelId];
229
+
230
+ let release: (() => void) | undefined;
231
+ try {
232
+ release = concurrency.acquire({ apiKeyId, provider }, config);
233
+ } catch (e) {
234
+ if (e instanceof ConcurrencyError) {
235
+ const status = e.code === "SERVER_FULL" ? 503 : 429;
236
+ reply.header("Retry-After", String(Math.ceil(e.retryAfterMs / 1000)));
237
+ return reply.code(status).send({ code: e.code });
238
+ }
239
+ throw e;
240
+ }
241
+
242
+ const startTime = Date.now();
243
+ const requestId = crypto.randomUUID();
244
+ const msgId = `msg_${crypto.randomUUID().slice(0, 12)}`;
245
+
246
+ try {
247
+ const { systemPrompt, messages } = convertAnthropicMessages(body);
248
+ const tools = body.tools ? convertAnthropicTools(body.tools) : undefined;
249
+
250
+ const [prov, mid] = modelId.includes("/") ? modelId.split("/", 2) : [undefined, modelId];
251
+ const model = prov ? await registry.find(prov, mid) : null;
252
+ if (!model) {
253
+ return reply.code(404).send({ error: { type: "invalid_request_error", message: `Model not found: ${modelId}` } });
254
+ }
255
+
256
+ const creds = await registry.getApiKeyAndHeaders(model);
257
+ const controller = new AbortController();
258
+ request.raw.on("close", () => controller.abort());
259
+
260
+ const streamSimple = deps.streamSimple;
261
+ if (!streamSimple) {
262
+ return reply.code(503).send({ code: "MODEL_PROXY_RUNTIME_MISSING", message: "streamSimple unavailable" });
263
+ }
264
+
265
+ const streamOpts: any = {
266
+ model,
267
+ messages,
268
+ ...(systemPrompt ? { system: systemPrompt } : {}),
269
+ ...(tools ? { tools } : {}),
270
+ maxTokens: body.max_tokens,
271
+ ...(body.temperature != null ? { temperature: body.temperature } : {}),
272
+ signal: controller.signal,
273
+ apiKey: creds.apiKey,
274
+ headers: creds.headers,
275
+ };
276
+
277
+ const eventStream = streamSimple(streamOpts);
278
+
279
+ if (stream) {
280
+ if (typeof request.raw.setTimeout === "function") request.raw.setTimeout(0);
281
+ reply.raw.writeHead(200, {
282
+ "Content-Type": "text/event-stream",
283
+ "Cache-Control": "no-cache",
284
+ "Connection": "keep-alive",
285
+ });
286
+
287
+ const tracker = new AnthropicBlockTracker();
288
+ let lastMsg: any;
289
+
290
+ for await (const event of eventStream) {
291
+ if (event.type === "done") lastMsg = event.message;
292
+ const sseChunks = eventToAnthropicSSE(event, modelId, msgId, tracker);
293
+ for (const chunk of sseChunks) {
294
+ reply.raw.write(chunk);
295
+ }
296
+ }
297
+
298
+ reply.raw.end();
299
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "anthropic", status: 200, durationMs: Date.now() - startTime, inputTokens: lastMsg?.usage?.input, outputTokens: lastMsg?.usage?.output });
300
+ } else {
301
+ let finalMsg: any;
302
+ for await (const event of eventStream) {
303
+ if (event.type === "done") finalMsg = event.message;
304
+ if (event.type === "error") {
305
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "anthropic", status: 500, durationMs: Date.now() - startTime, error: event.error?.errorMessage });
306
+ return reply.code(500).send({ error: { type: "api_error", message: event.error?.errorMessage || "Provider error" } });
307
+ }
308
+ }
309
+
310
+ if (!finalMsg) {
311
+ return reply.code(500).send({ error: { type: "api_error", message: "No response from model" } });
312
+ }
313
+
314
+ const response = eventToAnthropicResponse(finalMsg, modelId, msgId);
315
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "anthropic", status: 200, durationMs: Date.now() - startTime, inputTokens: finalMsg.usage?.input, outputTokens: finalMsg.usage?.output });
316
+ return response;
317
+ }
318
+ } catch (err: any) {
319
+ if (err.name === "AbortError") return;
320
+ maybeLog(config, { ts: new Date().toISOString(), requestId, apiKeyId, model: modelId, format: "anthropic", status: 500, durationMs: Date.now() - startTime, error: err.message });
321
+ return reply.code(500).send({ error: { type: "api_error", message: err.message || "Internal error" } });
322
+ } finally {
323
+ release?.();
324
+ }
325
+ });
326
+ }
327
+
328
+ function maybeLog(config: ModelProxyConfig, entry: RequestLogEntry): void {
329
+ if (config.logRequests) logRequest(entry);
330
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * OpenSpec change-grouping REST routes.
3
+ *
4
+ * Five endpoints under `/api/openspec/groups`. All accept a `cwd` query
5
+ * parameter and validate it against the dashboard's known-cwd set
6
+ * (sessions ∪ pinned directories) — same pattern as `pi-resource-file`
7
+ * in `openspec-routes.ts`.
8
+ *
9
+ * See change: add-openspec-change-grouping (tasks 3.1–3.13).
10
+ */
11
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
12
+ import type { SessionManager } from "../memory-session-manager.js";
13
+ import type { PreferencesStore } from "../preferences-store.js";
14
+ import type { NetworkGuard } from "./route-deps.js";
15
+ import type {
16
+ ApiResponse,
17
+ OpenSpecGroupsFile,
18
+ } from "@blackbelt-technology/pi-dashboard-shared/types.js";
19
+ import {
20
+ ConcurrentEditError,
21
+ GroupNotFoundError,
22
+ UnknownGroupIdError,
23
+ UnsupportedSchemaVersionError,
24
+ type OpenSpecGroupStore,
25
+ } from "../openspec-group-store.js";
26
+
27
+ export interface OpenSpecGroupRoutesDeps {
28
+ sessionManager: SessionManager;
29
+ preferencesStore: PreferencesStore;
30
+ networkGuard: NetworkGuard;
31
+ store: OpenSpecGroupStore;
32
+ }
33
+
34
+ export function registerOpenSpecGroupRoutes(
35
+ fastify: FastifyInstance,
36
+ deps: OpenSpecGroupRoutesDeps,
37
+ ): void {
38
+ const { sessionManager, preferencesStore, networkGuard, store } = deps;
39
+
40
+ /**
41
+ * Validate `cwd` is non-empty AND in the dashboard's known-cwd set
42
+ * (active or hidden sessions ∪ pinned directories). Returns true when the
43
+ * reply has been short-circuited; false when the route should proceed.
44
+ */
45
+ function rejectInvalidCwd(reply: FastifyReply, cwd: string | undefined): cwd is undefined {
46
+ if (!cwd) {
47
+ reply.code(400);
48
+ reply.send({ success: false, error: "Missing cwd" } satisfies ApiResponse);
49
+ return true;
50
+ }
51
+ const known = new Set<string>();
52
+ for (const s of sessionManager.listAll()) known.add(s.cwd);
53
+ for (const d of preferencesStore.getPinnedDirectories()) known.add(d);
54
+ if (!known.has(cwd)) {
55
+ reply.code(403);
56
+ reply.send({ success: false, error: "cwd not allowed" } satisfies ApiResponse);
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /** Map known store errors to HTTP status codes. */
63
+ function handleError(reply: FastifyReply, err: unknown): ApiResponse {
64
+ if (err instanceof ConcurrentEditError) {
65
+ reply.code(409);
66
+ return {
67
+ success: false,
68
+ error: "Concurrent edit detected",
69
+ data: err.current,
70
+ } satisfies ApiResponse<OpenSpecGroupsFile>;
71
+ }
72
+ if (err instanceof UnsupportedSchemaVersionError) {
73
+ reply.code(422);
74
+ return { success: false, error: err.message } satisfies ApiResponse;
75
+ }
76
+ if (err instanceof GroupNotFoundError) {
77
+ reply.code(404);
78
+ return { success: false, error: "Group not found" } satisfies ApiResponse;
79
+ }
80
+ if (err instanceof UnknownGroupIdError) {
81
+ reply.code(422);
82
+ return { success: false, error: "Unknown groupId" } satisfies ApiResponse;
83
+ }
84
+ reply.code(500);
85
+ const msg = err instanceof Error ? err.message : "internal error";
86
+ return { success: false, error: msg } satisfies ApiResponse;
87
+ }
88
+
89
+ // ── GET /api/openspec/groups ─────────────────────────────────
90
+
91
+ fastify.get<{ Querystring: { cwd?: string } }>(
92
+ "/api/openspec/groups",
93
+ { preHandler: networkGuard },
94
+ async (request, reply) => {
95
+ const { cwd } = request.query;
96
+ if (rejectInvalidCwd(reply, cwd)) return;
97
+ try {
98
+ const data = await store.read(cwd!);
99
+ return { success: true, data } satisfies ApiResponse;
100
+ } catch (err) {
101
+ return handleError(reply, err);
102
+ }
103
+ },
104
+ );
105
+
106
+ // ── POST /api/openspec/groups ────────────────────────────────
107
+
108
+ fastify.post<{
109
+ Querystring: { cwd?: string };
110
+ Body: { name?: unknown; color?: unknown };
111
+ }>(
112
+ "/api/openspec/groups",
113
+ { preHandler: networkGuard },
114
+ async (request, reply) => {
115
+ const { cwd } = request.query;
116
+ if (rejectInvalidCwd(reply, cwd)) return;
117
+ const body = request.body ?? {};
118
+ const name = typeof body.name === "string" ? body.name.trim() : "";
119
+ if (!name) {
120
+ reply.code(400);
121
+ return { success: false, error: "name is required" } satisfies ApiResponse;
122
+ }
123
+ const color = typeof body.color === "string" ? body.color : undefined;
124
+ try {
125
+ const created = await store.createGroup(cwd!, { name, ...(color !== undefined ? { color } : {}) });
126
+ reply.code(201);
127
+ return { success: true, data: created } satisfies ApiResponse;
128
+ } catch (err) {
129
+ return handleError(reply, err);
130
+ }
131
+ },
132
+ );
133
+
134
+ // ── PATCH /api/openspec/groups/:id ───────────────────────────
135
+
136
+ fastify.patch<{
137
+ Params: { id: string };
138
+ Querystring: { cwd?: string };
139
+ Body: { name?: unknown; color?: unknown; order?: unknown };
140
+ }>(
141
+ "/api/openspec/groups/:id",
142
+ { preHandler: networkGuard },
143
+ async (request, reply) => {
144
+ const { cwd } = request.query;
145
+ if (rejectInvalidCwd(reply, cwd)) return;
146
+ const { id } = request.params;
147
+ const body = request.body ?? {};
148
+ const update: { name?: string; color?: string; order?: number } = {};
149
+ if (body.name !== undefined) {
150
+ if (typeof body.name !== "string") {
151
+ reply.code(400);
152
+ return { success: false, error: "name must be a string" } satisfies ApiResponse;
153
+ }
154
+ update.name = body.name;
155
+ }
156
+ if (body.color !== undefined) {
157
+ if (typeof body.color !== "string") {
158
+ reply.code(400);
159
+ return { success: false, error: "color must be a string" } satisfies ApiResponse;
160
+ }
161
+ update.color = body.color;
162
+ }
163
+ if (body.order !== undefined) {
164
+ if (typeof body.order !== "number" || !Number.isFinite(body.order)) {
165
+ reply.code(400);
166
+ return { success: false, error: "order must be a number" } satisfies ApiResponse;
167
+ }
168
+ update.order = body.order;
169
+ }
170
+ try {
171
+ const updated = await store.updateGroup(cwd!, id, update);
172
+ return { success: true, data: updated } satisfies ApiResponse;
173
+ } catch (err) {
174
+ return handleError(reply, err);
175
+ }
176
+ },
177
+ );
178
+
179
+ // ── DELETE /api/openspec/groups/:id ──────────────────────────
180
+
181
+ fastify.delete<{
182
+ Params: { id: string };
183
+ Querystring: { cwd?: string };
184
+ }>(
185
+ "/api/openspec/groups/:id",
186
+ { preHandler: networkGuard },
187
+ async (request, reply) => {
188
+ const { cwd } = request.query;
189
+ if (rejectInvalidCwd(reply, cwd)) return;
190
+ const { id } = request.params;
191
+ try {
192
+ await store.deleteGroup(cwd!, id);
193
+ return { success: true } satisfies ApiResponse;
194
+ } catch (err) {
195
+ return handleError(reply, err);
196
+ }
197
+ },
198
+ );
199
+
200
+ // ── PUT /api/openspec/groups/assignments ─────────────────────
201
+
202
+ fastify.put<{
203
+ Querystring: { cwd?: string };
204
+ Body: { changeName?: unknown; groupId?: unknown };
205
+ }>(
206
+ "/api/openspec/groups/assignments",
207
+ { preHandler: networkGuard },
208
+ async (request, reply) => {
209
+ const { cwd } = request.query;
210
+ if (rejectInvalidCwd(reply, cwd)) return;
211
+ const body = request.body ?? {};
212
+ if (typeof body.changeName !== "string" || body.changeName.length === 0) {
213
+ reply.code(400);
214
+ return { success: false, error: "changeName must be a non-empty string" } satisfies ApiResponse;
215
+ }
216
+ if (body.groupId !== null && typeof body.groupId !== "string") {
217
+ reply.code(400);
218
+ return { success: false, error: "groupId must be a string or null" } satisfies ApiResponse;
219
+ }
220
+ try {
221
+ await store.setAssignment(cwd!, body.changeName, body.groupId as string | null);
222
+ return { success: true } satisfies ApiResponse;
223
+ } catch (err) {
224
+ return handleError(reply, err);
225
+ }
226
+ },
227
+ );
228
+
229
+ // Silence unused-import warning when types are only used at signature level.
230
+ void (undefined as unknown as FastifyRequest);
231
+ }
@@ -22,6 +22,7 @@ import { getLatestCatalogue } from "../provider-catalogue-cache.js";
22
22
  import { startCallbackServer } from "../oauth-callback-server.js";
23
23
  import type { PiGateway } from "../pi-gateway.js";
24
24
  import type { BrowserGateway } from "../browser-gateway.js";
25
+ import { refreshModelRegistry } from "../model-proxy/registry-singleton.js";
25
26
 
26
27
  // ── In-memory flow store (short-lived PKCE + device code state) ──────────────
27
28
 
@@ -94,6 +95,8 @@ export function registerProviderAuthRoutes(
94
95
  // broadcast and update modelsMap / catalogue cache without needing a
95
96
  // global wipe. See change: simplify-model-selection-channels.
96
97
  piGateway.broadcast({ type: "credentials_updated" });
98
+ // Eager-refresh model proxy registry so /v1/models reflects the change.
99
+ refreshModelRegistry().catch(() => {});
97
100
  }
98
101
 
99
102
  // List OAuth providers
@@ -9,6 +9,9 @@ import type { NetworkGuard } from "./route-deps.js";
9
9
  import type { PiGateway } from "../pi-gateway.js";
10
10
  import type { BrowserGateway } from "../browser-gateway.js";
11
11
  import { probeProvider, resolveProbeApiKey, type ProbeApi } from "../provider-probe.js";
12
+ import { refreshModelRegistry } from "../model-proxy/registry-singleton.js";
13
+ import { isSelfPointing, collectDashboardOrigins } from "../model-proxy/recursion-guard.js";
14
+ import { getTunnelUrl } from "../tunnel.js";
12
15
 
13
16
  const REDACTED = "***";
14
17
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
@@ -47,7 +50,7 @@ function redactProviders(
47
50
  return redacted;
48
51
  }
49
52
 
50
- export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard; piGateway?: PiGateway; browserGateway?: BrowserGateway }): void {
53
+ export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard; piGateway?: PiGateway; browserGateway?: BrowserGateway; port?: number }): void {
51
54
  const { networkGuard, piGateway } = deps;
52
55
  fastify.get(
53
56
  "/api/providers",
@@ -68,6 +71,23 @@ export function registerProviderRoutes(fastify: FastifyInstance, deps: { network
68
71
  }
69
72
 
70
73
  const incoming = body.providers as Record<string, ProviderEntry>;
74
+
75
+ // Recursion guard: reject providers pointing back at the dashboard
76
+ const dashboardPort = deps.port ?? 8000;
77
+ const tunnelUrl = getTunnelUrl();
78
+ const tunnelHostname = tunnelUrl ? new URL(tunnelUrl).hostname : undefined;
79
+ const origins = collectDashboardOrigins(dashboardPort, { tunnelHostname });
80
+ for (const [name, entry] of Object.entries(incoming)) {
81
+ if (entry.baseUrl && isSelfPointing(entry.baseUrl, origins)) {
82
+ return reply.code(400).send({
83
+ success: false,
84
+ code: "RECURSIVE_PROXY",
85
+ message: `Provider "${name}" baseUrl points back at this dashboard`,
86
+ offendingBaseUrl: entry.baseUrl,
87
+ });
88
+ }
89
+ }
90
+
71
91
  const existing = readProvidersRaw();
72
92
 
73
93
  // Merge: preserve redacted apiKey values from existing file
@@ -106,6 +126,9 @@ export function registerProviderRoutes(fastify: FastifyInstance, deps: { network
106
126
  piGateway.broadcast({ type: "credentials_updated" });
107
127
  }
108
128
 
129
+ // Eager-refresh model proxy registry so /v1/models reflects the change.
130
+ refreshModelRegistry().catch(() => {});
131
+
109
132
  return { success: true };
110
133
  },
111
134
  );