@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,169 @@
1
+ /**
2
+ * Multi-user key visibility and admin override tests (task 3.10).
3
+ *
4
+ * Tests the per-user isolation and admin override logic in the API key
5
+ * management routes:
6
+ * - Alice cannot see Bob's keys
7
+ * - Admin sees all keys
8
+ * - Admin can revoke any user's key
9
+ *
10
+ * Uses Fastify directly, injecting user context via request.user.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import Fastify from "fastify";
14
+ import { registerModelProxyApiKeyRoutes } from "../routes/model-proxy-api-key-routes.js";
15
+ import { generateKey, hashKey } from "../model-proxy/api-key-store.js";
16
+ import type { ProxyApiKey, ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
17
+
18
+ // ── Helpers ────────────────────────────────────────────────────────────────
19
+
20
+ function makeEntry(id: string, label: string, createdBy: string, overrides: Partial<ProxyApiKey> = {}): ProxyApiKey {
21
+ return {
22
+ id,
23
+ label,
24
+ createdAt: Date.now(),
25
+ hash: hashKey(generateKey()),
26
+ scopes: ["all"],
27
+ createdBy,
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ interface SetupOpts {
33
+ user: { email: string };
34
+ adminEmail?: string;
35
+ apiKeys?: ProxyApiKey[];
36
+ }
37
+
38
+ async function buildApp(opts: SetupOpts) {
39
+ const { user, adminEmail, apiKeys = [] } = opts;
40
+
41
+ let config: ModelProxyConfig = {
42
+ enabled: true,
43
+ maxConcurrentStreams: 16,
44
+ perKeyConcurrentStreams: 4,
45
+ logRequests: false,
46
+ apiKeys: [...apiKeys],
47
+ };
48
+
49
+ const writes: ProxyApiKey[][] = [];
50
+
51
+ const app = Fastify({ logger: false });
52
+
53
+ // Inject user context (simulates JWT-decoded user)
54
+ app.addHook("onRequest", async (req) => {
55
+ (req as any).user = user;
56
+ });
57
+
58
+ // Passthrough networkGuard
59
+ const networkGuard = async (_req: any, _reply: any) => {};
60
+
61
+ registerModelProxyApiKeyRoutes(app, {
62
+ networkGuard,
63
+ getModelProxyConfig: () => config,
64
+ writeModelProxyApiKeys: async (keys) => {
65
+ config = { ...config, apiKeys: keys };
66
+ writes.push(keys);
67
+ },
68
+ getAdminEmail: () => adminEmail,
69
+ });
70
+
71
+ await app.ready();
72
+ return { app, writes, getConfig: () => config };
73
+ }
74
+
75
+ // ── Tests ──────────────────────────────────────────────────────────────────
76
+
77
+ describe("multi-user key visibility (task 3.10)", () => {
78
+ it("alice sees only her own keys", async () => {
79
+ const aliceKey = makeEntry("k1", "Alice key", "alice@example.com");
80
+ const bobKey = makeEntry("k2", "Bob key", "bob@example.com");
81
+
82
+ const { app } = await buildApp({
83
+ user: { email: "alice@example.com" },
84
+ apiKeys: [aliceKey, bobKey],
85
+ });
86
+
87
+ const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
88
+ expect(res.statusCode).toBe(200);
89
+
90
+ const body = JSON.parse(res.body);
91
+ const allVisible = [...(body.data?.keys ?? []), ...(body.data?.revoked ?? [])];
92
+ const ids = allVisible.map((k: any) => k.id);
93
+
94
+ expect(ids).toContain("k1");
95
+ expect(ids).not.toContain("k2");
96
+ });
97
+
98
+ it("bob sees only his own keys", async () => {
99
+ const aliceKey = makeEntry("k1", "Alice key", "alice@example.com");
100
+ const bobKey = makeEntry("k2", "Bob key", "bob@example.com");
101
+
102
+ const { app } = await buildApp({
103
+ user: { email: "bob@example.com" },
104
+ apiKeys: [aliceKey, bobKey],
105
+ });
106
+
107
+ const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
108
+ const body = JSON.parse(res.body);
109
+ const allVisible = [...(body.data?.keys ?? []), ...(body.data?.revoked ?? [])];
110
+ const ids = allVisible.map((k: any) => k.id);
111
+
112
+ expect(ids).toContain("k2");
113
+ expect(ids).not.toContain("k1");
114
+ });
115
+
116
+ it("admin sees all keys", async () => {
117
+ const aliceKey = makeEntry("k1", "Alice key", "alice@example.com");
118
+ const bobKey = makeEntry("k2", "Bob key", "bob@example.com");
119
+
120
+ const { app } = await buildApp({
121
+ user: { email: "admin@example.com" },
122
+ adminEmail: "admin@example.com",
123
+ apiKeys: [aliceKey, bobKey],
124
+ });
125
+
126
+ const res = await app.inject({ method: "GET", url: "/api/model-proxy/api-keys" });
127
+ const body = JSON.parse(res.body);
128
+ const allVisible = [...(body.data?.keys ?? []), ...(body.data?.revoked ?? [])];
129
+ const ids = allVisible.map((k: any) => k.id);
130
+
131
+ expect(ids).toContain("k1");
132
+ expect(ids).toContain("k2");
133
+ });
134
+
135
+ it("alice cannot revoke bob's key (403)", async () => {
136
+ const bobKey = makeEntry("k2", "Bob key", "bob@example.com");
137
+
138
+ const { app } = await buildApp({
139
+ user: { email: "alice@example.com" },
140
+ apiKeys: [bobKey],
141
+ });
142
+
143
+ const res = await app.inject({
144
+ method: "POST",
145
+ url: "/api/model-proxy/api-keys/k2/revoke",
146
+ });
147
+
148
+ expect(res.statusCode).toBe(403);
149
+ });
150
+
151
+ it("admin can revoke any user's key", async () => {
152
+ const bobKey = makeEntry("k2", "Bob key", "bob@example.com");
153
+
154
+ const { app, getConfig } = await buildApp({
155
+ user: { email: "admin@example.com" },
156
+ adminEmail: "admin@example.com",
157
+ apiKeys: [bobKey],
158
+ });
159
+
160
+ const res = await app.inject({
161
+ method: "POST",
162
+ url: "/api/model-proxy/api-keys/k2/revoke",
163
+ });
164
+
165
+ expect(res.statusCode).toBe(204);
166
+ const revokedEntry = getConfig().apiKeys.find((k) => k.id === "k2");
167
+ expect(revokedEntry?.revokedAt).toBeDefined();
168
+ });
169
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Integration tests for model proxy route handlers (task 8.4).
3
+ *
4
+ * Uses Fastify inject + in-memory mock registry — no real pi-ai.
5
+ *
6
+ * Covers:
7
+ * - GET /v1/models returns correct shape
8
+ * - POST /v1/chat/completions streaming round-trip
9
+ * - POST /v1/chat/completions non-streaming round-trip
10
+ * - POST /v1/messages streaming round-trip
11
+ * - auth missing → 401 (auth gate wired)
12
+ * - concurrency cap exhaust → 503
13
+ */
14
+ import { describe, it, expect, beforeEach } from "vitest";
15
+ import Fastify from "fastify";
16
+ import { registerModelProxyRoutes } from "../routes/model-proxy-routes.js";
17
+ import { createModelProxyAuthGate } from "../model-proxy/auth-gate.js";
18
+ import { generateKey, hashKey } from "../model-proxy/api-key-store.js";
19
+ import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
+
21
+ // ── Fake stream ────────────────────────────────────────────────────────────
22
+
23
+ async function* fakeTextStream(text: string): AsyncIterable<any> {
24
+ yield { type: "start" };
25
+ yield { type: "text_delta", delta: text };
26
+ yield {
27
+ type: "done",
28
+ message: {
29
+ content: [{ type: "text", text }],
30
+ stopReason: "stop",
31
+ usage: { input: 5, output: 3 },
32
+ },
33
+ };
34
+ }
35
+
36
+ // ── Fake registry ──────────────────────────────────────────────────────────
37
+
38
+ function makeFakeRegistry() {
39
+ const model = {
40
+ id: "claude-3-5-sonnet",
41
+ provider: "anthropic",
42
+ contextWindow: 200000,
43
+ maxTokens: 8192,
44
+ reasoning: false,
45
+ };
46
+ return {
47
+ getAvailable: async () => [model],
48
+ find: async (_provider: string, modelId: string) =>
49
+ modelId === "claude-3-5-sonnet" ? model : null,
50
+ getApiKeyAndHeaders: async () => ({ apiKey: "sk-test", headers: {} }),
51
+ };
52
+ }
53
+
54
+ // ── Test setup ─────────────────────────────────────────────────────────────
55
+
56
+ function makeKey(scopes = ["all"]) {
57
+ const cleartext = generateKey();
58
+ const entry = {
59
+ id: "k1",
60
+ label: "test",
61
+ createdAt: Date.now(),
62
+ hash: hashKey(cleartext),
63
+ scopes,
64
+ };
65
+ return { cleartext, entry };
66
+ }
67
+
68
+ async function buildApp(opts: {
69
+ streamFn?: (o: any) => AsyncIterable<any>;
70
+ capExhausted?: boolean;
71
+ } = {}) {
72
+ const { cleartext, entry } = makeKey();
73
+ const config: ModelProxyConfig = {
74
+ enabled: true,
75
+ maxConcurrentStreams: opts.capExhausted ? 0 : 16,
76
+ perKeyConcurrentStreams: opts.capExhausted ? 0 : 4,
77
+ logRequests: false,
78
+ apiKeys: [entry],
79
+ };
80
+
81
+ const streamFn = opts.streamFn ?? ((o: any) => fakeTextStream("hello"));
82
+
83
+ const app = Fastify({ logger: false });
84
+
85
+ const gate = createModelProxyAuthGate({ getConfig: () => config });
86
+ app.addHook("onRequest", gate);
87
+
88
+ registerModelProxyRoutes(app, {
89
+ getConfig: () => config,
90
+ getRegistry: async () => makeFakeRegistry(),
91
+ streamSimple: streamFn,
92
+ });
93
+
94
+ await app.ready();
95
+ return { app, cleartext, config };
96
+ }
97
+
98
+ // ── Tests ──────────────────────────────────────────────────────────────────
99
+
100
+ describe("GET /v1/models (task 8.4)", () => {
101
+ it("returns list shape with model data", async () => {
102
+ const { app, cleartext } = await buildApp();
103
+
104
+ const res = await app.inject({
105
+ method: "GET",
106
+ url: "/v1/models",
107
+ headers: { authorization: `Bearer ${cleartext}` },
108
+ });
109
+
110
+ expect(res.statusCode).toBe(200);
111
+ const body = JSON.parse(res.body);
112
+ expect(body.object).toBe("list");
113
+ expect(body.data).toHaveLength(1);
114
+ const m = body.data[0];
115
+ expect(m.id).toContain("claude-3-5-sonnet");
116
+ expect(m.object).toBe("model");
117
+ expect(m["x-pi"].contextWindow).toBe(200000);
118
+ });
119
+
120
+ it("auth missing → 401", async () => {
121
+ const { app } = await buildApp();
122
+
123
+ const res = await app.inject({ method: "GET", url: "/v1/models" });
124
+ expect(res.statusCode).toBe(401);
125
+ });
126
+ });
127
+
128
+ describe("POST /v1/chat/completions (task 8.4)", () => {
129
+ it("non-streaming returns OpenAI completion shape", async () => {
130
+ const { app, cleartext } = await buildApp();
131
+
132
+ const res = await app.inject({
133
+ method: "POST",
134
+ url: "/v1/chat/completions",
135
+ headers: {
136
+ authorization: `Bearer ${cleartext}`,
137
+ "content-type": "application/json",
138
+ },
139
+ body: JSON.stringify({
140
+ model: "anthropic/claude-3-5-sonnet",
141
+ messages: [{ role: "user", content: "hi" }],
142
+ stream: false,
143
+ }),
144
+ });
145
+
146
+ expect(res.statusCode).toBe(200);
147
+ const body = JSON.parse(res.body);
148
+ expect(body.object).toBe("chat.completion");
149
+ expect(body.choices[0].message.content).toBe("hello");
150
+ expect(body.choices[0].finish_reason).toBe("stop");
151
+ });
152
+
153
+ it("streaming returns text/event-stream with [DONE]", async () => {
154
+ const { app, cleartext } = await buildApp();
155
+
156
+ const res = await app.inject({
157
+ method: "POST",
158
+ url: "/v1/chat/completions",
159
+ headers: {
160
+ authorization: `Bearer ${cleartext}`,
161
+ "content-type": "application/json",
162
+ },
163
+ body: JSON.stringify({
164
+ model: "anthropic/claude-3-5-sonnet",
165
+ messages: [{ role: "user", content: "hi" }],
166
+ stream: true,
167
+ }),
168
+ });
169
+
170
+ expect(res.statusCode).toBe(200);
171
+ expect(res.headers["content-type"]).toContain("text/event-stream");
172
+ expect(res.body).toContain("[DONE]");
173
+ expect(res.body).toContain("hello");
174
+ });
175
+
176
+ it("model not found → 404", async () => {
177
+ const { app, cleartext } = await buildApp();
178
+
179
+ const res = await app.inject({
180
+ method: "POST",
181
+ url: "/v1/chat/completions",
182
+ headers: {
183
+ authorization: `Bearer ${cleartext}`,
184
+ "content-type": "application/json",
185
+ },
186
+ body: JSON.stringify({
187
+ model: "anthropic/nonexistent",
188
+ messages: [{ role: "user", content: "hi" }],
189
+ stream: false,
190
+ }),
191
+ });
192
+
193
+ expect(res.statusCode).toBe(404);
194
+ });
195
+
196
+ it("concurrency cap exhaust → 503 SERVER_FULL", async () => {
197
+ const { app, cleartext } = await buildApp({ capExhausted: true });
198
+
199
+ const res = await app.inject({
200
+ method: "POST",
201
+ url: "/v1/chat/completions",
202
+ headers: {
203
+ authorization: `Bearer ${cleartext}`,
204
+ "content-type": "application/json",
205
+ },
206
+ body: JSON.stringify({
207
+ model: "anthropic/claude-3-5-sonnet",
208
+ messages: [{ role: "user", content: "hi" }],
209
+ stream: false,
210
+ }),
211
+ });
212
+
213
+ expect([429, 503]).toContain(res.statusCode);
214
+ });
215
+ });
216
+
217
+ describe("POST /v1/messages (task 8.4)", () => {
218
+ it("returns Anthropic response shape", async () => {
219
+ const { app, cleartext } = await buildApp();
220
+
221
+ const res = await app.inject({
222
+ method: "POST",
223
+ url: "/v1/messages",
224
+ headers: {
225
+ authorization: `Bearer ${cleartext}`,
226
+ "content-type": "application/json",
227
+ },
228
+ body: JSON.stringify({
229
+ model: "anthropic/claude-3-5-sonnet",
230
+ messages: [{ role: "user", content: "hi" }],
231
+ max_tokens: 100,
232
+ stream: false,
233
+ }),
234
+ });
235
+
236
+ expect(res.statusCode).toBe(200);
237
+ const body = JSON.parse(res.body);
238
+ expect(body.type).toBe("message");
239
+ expect(body.role).toBe("assistant");
240
+ expect(body.content[0].text).toBe("hello");
241
+ expect(body.stop_reason).toBe("end_turn");
242
+ });
243
+
244
+ it("Anthropic streaming returns SSE", async () => {
245
+ const { app, cleartext } = await buildApp();
246
+
247
+ const res = await app.inject({
248
+ method: "POST",
249
+ url: "/v1/messages",
250
+ headers: {
251
+ authorization: `Bearer ${cleartext}`,
252
+ "content-type": "application/json",
253
+ },
254
+ body: JSON.stringify({
255
+ model: "anthropic/claude-3-5-sonnet",
256
+ messages: [{ role: "user", content: "hi" }],
257
+ max_tokens: 100,
258
+ stream: true,
259
+ }),
260
+ });
261
+
262
+ expect(res.statusCode).toBe(200);
263
+ expect(res.headers["content-type"]).toContain("text/event-stream");
264
+ expect(res.body).toContain("message_start");
265
+ });
266
+
267
+ it("missing max_tokens → 400", async () => {
268
+ const { app, cleartext } = await buildApp();
269
+
270
+ const res = await app.inject({
271
+ method: "POST",
272
+ url: "/v1/messages",
273
+ headers: {
274
+ authorization: `Bearer ${cleartext}`,
275
+ "content-type": "application/json",
276
+ },
277
+ body: JSON.stringify({
278
+ model: "anthropic/claude-3-5-sonnet",
279
+ messages: [{ role: "user", content: "hi" }],
280
+ // no max_tokens
281
+ }),
282
+ });
283
+
284
+ expect(res.statusCode).toBe(400);
285
+ });
286
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Integration test for the model proxy's optional second port (task 9.3).
3
+ *
4
+ * Starts a server with `modelProxy.secondPort` set, then verifies that
5
+ * GET /v1/models returns an identical response on both ports.
6
+ *
7
+ * The test uses a valid proxy API key on both ports.
8
+ */
9
+ import { describe, it, expect, afterAll } from "vitest";
10
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
11
+ import { writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ let handle: TestServerHandle;
16
+
17
+ async function findFreePort(): Promise<number> {
18
+ const { createServer } = await import("node:net");
19
+ return new Promise((resolve, reject) => {
20
+ const s = createServer();
21
+ s.listen(0, "127.0.0.1", () => {
22
+ const port = (s.address() as any).port;
23
+ s.close(() => resolve(port));
24
+ });
25
+ s.on("error", reject);
26
+ });
27
+ }
28
+
29
+ afterAll(async () => {
30
+ if (handle) await handle.stop();
31
+ });
32
+
33
+ describe("model proxy second port (task 9.3)", () => {
34
+ it("both :mainPort/v1/models and :secondPort/v1/models return identical 200 or 503", async () => {
35
+ const secondPort = await findFreePort();
36
+
37
+ // Write a minimal config with secondPort enabled
38
+ const configPath = join(homedir(), ".pi", "dashboard", "config.json");
39
+ const dashDir = join(homedir(), ".pi", "dashboard");
40
+ const { mkdirSync } = await import("node:fs");
41
+ mkdirSync(dashDir, { recursive: true });
42
+
43
+ let existing: any = {};
44
+ try {
45
+ existing = JSON.parse(require("fs").readFileSync(configPath, "utf-8"));
46
+ } catch {}
47
+
48
+ writeFileSync(configPath, JSON.stringify({
49
+ ...existing,
50
+ modelProxy: {
51
+ ...(existing.modelProxy ?? {}),
52
+ enabled: true,
53
+ secondPort,
54
+ apiKeys: [],
55
+ maxConcurrentStreams: 16,
56
+ perKeyConcurrentStreams: 4,
57
+ logRequests: false,
58
+ },
59
+ }));
60
+
61
+ handle = await createTestServer();
62
+ const { httpPort } = handle;
63
+
64
+ // Generate a proxy API key via the management API
65
+ const createKeyRes = await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ label: "test-key" }),
69
+ });
70
+
71
+ // The server may not have auth enabled in test mode (loopback bypass)
72
+ // so the create should succeed or return an auth response
73
+ let proxyKey: string | null = null;
74
+ if (createKeyRes.ok) {
75
+ const created = await createKeyRes.json() as any;
76
+ proxyKey = created.data?.key ?? null;
77
+ }
78
+
79
+ const authHeader: Record<string, string> = proxyKey
80
+ ? { "Authorization": `Bearer ${proxyKey}` }
81
+ : {};
82
+
83
+ // Main port
84
+ const mainRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
85
+ headers: authHeader,
86
+ });
87
+
88
+ // Second port — if it didn't bind (port conflict) we skip
89
+ let secondRes: Response;
90
+ try {
91
+ secondRes = await fetch(`http://127.0.0.1:${secondPort}/v1/models`, {
92
+ headers: authHeader,
93
+ });
94
+ } catch {
95
+ // Second port failed to bind — warn but don't fail test
96
+ console.warn(`Second port ${secondPort} not reachable — skipping comparison`);
97
+ expect(mainRes.status).toBeGreaterThanOrEqual(200);
98
+ return;
99
+ }
100
+
101
+ // Both should return the same HTTP status code (200 with models or 503 if pi-ai unavailable)
102
+ expect(mainRes.status).toBe(secondRes.status);
103
+
104
+ // Both should return valid JSON
105
+ const mainBody = await mainRes.json() as any;
106
+ const secondBody = await secondRes.json() as any;
107
+
108
+ // The top-level shape should match
109
+ if (mainBody.object) {
110
+ expect(secondBody.object).toBe(mainBody.object);
111
+ } else {
112
+ // Both degraded (503)
113
+ expect(secondBody.code).toBe(mainBody.code);
114
+ }
115
+ });
116
+ });
@@ -16,11 +16,29 @@ function ds(map: Record<string, OpenSpecData | undefined>) {
16
16
  }
