@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fastify onRequest hook for /v1/* proxy routes.
|
|
3
|
+
*
|
|
4
|
+
* Uniform API-key auth — no JWT, no bypass inheritance.
|
|
5
|
+
* See design.md Decision 2.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-dashboard-model-proxy.
|
|
8
|
+
*/
|
|
9
|
+
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
10
|
+
import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
11
|
+
import { findApiKey, keyHasScope, recordKeyUsage, type ProxyScope } from "./api-key-store.js";
|
|
12
|
+
import { FailedAuthBackoff } from "./failed-auth-backoff.js";
|
|
13
|
+
|
|
14
|
+
export interface AuthGateDeps {
|
|
15
|
+
getConfig: () => ModelProxyConfig;
|
|
16
|
+
persistKeyUsage?: (apiKeys: import("@blackbelt-technology/pi-dashboard-shared/config.js").ProxyApiKey[]) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PROXY_KEY_PREFIX = "pi-proxy-";
|
|
20
|
+
|
|
21
|
+
function scopeForPath(url: string): ProxyScope {
|
|
22
|
+
if (url.startsWith("/v1/models")) return "models:list";
|
|
23
|
+
if (url.startsWith("/v1/messages")) return "messages";
|
|
24
|
+
return "chat"; // /v1/chat/completions and fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms: number): Promise<void> {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createModelProxyAuthGate(deps: AuthGateDeps) {
|
|
32
|
+
const backoff = new FailedAuthBackoff();
|
|
33
|
+
|
|
34
|
+
return async function modelProxyAuthGate(
|
|
35
|
+
request: FastifyRequest,
|
|
36
|
+
reply: FastifyReply,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const url = request.url;
|
|
39
|
+
if (!url.startsWith("/v1/")) return; // not a proxy route
|
|
40
|
+
|
|
41
|
+
const authHeader = request.headers.authorization;
|
|
42
|
+
|
|
43
|
+
// Apply backoff delay before verification
|
|
44
|
+
const ip = request.ip;
|
|
45
|
+
const delay = backoff.getDelayMs(ip);
|
|
46
|
+
if (delay > 0) await sleep(delay);
|
|
47
|
+
|
|
48
|
+
// Missing authorization
|
|
49
|
+
if (!authHeader) {
|
|
50
|
+
backoff.record(ip);
|
|
51
|
+
return reply.code(401).send({ code: "AUTH_REQUIRED", message: "Authorization header required" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract bearer token
|
|
55
|
+
if (!authHeader.startsWith("Bearer ")) {
|
|
56
|
+
backoff.record(ip);
|
|
57
|
+
return reply.code(401).send({ code: "AUTH_MALFORMED", message: "Authorization must be Bearer token" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = authHeader.slice(7);
|
|
61
|
+
if (!token) {
|
|
62
|
+
backoff.record(ip);
|
|
63
|
+
return reply.code(401).send({ code: "AUTH_MALFORMED", message: "Empty bearer token" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Reject non-proxy-key tokens (JWT or arbitrary)
|
|
67
|
+
if (!token.startsWith(PROXY_KEY_PREFIX)) {
|
|
68
|
+
backoff.record(ip);
|
|
69
|
+
return reply.code(401).send({
|
|
70
|
+
code: "PROXY_KEY_REQUIRED",
|
|
71
|
+
message: "Only proxy API keys (pi-proxy-*) are accepted for /v1/* routes",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Look up the key
|
|
76
|
+
const config = deps.getConfig();
|
|
77
|
+
const result = findApiKey(token, config);
|
|
78
|
+
|
|
79
|
+
switch (result.kind) {
|
|
80
|
+
case "revoked":
|
|
81
|
+
backoff.record(ip);
|
|
82
|
+
return reply.code(401).send({ code: "AUTH_REVOKED", message: "API key has been revoked" });
|
|
83
|
+
case "expired":
|
|
84
|
+
backoff.record(ip);
|
|
85
|
+
return reply.code(401).send({ code: "AUTH_EXPIRED", message: "API key has expired" });
|
|
86
|
+
case "miss":
|
|
87
|
+
backoff.record(ip);
|
|
88
|
+
return reply.code(401).send({ code: "AUTH_REQUIRED", message: "Invalid API key" });
|
|
89
|
+
case "valid": {
|
|
90
|
+
// Scope check
|
|
91
|
+
const requiredScope = scopeForPath(url);
|
|
92
|
+
if (!keyHasScope(result.entry, requiredScope)) {
|
|
93
|
+
return reply.code(403).send({
|
|
94
|
+
code: "SCOPE_INSUFFICIENT",
|
|
95
|
+
required: requiredScope,
|
|
96
|
+
granted: result.entry.scopes ?? ["all"],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Success — reset backoff + record usage
|
|
101
|
+
backoff.reset(ip);
|
|
102
|
+
|
|
103
|
+
// Record usage (debounced, fire-and-forget)
|
|
104
|
+
if (deps.persistKeyUsage) {
|
|
105
|
+
const { updated, apiKeys } = recordKeyUsage(result.entry.id, config.apiKeys);
|
|
106
|
+
if (updated) deps.persistKeyUsage(apiKeys);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Attach key info to request for downstream handlers
|
|
110
|
+
(request as any).proxyApiKeyId = result.entry.id;
|
|
111
|
+
(request as any).proxyApiKeyLabel = result.entry.label;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested concurrency caps for the model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Three levels: server-wide, per-API-key, per-upstream-provider.
|
|
5
|
+
* Caps read from config at acquire time so live config updates take effect.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-dashboard-model-proxy.
|
|
8
|
+
*/
|
|
9
|
+
import type { ModelProxyConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
10
|
+
|
|
11
|
+
export type ConcurrencyErrorCode = "SERVER_FULL" | "KEY_FULL" | "PROVIDER_FULL";
|
|
12
|
+
|
|
13
|
+
export class ConcurrencyError extends Error {
|
|
14
|
+
public readonly code: ConcurrencyErrorCode;
|
|
15
|
+
public readonly retryAfterMs: number;
|
|
16
|
+
|
|
17
|
+
constructor(code: ConcurrencyErrorCode, retryAfterMs = 1000) {
|
|
18
|
+
super(`Concurrency limit exceeded: ${code}`);
|
|
19
|
+
this.name = "ConcurrencyError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.retryAfterMs = retryAfterMs;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ConcurrencyTracker {
|
|
26
|
+
private serverCount = 0;
|
|
27
|
+
private perKey = new Map<string, number>();
|
|
28
|
+
private perProvider = new Map<string, number>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Acquire a concurrency slot. Returns a release() callback.
|
|
32
|
+
* Throws ConcurrencyError if any cap is exceeded.
|
|
33
|
+
*/
|
|
34
|
+
acquire(
|
|
35
|
+
opts: { apiKeyId: string; provider: string },
|
|
36
|
+
config: ModelProxyConfig,
|
|
37
|
+
): () => void {
|
|
38
|
+
const { apiKeyId, provider } = opts;
|
|
39
|
+
|
|
40
|
+
// Server-wide check
|
|
41
|
+
if (this.serverCount >= config.maxConcurrentStreams) {
|
|
42
|
+
throw new ConcurrencyError("SERVER_FULL");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Per-key check
|
|
46
|
+
const keyCount = this.perKey.get(apiKeyId) ?? 0;
|
|
47
|
+
if (keyCount >= config.perKeyConcurrentStreams) {
|
|
48
|
+
throw new ConcurrencyError("KEY_FULL");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Per-provider check
|
|
52
|
+
const providerCount = this.perProvider.get(provider) ?? 0;
|
|
53
|
+
const providerCap = config.perProviderCaps?.[provider] ?? 4;
|
|
54
|
+
if (providerCount >= providerCap) {
|
|
55
|
+
throw new ConcurrencyError("PROVIDER_FULL");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Acquire all three
|
|
59
|
+
this.serverCount++;
|
|
60
|
+
this.perKey.set(apiKeyId, keyCount + 1);
|
|
61
|
+
this.perProvider.set(provider, providerCount + 1);
|
|
62
|
+
|
|
63
|
+
let released = false;
|
|
64
|
+
return () => {
|
|
65
|
+
if (released) return;
|
|
66
|
+
released = true;
|
|
67
|
+
this.serverCount = Math.max(0, this.serverCount - 1);
|
|
68
|
+
const newKeyCount = Math.max(0, (this.perKey.get(apiKeyId) ?? 1) - 1);
|
|
69
|
+
if (newKeyCount === 0) this.perKey.delete(apiKeyId);
|
|
70
|
+
else this.perKey.set(apiKeyId, newKeyCount);
|
|
71
|
+
const newProviderCount = Math.max(0, (this.perProvider.get(provider) ?? 1) - 1);
|
|
72
|
+
if (newProviderCount === 0) this.perProvider.delete(provider);
|
|
73
|
+
else this.perProvider.set(provider, newProviderCount);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Upstream Source
|
|
2
|
+
|
|
3
|
+
Lifted from [BlackBeltTechnology/pi-model-proxy](https://github.com/BlackBeltTechnology/pi-model-proxy), MIT licensed.
|
|
4
|
+
|
|
5
|
+
- **Commit:** 179d450 (v0.40.1)
|
|
6
|
+
- **Lift date:** 2026-05-07
|
|
7
|
+
- **pi-ai types version:** 0.73.0
|
|
8
|
+
|
|
9
|
+
## Local Divergences
|
|
10
|
+
|
|
11
|
+
1. **Type imports**: Upstream uses `import type { ... } from "@earendil-works/pi-ai"` — replaced with `any` since pi-ai is runtime-resolved. Local `types.ts` mirrors upstream's `../types.js` shapes.
|
|
12
|
+
2. **Tab → 2-space indentation**: Matches dashboard's `.editorconfig`.
|
|
13
|
+
3. **Lint**: Minor adjustments for dashboard's stricter `tsconfig` (no implicit any on some paths).
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/* Ported from BlackBeltTechnology/pi-model-proxy@179d450 test suite.
|
|
2
|
+
* See model-proxy/convert/UPSTREAM.md for divergences.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { convertAnthropicMessages, convertAnthropicTools } from "../anthropic-in.js";
|
|
6
|
+
|
|
7
|
+
describe("convertAnthropicMessages", () => {
|
|
8
|
+
it("string system prompt", () => {
|
|
9
|
+
const req = { system: "You are helpful.", messages: [{ role: "user", content: "hi" }] };
|
|
10
|
+
const { systemPrompt, messages } = convertAnthropicMessages(req as any);
|
|
11
|
+
expect(systemPrompt).toBe("You are helpful.");
|
|
12
|
+
expect(messages).toHaveLength(1);
|
|
13
|
+
expect(messages[0].role).toBe("user");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("array system prompt extracted as text", () => {
|
|
17
|
+
const req = {
|
|
18
|
+
system: [{ type: "text", text: "Part 1" }, { type: "text", text: "Part 2" }],
|
|
19
|
+
messages: [],
|
|
20
|
+
};
|
|
21
|
+
const { systemPrompt } = convertAnthropicMessages(req as any);
|
|
22
|
+
expect(systemPrompt).toBe("Part 1\nPart 2");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("no system prompt", () => {
|
|
26
|
+
const req = { messages: [{ role: "user", content: "hi" }] };
|
|
27
|
+
const { systemPrompt } = convertAnthropicMessages(req as any);
|
|
28
|
+
expect(systemPrompt).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("string user message", () => {
|
|
32
|
+
const req = { messages: [{ role: "user", content: "hello" }] };
|
|
33
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
34
|
+
expect(messages[0].role).toBe("user");
|
|
35
|
+
expect(messages[0].content).toBe("hello");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("user message with text content blocks", () => {
|
|
39
|
+
const req = {
|
|
40
|
+
messages: [
|
|
41
|
+
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
45
|
+
expect(messages).toHaveLength(1);
|
|
46
|
+
expect(messages[0].role).toBe("user");
|
|
47
|
+
expect(messages[0].content).toBe("hello");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("user message with image block", () => {
|
|
51
|
+
const req = {
|
|
52
|
+
messages: [
|
|
53
|
+
{
|
|
54
|
+
role: "user",
|
|
55
|
+
content: [
|
|
56
|
+
{ type: "text", text: "what is this?" },
|
|
57
|
+
{ type: "image", source: { media_type: "image/png", data: "abc123" } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
63
|
+
expect(Array.isArray(messages[0].content)).toBe(true);
|
|
64
|
+
const imgPart = (messages[0].content as any[]).find((p: any) => p.type === "image");
|
|
65
|
+
expect(imgPart?.mimeType).toBe("image/png");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("tool_result block splits into toolResult message", () => {
|
|
69
|
+
const req = {
|
|
70
|
+
messages: [
|
|
71
|
+
{
|
|
72
|
+
role: "user",
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: "tool_result",
|
|
76
|
+
tool_use_id: "tu1",
|
|
77
|
+
content: "result",
|
|
78
|
+
is_error: false,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
85
|
+
const toolResult = messages.find((m: any) => m.role === "toolResult");
|
|
86
|
+
expect(toolResult).toBeDefined();
|
|
87
|
+
expect(toolResult.toolCallId).toBe("tu1");
|
|
88
|
+
expect(toolResult.content[0].text).toBe("result");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("assistant message with text", () => {
|
|
92
|
+
const req = {
|
|
93
|
+
messages: [
|
|
94
|
+
{ role: "user", content: "hi" },
|
|
95
|
+
{ role: "assistant", content: "hey there" },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
99
|
+
const assistant = messages.find((m: any) => m.role === "assistant");
|
|
100
|
+
expect(assistant).toBeDefined();
|
|
101
|
+
const textPart = assistant.content.find((c: any) => c.type === "text");
|
|
102
|
+
expect(textPart?.text).toBe("hey there");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("assistant message with tool_use block", () => {
|
|
106
|
+
const req = {
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
role: "assistant",
|
|
110
|
+
content: [
|
|
111
|
+
{ type: "tool_use", id: "tu1", name: "my_fn", input: { x: 1 } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
const { messages } = convertAnthropicMessages(req as any);
|
|
117
|
+
const toolCall = messages[0].content.find((c: any) => c.type === "toolCall");
|
|
118
|
+
expect(toolCall).toBeDefined();
|
|
119
|
+
expect(toolCall.name).toBe("my_fn");
|
|
120
|
+
expect(toolCall.arguments).toEqual({ x: 1 });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("convertAnthropicTools", () => {
|
|
125
|
+
it("converts tool definitions", () => {
|
|
126
|
+
const tools = convertAnthropicTools([
|
|
127
|
+
{ name: "search", description: "Search the web", input_schema: { type: "object", properties: {} } },
|
|
128
|
+
]);
|
|
129
|
+
expect(tools[0].name).toBe("search");
|
|
130
|
+
expect(tools[0].description).toBe("Search the web");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles missing description", () => {
|
|
134
|
+
const tools = convertAnthropicTools([{ name: "no_desc" } as any]);
|
|
135
|
+
expect(tools[0].description).toBe("");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/* Ported from BlackBeltTechnology/pi-model-proxy@179d450 test suite.
|
|
2
|
+
* See model-proxy/convert/UPSTREAM.md for divergences.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { AnthropicBlockTracker, eventToAnthropicSSE, eventToAnthropicResponse } from "../anthropic-out.js";
|
|
6
|
+
|
|
7
|
+
const MODEL = "anthropic/claude-3-5-sonnet";
|
|
8
|
+
const MSG_ID = "msg_test";
|
|
9
|
+
|
|
10
|
+
function parseSSELine(line: string): { eventType: string; data: any } {
|
|
11
|
+
const eventMatch = line.match(/^event: (.+)/m);
|
|
12
|
+
const dataMatch = line.match(/^data: (.+)/m);
|
|
13
|
+
const eventType = eventMatch?.[1] ?? "unknown";
|
|
14
|
+
const data = dataMatch ? JSON.parse(dataMatch[1]) : null;
|
|
15
|
+
return { eventType, data };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeTracker() {
|
|
19
|
+
return new AnthropicBlockTracker();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("AnthropicBlockTracker", () => {
|
|
23
|
+
it("starts at -1", () => {
|
|
24
|
+
const t = makeTracker();
|
|
25
|
+
expect(t.getCurrentIndex()).toBe(-1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("nextIndex increments and returns", () => {
|
|
29
|
+
const t = makeTracker();
|
|
30
|
+
expect(t.nextIndex()).toBe(0);
|
|
31
|
+
expect(t.nextIndex()).toBe(1);
|
|
32
|
+
expect(t.getCurrentIndex()).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("eventToAnthropicSSE", () => {
|
|
37
|
+
it("start emits message_start", () => {
|
|
38
|
+
const chunks = eventToAnthropicSSE({ type: "start" }, MODEL, MSG_ID, makeTracker());
|
|
39
|
+
expect(chunks).toHaveLength(1);
|
|
40
|
+
const { eventType, data } = parseSSELine(chunks[0]);
|
|
41
|
+
expect(eventType).toBe("message_start");
|
|
42
|
+
expect(data.message.id).toBe(MSG_ID);
|
|
43
|
+
expect(data.message.role).toBe("assistant");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("text_delta without prior block emits content_block_start then delta", () => {
|
|
47
|
+
const tracker = makeTracker();
|
|
48
|
+
const chunks = eventToAnthropicSSE({ type: "text_delta", delta: "hello" }, MODEL, MSG_ID, tracker);
|
|
49
|
+
expect(chunks).toHaveLength(2);
|
|
50
|
+
const { eventType: et0 } = parseSSELine(chunks[0]);
|
|
51
|
+
const { eventType: et1, data: d1 } = parseSSELine(chunks[1]);
|
|
52
|
+
expect(et0).toBe("content_block_start");
|
|
53
|
+
expect(et1).toBe("content_block_delta");
|
|
54
|
+
expect(d1.delta.text).toBe("hello");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("text_delta with existing block emits only delta", () => {
|
|
58
|
+
const tracker = makeTracker();
|
|
59
|
+
tracker.nextIndex(); // simulate existing block at index 0
|
|
60
|
+
const chunks = eventToAnthropicSSE({ type: "text_delta", delta: "more" }, MODEL, MSG_ID, tracker);
|
|
61
|
+
expect(chunks).toHaveLength(1);
|
|
62
|
+
const { eventType, data } = parseSSELine(chunks[0]);
|
|
63
|
+
expect(eventType).toBe("content_block_delta");
|
|
64
|
+
expect(data.delta.text).toBe("more");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("thinking_delta emits thinking content_block_start + delta", () => {
|
|
68
|
+
const tracker = makeTracker();
|
|
69
|
+
const chunks = eventToAnthropicSSE({ type: "thinking_delta", delta: "hmm" }, MODEL, MSG_ID, tracker);
|
|
70
|
+
expect(chunks).toHaveLength(2);
|
|
71
|
+
const { data: startData } = parseSSELine(chunks[0]);
|
|
72
|
+
expect(startData.content_block.type).toBe("thinking");
|
|
73
|
+
const { data: deltaData } = parseSSELine(chunks[1]);
|
|
74
|
+
expect(deltaData.delta.type).toBe("thinking_delta");
|
|
75
|
+
expect(deltaData.delta.thinking).toBe("hmm");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("toolcall_start closes prior block and opens tool_use block", () => {
|
|
79
|
+
const tracker = makeTracker();
|
|
80
|
+
tracker.nextIndex(); // prior block at 0
|
|
81
|
+
const event = {
|
|
82
|
+
type: "toolcall_start",
|
|
83
|
+
contentIndex: 0,
|
|
84
|
+
partial: { content: [{ type: "toolCall", id: "tc1", name: "my_fn" }] },
|
|
85
|
+
};
|
|
86
|
+
const chunks = eventToAnthropicSSE(event, MODEL, MSG_ID, tracker);
|
|
87
|
+
// Should emit content_block_stop + content_block_start
|
|
88
|
+
expect(chunks).toHaveLength(2);
|
|
89
|
+
const { eventType: et0 } = parseSSELine(chunks[0]);
|
|
90
|
+
const { eventType: et1, data: d1 } = parseSSELine(chunks[1]);
|
|
91
|
+
expect(et0).toBe("content_block_stop");
|
|
92
|
+
expect(et1).toBe("content_block_start");
|
|
93
|
+
expect(d1.content_block.type).toBe("tool_use");
|
|
94
|
+
expect(d1.content_block.name).toBe("my_fn");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("toolcall_delta emits input_json_delta", () => {
|
|
98
|
+
const tracker = makeTracker();
|
|
99
|
+
tracker.nextIndex(); // block at 0
|
|
100
|
+
const chunks = eventToAnthropicSSE({ type: "toolcall_delta", delta: '{"x":' }, MODEL, MSG_ID, tracker);
|
|
101
|
+
expect(chunks).toHaveLength(1);
|
|
102
|
+
const { data } = parseSSELine(chunks[0]);
|
|
103
|
+
expect(data.delta.type).toBe("input_json_delta");
|
|
104
|
+
expect(data.delta.partial_json).toBe('{"x":');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("done emits content_block_stop + message_delta + message_stop", () => {
|
|
108
|
+
const tracker = makeTracker();
|
|
109
|
+
tracker.nextIndex(); // block at 0
|
|
110
|
+
const msg = { stopReason: "stop", usage: { input: 10, output: 5 }, content: [] };
|
|
111
|
+
const chunks = eventToAnthropicSSE({ type: "done", message: msg }, MODEL, MSG_ID, tracker);
|
|
112
|
+
expect(chunks).toHaveLength(3);
|
|
113
|
+
const { eventType: et0 } = parseSSELine(chunks[0]);
|
|
114
|
+
const { eventType: et1, data: d1 } = parseSSELine(chunks[1]);
|
|
115
|
+
const { eventType: et2 } = parseSSELine(chunks[2]);
|
|
116
|
+
expect(et0).toBe("content_block_stop");
|
|
117
|
+
expect(et1).toBe("message_delta");
|
|
118
|
+
expect(d1.delta.stop_reason).toBe("end_turn");
|
|
119
|
+
expect(d1.usage.output_tokens).toBe(5);
|
|
120
|
+
expect(et2).toBe("message_stop");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("done with stopReason=toolUse → stop_reason=tool_use", () => {
|
|
124
|
+
const tracker = makeTracker();
|
|
125
|
+
const msg = { stopReason: "toolUse", usage: { input: 0, output: 0 }, content: [] };
|
|
126
|
+
const chunks = eventToAnthropicSSE({ type: "done", message: msg }, MODEL, MSG_ID, tracker);
|
|
127
|
+
const messageDelta = chunks.find((c) => c.includes("message_delta"));
|
|
128
|
+
expect(messageDelta).toBeDefined();
|
|
129
|
+
const { data } = parseSSELine(messageDelta!);
|
|
130
|
+
expect(data.delta.stop_reason).toBe("tool_use");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("done with stopReason=length → stop_reason=max_tokens", () => {
|
|
134
|
+
const tracker = makeTracker();
|
|
135
|
+
const msg = { stopReason: "length", usage: { input: 0, output: 0 }, content: [] };
|
|
136
|
+
const chunks = eventToAnthropicSSE({ type: "done", message: msg }, MODEL, MSG_ID, tracker);
|
|
137
|
+
const messageDelta = chunks.find((c) => c.includes("message_delta"));
|
|
138
|
+
const { data } = parseSSELine(messageDelta!);
|
|
139
|
+
expect(data.delta.stop_reason).toBe("max_tokens");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("error emits error event", () => {
|
|
143
|
+
const chunks = eventToAnthropicSSE({ type: "error", error: { errorMessage: "fail" } }, MODEL, MSG_ID, makeTracker());
|
|
144
|
+
expect(chunks).toHaveLength(1);
|
|
145
|
+
const { eventType, data } = parseSSELine(chunks[0]);
|
|
146
|
+
expect(eventType).toBe("error");
|
|
147
|
+
expect(data.error.message).toBe("fail");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("eventToAnthropicResponse", () => {
|
|
152
|
+
it("text response", () => {
|
|
153
|
+
const msg = {
|
|
154
|
+
content: [{ type: "text", text: "hello" }],
|
|
155
|
+
stopReason: "stop",
|
|
156
|
+
usage: { input: 5, output: 3 },
|
|
157
|
+
};
|
|
158
|
+
const response = eventToAnthropicResponse(msg, MODEL, MSG_ID);
|
|
159
|
+
expect(response.id).toBe(MSG_ID);
|
|
160
|
+
expect(response.role).toBe("assistant");
|
|
161
|
+
expect(response.content[0]).toEqual({ type: "text", text: "hello" });
|
|
162
|
+
expect(response.stop_reason).toBe("end_turn");
|
|
163
|
+
expect(response.usage.input_tokens).toBe(5);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("tool_use response", () => {
|
|
167
|
+
const msg = {
|
|
168
|
+
content: [{ type: "toolCall", id: "tc1", name: "fn", arguments: { x: 1 } }],
|
|
169
|
+
stopReason: "toolUse",
|
|
170
|
+
usage: { input: 5, output: 3 },
|
|
171
|
+
};
|
|
172
|
+
const response = eventToAnthropicResponse(msg, MODEL, MSG_ID);
|
|
173
|
+
expect(response.stop_reason).toBe("tool_use");
|
|
174
|
+
expect(response.content[0].type).toBe("tool_use");
|
|
175
|
+
expect(response.content[0].input).toEqual({ x: 1 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("max_tokens stop reason", () => {
|
|
179
|
+
const msg = { content: [], stopReason: "length", usage: { input: 1, output: 1 } };
|
|
180
|
+
const response = eventToAnthropicResponse(msg, MODEL, MSG_ID);
|
|
181
|
+
expect(response.stop_reason).toBe("max_tokens");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* Ported from BlackBeltTechnology/pi-model-proxy@179d450 test suite.
|
|
2
|
+
* See model-proxy/convert/UPSTREAM.md for divergences.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { convertOpenAIMessages, convertOpenAITools } from "../openai-in.js";
|
|
6
|
+
|
|
7
|
+
describe("convertOpenAIMessages", () => {
|
|
8
|
+
it("plain user message", () => {
|
|
9
|
+
const { systemPrompt, messages } = convertOpenAIMessages([
|
|
10
|
+
{ role: "user", content: "hello" },
|
|
11
|
+
]);
|
|
12
|
+
expect(systemPrompt).toBeUndefined();
|
|
13
|
+
expect(messages).toHaveLength(1);
|
|
14
|
+
expect(messages[0].role).toBe("user");
|
|
15
|
+
expect(messages[0].content).toBe("hello");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("system message extracted as systemPrompt", () => {
|
|
19
|
+
const { systemPrompt, messages } = convertOpenAIMessages([
|
|
20
|
+
{ role: "system", content: "You are helpful." },
|
|
21
|
+
{ role: "user", content: "hi" },
|
|
22
|
+
]);
|
|
23
|
+
expect(systemPrompt).toBe("You are helpful.");
|
|
24
|
+
expect(messages).toHaveLength(1);
|
|
25
|
+
expect(messages[0].role).toBe("user");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("multiple system messages joined with newline", () => {
|
|
29
|
+
const { systemPrompt } = convertOpenAIMessages([
|
|
30
|
+
{ role: "system", content: "Part 1." },
|
|
31
|
+
{ role: "system", content: "Part 2." },
|
|
32
|
+
]);
|
|
33
|
+
expect(systemPrompt).toBe("Part 1.\nPart 2.");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("assistant message with text", () => {
|
|
37
|
+
const { messages } = convertOpenAIMessages([
|
|
38
|
+
{ role: "user", content: "hi" },
|
|
39
|
+
{ role: "assistant", content: "hey" },
|
|
40
|
+
]);
|
|
41
|
+
expect(messages).toHaveLength(2);
|
|
42
|
+
expect(messages[1].role).toBe("assistant");
|
|
43
|
+
const textPart = messages[1].content.find((c: any) => c.type === "text");
|
|
44
|
+
expect(textPart?.text).toBe("hey");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("assistant message with tool_calls", () => {
|
|
48
|
+
const { messages } = convertOpenAIMessages([
|
|
49
|
+
{
|
|
50
|
+
role: "assistant",
|
|
51
|
+
content: null,
|
|
52
|
+
tool_calls: [
|
|
53
|
+
{ id: "tc1", type: "function", function: { name: "my_tool", arguments: '{"x":1}' } },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
expect(messages[0].role).toBe("assistant");
|
|
58
|
+
const toolCall = messages[0].content.find((c: any) => c.type === "toolCall");
|
|
59
|
+
expect(toolCall).toBeDefined();
|
|
60
|
+
expect(toolCall.name).toBe("my_tool");
|
|
61
|
+
expect(toolCall.arguments).toEqual({ x: 1 });
|
|
62
|
+
expect(toolCall.id).toBe("tc1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("tool result message", () => {
|
|
66
|
+
const { messages } = convertOpenAIMessages([
|
|
67
|
+
{ role: "tool", content: "result text", tool_call_id: "tc1", name: "my_tool" },
|
|
68
|
+
]);
|
|
69
|
+
expect(messages[0].role).toBe("toolResult");
|
|
70
|
+
expect(messages[0].toolCallId).toBe("tc1");
|
|
71
|
+
expect(messages[0].content[0].text).toBe("result text");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("user message with image content part", () => {
|
|
75
|
+
const { messages } = convertOpenAIMessages([
|
|
76
|
+
{
|
|
77
|
+
role: "user",
|
|
78
|
+
content: [
|
|
79
|
+
{ type: "text", text: "what is this?" },
|
|
80
|
+
{ type: "image_url", image_url: { url: "data:image/png;base64,abc123" } },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
const content = messages[0].content;
|
|
85
|
+
expect(Array.isArray(content)).toBe(true);
|
|
86
|
+
const imgPart = (content as any[]).find((p: any) => p.type === "image");
|
|
87
|
+
expect(imgPart).toBeDefined();
|
|
88
|
+
expect(imgPart.mimeType).toBe("image/png");
|
|
89
|
+
expect(imgPart.data).toBe("abc123");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("user message with text-only content parts collapses to string", () => {
|
|
93
|
+
const { messages } = convertOpenAIMessages([
|
|
94
|
+
{
|
|
95
|
+
role: "user",
|
|
96
|
+
content: [{ type: "text", text: "just text" }],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
expect(messages[0].content).toBe("just text");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("convertOpenAITools", () => {
|
|
104
|
+
it("converts tool definitions", () => {
|
|
105
|
+
const tools = convertOpenAITools([
|
|
106
|
+
{
|
|
107
|
+
type: "function",
|
|
108
|
+
function: {
|
|
109
|
+
name: "get_weather",
|
|
110
|
+
description: "Get the weather",
|
|
111
|
+
parameters: { type: "object", properties: { city: { type: "string" } } },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
expect(tools).toHaveLength(1);
|
|
116
|
+
expect(tools[0].name).toBe("get_weather");
|
|
117
|
+
expect(tools[0].description).toBe("Get the weather");
|
|
118
|
+
expect(tools[0].parameters.properties.city).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles missing description", () => {
|
|
122
|
+
const tools = convertOpenAITools([
|
|
123
|
+
{ type: "function", function: { name: "no_desc" } },
|
|
124
|
+
]);
|
|
125
|
+
expect(tools[0].description).toBe("");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("handles missing parameters", () => {
|
|
129
|
+
const tools = convertOpenAITools([
|
|
130
|
+
{ type: "function", function: { name: "no_params" } },
|
|
131
|
+
]);
|
|
132
|
+
expect(tools[0].parameters).toEqual({ type: "object", properties: {} });
|
|
133
|
+
});
|
|
134
|
+
});
|