@blackbelt-technology/pi-agent-dashboard 0.5.0 → 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.
- package/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -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
|
+
}
|