17
17
 
18
18
  describe("buildOpenSpecConnectSnapshot", () => {
19
- it("emits cached payload for cwds with initialized data (no pending field)", () => {
19
+ it("emits cached payload for cwds with initialized data (legacy: hasOpenspecDir backfilled from probe)", () => {
20
20
  const cached: OpenSpecData = { initialized: true, changes: [{ name: "x" } as never] };
21
21
  const msgs = buildOpenSpecConnectSnapshot(ds({ "/p": cached }), () => true);
22
22
  expect(msgs).toHaveLength(1);
23
- expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/p", data: cached });
23
+ expect(msgs[0]).toEqual({
24
+ type: "openspec_update",
25
+ cwd: "/p",
26
+ data: { ...cached, hasOpenspecDir: true },
27
+ });
28
+ });
29
+
30
+ it("preserves cached hasOpenspecDir field when set (does NOT overwrite from probe)", () => {
31
+ const cached: OpenSpecData = {
32
+ initialized: true,
33
+ changes: [],
34
+ hasOpenspecDir: true,
35
+ };
36
+ const msgs = buildOpenSpecConnectSnapshot(
37
+ ds({ "/p": cached }),
38
+ () => false,
39
+ () => false, // probe disagrees — cached value wins
40
+ );
41
+ expect(msgs[0].data).toEqual(cached);
24
42
  });
25
43
 
26
44
  it("emits pending: true when openspec dir exists but cache is empty", () => {
@@ -32,7 +50,7 @@ describe("buildOpenSpecConnectSnapshot", () => {
32
50
  {
33
51
  type: "openspec_update",
34
52
  cwd: "/p",
35
- data: { initialized: false, pending: true, changes: [] },
53
+ data: { initialized: false, pending: true, changes: [], hasOpenspecDir: true },
36
54
  },
37
55
  ]);
38
56
  });
@@ -46,7 +64,7 @@ describe("buildOpenSpecConnectSnapshot", () => {
46
64
  {
47
65
  type: "openspec_update",
48
66
  cwd: "/p",
49
- data: { initialized: false, pending: true, changes: [] },
67
+ data: { initialized: false, pending: true, changes: [], hasOpenspecDir: true },
50
68
  },
51
69
  ]);
52
70
  });
@@ -60,7 +78,7 @@ describe("buildOpenSpecConnectSnapshot", () => {
60
78
  {
61
79
  type: "openspec_update",
62
80
  cwd: "/p",
63
- data: { initialized: false, pending: false, changes: [] },
81
+ data: { initialized: false, pending: false, changes: [], hasOpenspecDir: false },
64
82
  },
65
83
  ]);
66
84
  });
