@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,142 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
hashKey,
|
|
4
|
+
verifyKey,
|
|
5
|
+
generateKey,
|
|
6
|
+
findApiKey,
|
|
7
|
+
recordKeyUsage,
|
|
8
|
+
keyHasScope,
|
|
9
|
+
type FindResult,
|
|
10
|
+
} from "../api-key-store.js";
|
|
11
|
+
import type { ProxyApiKey, ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
12
|
+
|
|
13
|
+
function makeConfig(apiKeys: ProxyApiKey[]): ModelProxyConfig {
|
|
14
|
+
return {
|
|
15
|
+
enabled: true,
|
|
16
|
+
maxConcurrentStreams: 16,
|
|
17
|
+
perKeyConcurrentStreams: 4,
|
|
18
|
+
logRequests: false,
|
|
19
|
+
apiKeys,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeKey(overrides: Partial<ProxyApiKey> & { cleartext: string }): { entry: ProxyApiKey; cleartext: string } {
|
|
24
|
+
const { cleartext, ...rest } = overrides;
|
|
25
|
+
return {
|
|
26
|
+
cleartext,
|
|
27
|
+
entry: {
|
|
28
|
+
id: "k1",
|
|
29
|
+
label: "test",
|
|
30
|
+
hash: hashKey(cleartext),
|
|
31
|
+
createdAt: 1000,
|
|
32
|
+
...rest,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("hashKey / verifyKey", () => {
|
|
38
|
+
it("hashKey produces consistent sha256 hex", () => {
|
|
39
|
+
const h = hashKey("pi-proxy-abc123");
|
|
40
|
+
expect(h).toHaveLength(64); // sha256 hex
|
|
41
|
+
expect(h).toBe(hashKey("pi-proxy-abc123"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("verifyKey returns true for matching key", () => {
|
|
45
|
+
const key = "pi-proxy-test";
|
|
46
|
+
expect(verifyKey(key, hashKey(key))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("verifyKey returns false for wrong key", () => {
|
|
50
|
+
expect(verifyKey("wrong", hashKey("right"))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("generateKey", () => {
|
|
55
|
+
it("returns pi-proxy- prefixed key", () => {
|
|
56
|
+
const key = generateKey();
|
|
57
|
+
expect(key).toMatch(/^pi-proxy-[A-Za-z0-9_-]{48}$/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("generates unique keys", () => {
|
|
61
|
+
const keys = new Set(Array.from({ length: 10 }, generateKey));
|
|
62
|
+
expect(keys.size).toBe(10);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("findApiKey", () => {
|
|
67
|
+
it("returns valid entry on match", () => {
|
|
68
|
+
const { entry, cleartext } = makeKey({ cleartext: generateKey() });
|
|
69
|
+
const config = makeConfig([entry]);
|
|
70
|
+
const result = findApiKey(cleartext, config);
|
|
71
|
+
expect(result.kind).toBe("valid");
|
|
72
|
+
expect((result as Extract<FindResult, { kind: "valid" }>).entry.id).toBe("k1");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns miss on no match", () => {
|
|
76
|
+
const config = makeConfig([]);
|
|
77
|
+
expect(findApiKey("pi-proxy-unknown", config).kind).toBe("miss");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns revoked for revoked key", () => {
|
|
81
|
+
const { entry, cleartext } = makeKey({ cleartext: generateKey(), revokedAt: Date.now() });
|
|
82
|
+
const config = makeConfig([entry]);
|
|
83
|
+
expect(findApiKey(cleartext, config).kind).toBe("revoked");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns expired for expired key", () => {
|
|
87
|
+
const { entry, cleartext } = makeKey({ cleartext: generateKey(), expiresAt: Date.now() - 1000 });
|
|
88
|
+
const config = makeConfig([entry]);
|
|
89
|
+
expect(findApiKey(cleartext, config).kind).toBe("expired");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns valid for non-expired key", () => {
|
|
93
|
+
const { entry, cleartext } = makeKey({ cleartext: generateKey(), expiresAt: Date.now() + 60_000 });
|
|
94
|
+
const config = makeConfig([entry]);
|
|
95
|
+
expect(findApiKey(cleartext, config).kind).toBe("valid");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("recordKeyUsage", () => {
|
|
100
|
+
it("updates lastUsedAt on first use", () => {
|
|
101
|
+
const { entry } = makeKey({ cleartext: generateKey() });
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const { updated, apiKeys } = recordKeyUsage(entry.id, [entry], now);
|
|
104
|
+
expect(updated).toBe(true);
|
|
105
|
+
expect(apiKeys[0].lastUsedAt).toBe(now);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("debounces within 60s", () => {
|
|
109
|
+
const { entry } = makeKey({ cleartext: generateKey(), lastUsedAt: 1000 });
|
|
110
|
+
const { updated } = recordKeyUsage(entry.id, [entry], 1000 + 30_000);
|
|
111
|
+
expect(updated).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("updates after debounce window", () => {
|
|
115
|
+
const { entry } = makeKey({ cleartext: generateKey(), lastUsedAt: 1000 });
|
|
116
|
+
const now = 1000 + 61_000;
|
|
117
|
+
const { updated, apiKeys } = recordKeyUsage(entry.id, [entry], now);
|
|
118
|
+
expect(updated).toBe(true);
|
|
119
|
+
expect(apiKeys[0].lastUsedAt).toBe(now);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("keyHasScope", () => {
|
|
124
|
+
it("\"all\" matches any scope", () => {
|
|
125
|
+
const { entry } = makeKey({ cleartext: generateKey(), scopes: ["all"] });
|
|
126
|
+
expect(keyHasScope(entry, "models:list")).toBe(true);
|
|
127
|
+
expect(keyHasScope(entry, "chat")).toBe(true);
|
|
128
|
+
expect(keyHasScope(entry, "messages")).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("specific scope matches only that scope", () => {
|
|
132
|
+
const { entry } = makeKey({ cleartext: generateKey(), scopes: ["models:list"] });
|
|
133
|
+
expect(keyHasScope(entry, "models:list")).toBe(true);
|
|
134
|
+
expect(keyHasScope(entry, "chat")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("default scopes (undefined) acts as [\"all\"]", () => {
|
|
138
|
+
const { entry } = makeKey({ cleartext: generateKey() });
|
|
139
|
+
delete (entry as any).scopes;
|
|
140
|
+
expect(keyHasScope(entry, "chat")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance test for the single-writer auth.json contract (task 2.12).
|
|
3
|
+
*
|
|
4
|
+
* Simulates concurrent credential writes from:
|
|
5
|
+
* (a) InternalAuthStorage (dashboard side) via writeCredential
|
|
6
|
+
* (b) A stub bridge-side AuthStorage.refresh (simulated via writeCredential
|
|
7
|
+
* from a separate "context")
|
|
8
|
+
*
|
|
9
|
+
* Asserts:
|
|
10
|
+
* - File remains valid JSON after concurrent writes
|
|
11
|
+
* - Both writes' fields survive (last-writer-wins for overlapping provider;
|
|
12
|
+
* non-overlapping providers preserved)
|
|
13
|
+
*
|
|
14
|
+
* Cap: 5s timeout.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { writeCredential, readAuthJson } from "../../provider-auth-storage.js";
|
|
21
|
+
|
|
22
|
+
const AUTH_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
23
|
+
const AUTH_PATH = path.join(AUTH_DIR, "auth.json");
|
|
24
|
+
|
|
25
|
+
let backup: string | null = null;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
28
|
+
try { backup = fs.readFileSync(AUTH_PATH, "utf-8"); } catch { backup = null; }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
try {
|
|
33
|
+
if (backup !== null) fs.writeFileSync(AUTH_PATH, backup);
|
|
34
|
+
else fs.rmSync(AUTH_PATH, { force: true });
|
|
35
|
+
} catch {}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("auth.json single-writer contract (task 2.12)", () => {
|
|
39
|
+
it("sequential writes produce valid JSON with all fields", () => {
|
|
40
|
+
// Write provider A from "dashboard" side
|
|
41
|
+
writeCredential("anthropic", { type: "oauth", refresh: "r1", access: "a1", expires: Date.now() + 3600_000 });
|
|
42
|
+
// Write provider B from "bridge" side (simulated as a second writeCredential)
|
|
43
|
+
writeCredential("openai", { type: "api_key", key: "sk-test" });
|
|
44
|
+
|
|
45
|
+
const data = readAuthJson();
|
|
46
|
+
expect(data["anthropic"]).toBeDefined();
|
|
47
|
+
expect(data["openai"]).toBeDefined();
|
|
48
|
+
expect(data["anthropic"].type).toBe("oauth");
|
|
49
|
+
expect(data["openai"].type).toBe("api_key");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("concurrent writes from two 'processes' leave valid JSON", async () => {
|
|
53
|
+
// Pre-populate with initial state
|
|
54
|
+
writeCredential("anthropic", { type: "oauth", refresh: "r0", access: "a0", expires: Date.now() + 100 });
|
|
55
|
+
writeCredential("openai", { type: "api_key", key: "sk-old" });
|
|
56
|
+
|
|
57
|
+
// Simulate two concurrent writes
|
|
58
|
+
const write1 = new Promise<void>((resolve) => {
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
writeCredential("anthropic", { type: "oauth", refresh: "r1", access: "a1", expires: Date.now() + 3600_000 });
|
|
61
|
+
resolve();
|
|
62
|
+
}, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const write2 = new Promise<void>((resolve) => {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
writeCredential("openai", { type: "api_key", key: "sk-new" });
|
|
68
|
+
resolve();
|
|
69
|
+
}, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await Promise.all([write1, write2]);
|
|
73
|
+
|
|
74
|
+
// File must still be valid JSON
|
|
75
|
+
const raw = fs.readFileSync(AUTH_PATH, "utf-8");
|
|
76
|
+
let data: any;
|
|
77
|
+
expect(() => { data = JSON.parse(raw); }).not.toThrow();
|
|
78
|
+
|
|
79
|
+
// Non-overlapping providers both survived (one or both may be latest version)
|
|
80
|
+
expect(data["anthropic"]).toBeDefined();
|
|
81
|
+
expect(data["openai"]).toBeDefined();
|
|
82
|
+
}, 5000);
|
|
83
|
+
|
|
84
|
+
it("overlapping provider write: last writer wins, other provider preserved", () => {
|
|
85
|
+
// Initial state
|
|
86
|
+
writeCredential("anthropic", { type: "oauth", refresh: "r0", access: "a0", expires: 1 });
|
|
87
|
+
writeCredential("gemini", { type: "api_key", key: "gk-original" });
|
|
88
|
+
|
|
89
|
+
// Refresh anthropic (simulates InternalAuthStorage OAuth refresh)
|
|
90
|
+
writeCredential("anthropic", { type: "oauth", refresh: "r1", access: "a1", expires: Date.now() + 3600_000 });
|
|
91
|
+
|
|
92
|
+
const data = readAuthJson();
|
|
93
|
+
// anthropic updated
|
|
94
|
+
expect((data["anthropic"] as any).refresh).toBe("r1");
|
|
95
|
+
// gemini unchanged
|
|
96
|
+
expect((data["gemini"] as any).key).toBe("gk-original");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ConcurrencyTracker, ConcurrencyError } from "../concurrency.js";
|
|
3
|
+
import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
4
|
+
|
|
5
|
+
function makeConfig(overrides?: Partial<ModelProxyConfig>): ModelProxyConfig {
|
|
6
|
+
return {
|
|
7
|
+
enabled: true,
|
|
8
|
+
maxConcurrentStreams: 2,
|
|
9
|
+
perKeyConcurrentStreams: 1,
|
|
10
|
+
logRequests: false,
|
|
11
|
+
apiKeys: [],
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("ConcurrencyTracker", () => {
|
|
17
|
+
it("acquires and releases normally", () => {
|
|
18
|
+
const t = new ConcurrencyTracker();
|
|
19
|
+
const config = makeConfig();
|
|
20
|
+
const release = t.acquire({ apiKeyId: "k1", provider: "openai" }, config);
|
|
21
|
+
expect(typeof release).toBe("function");
|
|
22
|
+
release();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("server cap exhausts → SERVER_FULL", () => {
|
|
26
|
+
const t = new ConcurrencyTracker();
|
|
27
|
+
const config = makeConfig({ maxConcurrentStreams: 2, perKeyConcurrentStreams: 10 });
|
|
28
|
+
t.acquire({ apiKeyId: "k1", provider: "a" }, config);
|
|
29
|
+
t.acquire({ apiKeyId: "k2", provider: "b" }, config);
|
|
30
|
+
expect(() => t.acquire({ apiKeyId: "k3", provider: "c" }, config)).toThrow(ConcurrencyError);
|
|
31
|
+
try {
|
|
32
|
+
t.acquire({ apiKeyId: "k3", provider: "c" }, config);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
expect((e as ConcurrencyError).code).toBe("SERVER_FULL");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("per-key cap exhausts → KEY_FULL", () => {
|
|
39
|
+
const t = new ConcurrencyTracker();
|
|
40
|
+
const config = makeConfig({ maxConcurrentStreams: 10, perKeyConcurrentStreams: 1 });
|
|
41
|
+
t.acquire({ apiKeyId: "k1", provider: "a" }, config);
|
|
42
|
+
expect(() => t.acquire({ apiKeyId: "k1", provider: "b" }, config)).toThrow(ConcurrencyError);
|
|
43
|
+
try {
|
|
44
|
+
t.acquire({ apiKeyId: "k1", provider: "b" }, config);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
expect((e as ConcurrencyError).code).toBe("KEY_FULL");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("per-provider cap exhausts → PROVIDER_FULL", () => {
|
|
51
|
+
const t = new ConcurrencyTracker();
|
|
52
|
+
const config = makeConfig({
|
|
53
|
+
maxConcurrentStreams: 10,
|
|
54
|
+
perKeyConcurrentStreams: 10,
|
|
55
|
+
perProviderCaps: { openai: 1 },
|
|
56
|
+
});
|
|
57
|
+
t.acquire({ apiKeyId: "k1", provider: "openai" }, config);
|
|
58
|
+
expect(() => t.acquire({ apiKeyId: "k2", provider: "openai" }, config)).toThrow(ConcurrencyError);
|
|
59
|
+
try {
|
|
60
|
+
t.acquire({ apiKeyId: "k2", provider: "openai" }, config);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
expect((e as ConcurrencyError).code).toBe("PROVIDER_FULL");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("release decrements all counters", () => {
|
|
67
|
+
const t = new ConcurrencyTracker();
|
|
68
|
+
const config = makeConfig({ maxConcurrentStreams: 1, perKeyConcurrentStreams: 1 });
|
|
69
|
+
const release = t.acquire({ apiKeyId: "k1", provider: "a" }, config);
|
|
70
|
+
release();
|
|
71
|
+
// Should be able to acquire again
|
|
72
|
+
const release2 = t.acquire({ apiKeyId: "k1", provider: "a" }, config);
|
|
73
|
+
release2();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("double release is safe", () => {
|
|
77
|
+
const t = new ConcurrencyTracker();
|
|
78
|
+
const config = makeConfig();
|
|
79
|
+
const release = t.acquire({ apiKeyId: "k1", provider: "a" }, config);
|
|
80
|
+
release();
|
|
81
|
+
release(); // no-op
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("concurrent acquire/release under Promise.all", async () => {
|
|
85
|
+
const t = new ConcurrencyTracker();
|
|
86
|
+
const config = makeConfig({ maxConcurrentStreams: 100, perKeyConcurrentStreams: 100, perProviderCaps: { a: 100 } });
|
|
87
|
+
const releases: (() => void)[] = [];
|
|
88
|
+
|
|
89
|
+
// Acquire many concurrently
|
|
90
|
+
await Promise.all(
|
|
91
|
+
Array.from({ length: 50 }, (_, i) => {
|
|
92
|
+
return new Promise<void>((resolve) => {
|
|
93
|
+
const release = t.acquire({ apiKeyId: `k${i}`, provider: "a" }, config);
|
|
94
|
+
releases.push(release);
|
|
95
|
+
resolve();
|
|
96
|
+
});
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Release all
|
|
101
|
+
releases.forEach((r) => r());
|
|
102
|
+
|
|
103
|
+
// Should be able to acquire again
|
|
104
|
+
const release = t.acquire({ apiKeyId: "k0", provider: "a" }, config);
|
|
105
|
+
release();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { FailedAuthBackoff } from "../failed-auth-backoff.js";
|
|
3
|
+
|
|
4
|
+
describe("FailedAuthBackoff", () => {
|
|
5
|
+
it("starts at 10ms on first failure", () => {
|
|
6
|
+
const b = new FailedAuthBackoff();
|
|
7
|
+
const delay = b.record("1.2.3.4");
|
|
8
|
+
expect(delay).toBe(10);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("doubles on each failure", () => {
|
|
12
|
+
const b = new FailedAuthBackoff();
|
|
13
|
+
expect(b.record("1.2.3.4")).toBe(10);
|
|
14
|
+
expect(b.record("1.2.3.4")).toBe(20);
|
|
15
|
+
expect(b.record("1.2.3.4")).toBe(40);
|
|
16
|
+
expect(b.record("1.2.3.4")).toBe(80);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("caps at 10s", () => {
|
|
20
|
+
const b = new FailedAuthBackoff();
|
|
21
|
+
for (let i = 0; i < 20; i++) b.record("1.2.3.4");
|
|
22
|
+
expect(b.getDelayMs("1.2.3.4")).toBe(10_000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resets on success", () => {
|
|
26
|
+
const b = new FailedAuthBackoff();
|
|
27
|
+
b.record("1.2.3.4");
|
|
28
|
+
b.record("1.2.3.4");
|
|
29
|
+
b.reset("1.2.3.4");
|
|
30
|
+
expect(b.getDelayMs("1.2.3.4")).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("isolates different IPs", () => {
|
|
34
|
+
const b = new FailedAuthBackoff();
|
|
35
|
+
b.record("1.1.1.1");
|
|
36
|
+
b.record("1.1.1.1");
|
|
37
|
+
b.record("2.2.2.2");
|
|
38
|
+
expect(b.getDelayMs("1.1.1.1")).toBe(20);
|
|
39
|
+
expect(b.getDelayMs("2.2.2.2")).toBe(10);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("getDelayMs returns 0 for unknown IP", () => {
|
|
43
|
+
const b = new FailedAuthBackoff();
|
|
44
|
+
expect(b.getDelayMs("unknown")).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isSelfPointing, collectDashboardOrigins } from "../recursion-guard.js";
|
|
3
|
+
|
|
4
|
+
const origins = [
|
|
5
|
+
"localhost:8000",
|
|
6
|
+
"127.0.0.1:8000",
|
|
7
|
+
"[::1]:8000",
|
|
8
|
+
"192.168.1.10:8000",
|
|
9
|
+
"abcdef.share.zrok.io",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe("isSelfPointing", () => {
|
|
13
|
+
it("catches localhost variants", () => {
|
|
14
|
+
expect(isSelfPointing("http://localhost:8000/v1", origins)).toBe(true);
|
|
15
|
+
expect(isSelfPointing("http://127.0.0.1:8000/v1", origins)).toBe(true);
|
|
16
|
+
expect(isSelfPointing("http://[::1]:8000/v1", origins)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("catches LAN IP self", () => {
|
|
20
|
+
expect(isSelfPointing("http://192.168.1.10:8000/v1", origins)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("catches tunnel hostname self", () => {
|
|
24
|
+
expect(isSelfPointing("https://abcdef.share.zrok.io/v1", origins)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("passes legitimate external URL", () => {
|
|
28
|
+
expect(isSelfPointing("https://api.openai.com/v1", origins)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles case insensitivity", () => {
|
|
32
|
+
expect(isSelfPointing("http://LOCALHOST:8000/v1", origins)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("handles different port", () => {
|
|
36
|
+
expect(isSelfPointing("http://localhost:9999/v1", origins)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("handles malformed URL gracefully", () => {
|
|
40
|
+
expect(isSelfPointing("not-a-url", origins)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles HTTPS with default port", () => {
|
|
44
|
+
const httpsOrigins = ["abcdef.share.zrok.io"];
|
|
45
|
+
expect(isSelfPointing("https://abcdef.share.zrok.io/v1", httpsOrigins)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("collectDashboardOrigins", () => {
|
|
50
|
+
it("includes localhost variants", () => {
|
|
51
|
+
const origins = collectDashboardOrigins(8000);
|
|
52
|
+
expect(origins).toContain("localhost:8000");
|
|
53
|
+
expect(origins).toContain("127.0.0.1:8000");
|
|
54
|
+
expect(origins).toContain("[::1]:8000");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("includes tunnel hostname when provided", () => {
|
|
58
|
+
const origins = collectDashboardOrigins(8000, { tunnelHostname: "my.tunnel.io" });
|
|
59
|
+
expect(origins).toContain("my.tunnel.io");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for streamer.ts: streamCompletion wraps pi-ai's streamSimple.
|
|
3
|
+
*
|
|
4
|
+
* Uses faux registry + streamSimple mocks — no real pi-ai required.
|
|
5
|
+
*
|
|
6
|
+
* See change: add-dashboard-model-proxy, tasks 6.2 + 6.3.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from "vitest";
|
|
9
|
+
import { streamCompletion } from "../streamer.js";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeModel(id = "test-model") {
|
|
14
|
+
return { id, provider: "test-provider" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeRegistry(apiKey = "sk-test", headers = {} as Record<string, string>) {
|
|
18
|
+
return {
|
|
19
|
+
getApiKeyAndHeaders: vi.fn().mockResolvedValue({ apiKey, headers }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function* fakeStream(events: any[]): AsyncIterable<any> {
|
|
24
|
+
for (const e of events) yield e;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("streamCompletion", () => {
|
|
30
|
+
it("calls streamSimple with resolved credentials", async () => {
|
|
31
|
+
const model = makeModel();
|
|
32
|
+
const registry = makeRegistry("sk-abc", { "x-foo": "bar" });
|
|
33
|
+
const streamSimple = vi.fn().mockReturnValue(fakeStream([{ type: "start" }]));
|
|
34
|
+
|
|
35
|
+
await streamCompletion({ model, messages: [{ role: "user", content: "hi" }] }, streamSimple as any, registry);
|
|
36
|
+
|
|
37
|
+
expect(registry.getApiKeyAndHeaders).toHaveBeenCalledWith(model);
|
|
38
|
+
expect(streamSimple).toHaveBeenCalledOnce();
|
|
39
|
+
const [, , optionsArg] = streamSimple.mock.calls[0];
|
|
40
|
+
expect(optionsArg.apiKey).toBe("sk-abc");
|
|
41
|
+
expect(optionsArg.headers).toEqual({ "x-foo": "bar" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("passes model, messages, and optional fields through to streamSimple", async () => {
|
|
45
|
+
const model = makeModel();
|
|
46
|
+
const registry = makeRegistry();
|
|
47
|
+
const streamSimple = vi.fn().mockReturnValue(fakeStream([]));
|
|
48
|
+
|
|
49
|
+
const messages = [{ role: "user", content: "hello" }];
|
|
50
|
+
const tools = [{ name: "search" }];
|
|
51
|
+
const signal = new AbortController().signal;
|
|
52
|
+
|
|
53
|
+
await streamCompletion({ model, messages, system: "Be helpful", tools, maxTokens: 100, temperature: 0.7, signal }, streamSimple as any, registry);
|
|
54
|
+
|
|
55
|
+
const [modelArg, contextArg, optionsArg] = streamSimple.mock.calls[0];
|
|
56
|
+
expect(modelArg).toEqual(model);
|
|
57
|
+
expect(contextArg.messages).toEqual(messages);
|
|
58
|
+
expect(contextArg.systemPrompt).toBe("Be helpful");
|
|
59
|
+
expect(contextArg.tools).toEqual(tools);
|
|
60
|
+
expect(optionsArg.maxTokens).toBe(100);
|
|
61
|
+
expect(optionsArg.temperature).toBe(0.7);
|
|
62
|
+
expect(optionsArg.signal).toBe(signal);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("omits systemPrompt when not provided", async () => {
|
|
66
|
+
const model = makeModel();
|
|
67
|
+
const registry = makeRegistry();
|
|
68
|
+
const streamSimple = vi.fn().mockReturnValue(fakeStream([]));
|
|
69
|
+
|
|
70
|
+
await streamCompletion({ model, messages: [] }, streamSimple as any, registry);
|
|
71
|
+
|
|
72
|
+
const [, contextArg] = streamSimple.mock.calls[0];
|
|
73
|
+
expect("systemPrompt" in contextArg).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("yields events from the underlying stream", async () => {
|
|
77
|
+
const model = makeModel();
|
|
78
|
+
const registry = makeRegistry();
|
|
79
|
+
const events = [
|
|
80
|
+
{ type: "start" },
|
|
81
|
+
{ type: "text_delta", delta: "hello" },
|
|
82
|
+
{ type: "done", message: { stopReason: "stop", usage: { input: 5, output: 3 }, content: [] } },
|
|
83
|
+
];
|
|
84
|
+
const streamSimple = vi.fn().mockReturnValue(fakeStream(events));
|
|
85
|
+
|
|
86
|
+
const iterable = await streamCompletion({ model, messages: [] }, streamSimple as any, registry);
|
|
87
|
+
const collected: any[] = [];
|
|
88
|
+
for await (const event of iterable) collected.push(event);
|
|
89
|
+
|
|
90
|
+
expect(collected).toHaveLength(3);
|
|
91
|
+
expect(collected[0].type).toBe("start");
|
|
92
|
+
expect(collected[1].delta).toBe("hello");
|
|
93
|
+
expect(collected[2].type).toBe("done");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("AbortSignal abort terminates iteration promptly (task 6.3)", async () => {
|
|
97
|
+
const model = makeModel();
|
|
98
|
+
const registry = makeRegistry();
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
|
|
101
|
+
let yieldCount = 0;
|
|
102
|
+
async function* slowStream(): AsyncIterable<any> {
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
if (controller.signal.aborted) return;
|
|
106
|
+
yield { type: "text_delta", delta: "chunk" };
|
|
107
|
+
yieldCount++;
|
|
108
|
+
if (yieldCount === 1) {
|
|
109
|
+
controller.abort(); // abort after first event
|
|
110
|
+
}
|
|
111
|
+
// Small delay so abort check catches up
|
|
112
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
// generator cleans up
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const streamSimple = vi.fn().mockReturnValue(slowStream());
|
|
120
|
+
|
|
121
|
+
const iterable = await streamCompletion(
|
|
122
|
+
{ model, messages: [], signal: controller.signal },
|
|
123
|
+
streamSimple as any,
|
|
124
|
+
registry,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const start = Date.now();
|
|
128
|
+
const collected: any[] = [];
|
|
129
|
+
for await (const event of iterable) {
|
|
130
|
+
collected.push(event);
|
|
131
|
+
}
|
|
132
|
+
const elapsed = Date.now() - start;
|
|
133
|
+
|
|
134
|
+
// Terminates promptly — well under 100ms after abort
|
|
135
|
+
expect(elapsed).toBeLessThan(200);
|
|
136
|
+
// Only events before abort emitted
|
|
137
|
+
expect(collected.length).toBeLessThanOrEqual(2);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for proxy API key management: hash, verify, generate, lookup.
|
|
3
|
+
*
|
|
4
|
+
* Keys use the format `pi-proxy-<48 base64url chars>` (288 bits of entropy).
|
|
5
|
+
* Storage uses sha256 hex hashes — cleartext is never persisted.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-dashboard-model-proxy.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import type { ProxyApiKey, ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
11
|
+
|
|
12
|
+
// ── Core helpers ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export function hashKey(key: string): string {
|
|
15
|
+
return crypto.createHash("sha256").update(key).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function verifyKey(key: string, hash: string): boolean {
|
|
19
|
+
return crypto.timingSafeEqual(
|
|
20
|
+
Buffer.from(hashKey(key), "hex"),
|
|
21
|
+
Buffer.from(hash, "hex"),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function generateKey(): string {
|
|
26
|
+
const bytes = crypto.randomBytes(36); // 36 bytes → 48 base64url chars
|
|
27
|
+
return `pi-proxy-${bytes.toString("base64url")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Lookup helpers ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type FindResult =
|
|
33
|
+
| { kind: "valid"; entry: ProxyApiKey }
|
|
34
|
+
| { kind: "revoked" }
|
|
35
|
+
| { kind: "expired" }
|
|
36
|
+
| { kind: "miss" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Look up an API key in the config. Returns discriminated result.
|
|
40
|
+
* Checks revoked/expired status before returning valid.
|
|
41
|
+
*/
|
|
42
|
+
export function findApiKey(token: string, config: ModelProxyConfig): FindResult {
|
|
43
|
+
const tokenHash = hashKey(token);
|
|
44
|
+
for (const entry of config.apiKeys) {
|
|
45
|
+
if (entry.hash !== tokenHash) continue;
|
|
46
|
+
// Constant-time verify to prevent timing attacks
|
|
47
|
+
if (!verifyKey(token, entry.hash)) continue;
|
|
48
|
+
if (entry.revokedAt != null) return { kind: "revoked" };
|
|
49
|
+
if (entry.expiresAt != null && entry.expiresAt <= Date.now()) return { kind: "expired" };
|
|
50
|
+
return { kind: "valid", entry };
|
|
51
|
+
}
|
|
52
|
+
return { kind: "miss" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Debounce window for lastUsedAt updates (60s). */
|
|
56
|
+
const LAST_USED_DEBOUNCE_MS = 60_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Update lastUsedAt on a key entry. Returns mutated apiKeys array (caller persists).
|
|
60
|
+
* Debounces: skips update if last use was within 60s.
|
|
61
|
+
*/
|
|
62
|
+
export function recordKeyUsage(
|
|
63
|
+
id: string,
|
|
64
|
+
apiKeys: ProxyApiKey[],
|
|
65
|
+
now = Date.now(),
|
|
66
|
+
): { updated: boolean; apiKeys: ProxyApiKey[] } {
|
|
67
|
+
const result = apiKeys.map((k) => {
|
|
68
|
+
if (k.id !== id) return k;
|
|
69
|
+
if (k.lastUsedAt && now - k.lastUsedAt < LAST_USED_DEBOUNCE_MS) return k;
|
|
70
|
+
return { ...k, lastUsedAt: now };
|
|
71
|
+
});
|
|
72
|
+
const changed = result.some((k, i) => k !== apiKeys[i]);
|
|
73
|
+
return { updated: changed, apiKeys: result };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Scope helpers ───────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export type ProxyScope = "models:list" | "chat" | "messages";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a key entry has the required scope.
|
|
82
|
+
* `"all"` in scopes matches any required scope.
|
|
83
|
+
*/
|
|
84
|
+
export function keyHasScope(entry: ProxyApiKey, requiredScope: ProxyScope): boolean {
|
|
85
|
+
const scopes = entry.scopes ?? ["all"];
|
|
86
|
+
return scopes.includes("all") || scopes.includes(requiredScope);
|
|
87
|
+
}
|