@@ -73,19 +91,57 @@ describe("buildOpenSpecConnectSnapshot", () => {
73
91
  (cwd) => cwd === "/cold",
74
92
  );
75
93
  expect(msgs).toHaveLength(3);
76
- expect(msgs[0]).toEqual({ type: "openspec_update", cwd: "/hot", data: cached });
94
+ // legacy cached data without hasOpenspecDir gets backfilled from hasRoot
95
+ // probe (defaults to hasDir when not provided)
96
+ expect(msgs[0]).toEqual({
97
+ type: "openspec_update",
98
+ cwd: "/hot",
99
+ data: { ...cached, hasOpenspecDir: false },
100
+ });
77
101
  expect(msgs[1]).toEqual({
78
102
  type: "openspec_update",
79
103
  cwd: "/cold",
80
- data: { initialized: false, pending: true, changes: [] },
104
+ data: { initialized: false, pending: true, changes: [], hasOpenspecDir: true },
81
105
  });
82
106
  expect(msgs[2]).toEqual({
83
107
  type: "openspec_update",
84
108
  cwd: "/none",
85
- data: { initialized: false, pending: false, changes: [] },
109
+ data: { initialized: false, pending: false, changes: [], hasOpenspecDir: false },
86
110
  });
87
111
  });
88
112
 
113
+ it("emits hasOpenspecDir: true when openspec/ exists but openspec/changes/ does NOT (fresh init)", () => {
114
+ // Exact compsych-letter-demo scenario: `openspec init` was run (openspec/
115
+ // exists with config.yaml) but no `openspec/changes/` subdir yet.
116
+ // Snapshot must surface the project as OpenSpec-applicable so the session
117
+ // card's OPENSPEC subcard renders as an init/attach affordance.
118
+ const msgs = buildOpenSpecConnectSnapshot(
119
+ ds({ "/fresh": { initialized: false, changes: [] } }),
120
+ () => false, // hasDir(openspec/changes/) → false
121
+ () => true, // hasRoot(openspec/) → true
122
+ );
123
+ expect(msgs).toEqual([
124
+ {
125
+ type: "openspec_update",
126
+ cwd: "/fresh",
127
+ data: {
128
+ initialized: false,
129
+ pending: false,
130
+ changes: [],
131
+ hasOpenspecDir: true,
132
+ },
133
+ },
134
+ ]);
135
+ });
136
+
137
+ it("hasRoot defaults to hasDir when omitted (backwards compat)", () => {
138
+ const msgs = buildOpenSpecConnectSnapshot(
139
+ ds({ "/p": undefined }),
140
+ () => true, // hasDir only — hasRoot will mirror it
141
+ );
142
+ expect(msgs[0].data).toMatchObject({ hasOpenspecDir: true });
143
+ });
144
+
89
145
  it("returns empty array when there are no known directories", () => {
90
146
  expect(buildOpenSpecConnectSnapshot(ds({}), () => true)).toEqual([]);
91
147
  });