@bothat-io/molenkopf 0.1.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/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SECURITY.md +36 -0
- package/bin/launcher.js +76 -0
- package/bin/molenkopf.js +4 -0
- package/docs/DEPLOYMENT.md +104 -0
- package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
- package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
- package/docs/MOLENKOPF_USAGE.md +195 -0
- package/docs/PRODUCT_INTENT.md +36 -0
- package/docs/THREAT_MODEL.md +94 -0
- package/molenkopf.config.example.json +68 -0
- package/package.json +98 -0
- package/packages/core/src/auth/password.ts +47 -0
- package/packages/core/src/auth/session.ts +64 -0
- package/packages/core/src/ci/ci-mode.ts +71 -0
- package/packages/core/src/compression/content-classifier.ts +25 -0
- package/packages/core/src/compression/context-compressor.ts +48 -0
- package/packages/core/src/compression/json-compressor.ts +54 -0
- package/packages/core/src/compression/log-compressor.ts +32 -0
- package/packages/core/src/compression/operational-block-compressor.ts +43 -0
- package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
- package/packages/core/src/config/config-policies.ts +146 -0
- package/packages/core/src/config/molenkopf-config.ts +137 -0
- package/packages/core/src/config/provider-config.ts +139 -0
- package/packages/core/src/events/event-bus.ts +88 -0
- package/packages/core/src/identity/api-keys.ts +149 -0
- package/packages/core/src/identity/budget.ts +51 -0
- package/packages/core/src/identity/db.ts +68 -0
- package/packages/core/src/identity/identity-store.ts +175 -0
- package/packages/core/src/identity/identity-validation.ts +102 -0
- package/packages/core/src/identity/key-permissions.ts +18 -0
- package/packages/core/src/identity/pricing.ts +11 -0
- package/packages/core/src/identity/types.ts +87 -0
- package/packages/core/src/identity/usage-snapshot.ts +116 -0
- package/packages/core/src/manifest/audit-activity.ts +74 -0
- package/packages/core/src/manifest/audit-metrics.ts +7 -0
- package/packages/core/src/manifest/audit-safety.ts +113 -0
- package/packages/core/src/manifest/audit-store.ts +189 -0
- package/packages/core/src/manifest/audit-summary.ts +184 -0
- package/packages/core/src/manifest/usage-meter.ts +105 -0
- package/packages/core/src/memory/memory-extractor.ts +57 -0
- package/packages/core/src/memory/memory-graph.ts +55 -0
- package/packages/core/src/pipeline/json-string-spans.ts +143 -0
- package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
- package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
- package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
- package/packages/core/src/plugins/plugin-api.ts +96 -0
- package/packages/core/src/plugins/plugin-catalog.ts +42 -0
- package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
- package/packages/core/src/plugins/plugin-sdk.ts +47 -0
- package/packages/core/src/plugins/static-pipeline.ts +5 -0
- package/packages/core/src/profiles/profile-router.ts +45 -0
- package/packages/core/src/providers/provider-catalog.ts +186 -0
- package/packages/core/src/routing/distribution.ts +31 -0
- package/packages/core/src/security/secret-redactor.ts +139 -0
- package/packages/core/src/security/target-policy.ts +61 -0
- package/packages/core/src/storage/local-paths.ts +6 -0
- package/packages/core/src/storage/private-state.ts +30 -0
- package/packages/core/src/storage/purge-dir.ts +10 -0
- package/packages/core/src/store/retrieval-store.ts +114 -0
- package/packages/core/src/utils/hash.ts +9 -0
- package/packages/core/src/utils/text.ts +18 -0
- package/packages/core/src/utils/tokens.ts +3 -0
- package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
- package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
- package/packages/dashboard/dist/favicon.png +0 -0
- package/packages/dashboard/dist/index.html +15 -0
- package/packages/dashboard/dist/molenkopf-logo.png +0 -0
- package/packages/dashboard/public/favicon.png +0 -0
- package/packages/dashboard/public/molenkopf-logo.png +0 -0
- package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
- package/packages/plugins/context-compressor-plugin/page.html +191 -0
- package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
- package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
- package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
- package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
- package/packages/plugins/shared/audit-projects.ts +32 -0
- package/packages/proxy/src/cli/args.ts +34 -0
- package/packages/proxy/src/cli/config-loader.ts +43 -0
- package/packages/proxy/src/cli/env-file.ts +43 -0
- package/packages/proxy/src/cli/main.ts +132 -0
- package/packages/proxy/src/cli/profile-server.ts +176 -0
- package/packages/proxy/src/cli/target.ts +7 -0
- package/packages/proxy/src/http/agent-drafts.ts +103 -0
- package/packages/proxy/src/http/agent-router.ts +69 -0
- package/packages/proxy/src/http/audit-view.ts +15 -0
- package/packages/proxy/src/http/auth-state.ts +44 -0
- package/packages/proxy/src/http/budget-gate.ts +45 -0
- package/packages/proxy/src/http/budget-warnings.ts +7 -0
- package/packages/proxy/src/http/cli-stream-response.ts +51 -0
- package/packages/proxy/src/http/client-identity.ts +51 -0
- package/packages/proxy/src/http/communication-graph.ts +139 -0
- package/packages/proxy/src/http/control-plane-guard.ts +56 -0
- package/packages/proxy/src/http/dashboard-assets.ts +115 -0
- package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
- package/packages/proxy/src/http/header-utils.ts +65 -0
- package/packages/proxy/src/http/identity-id.ts +11 -0
- package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
- package/packages/proxy/src/http/local-api-auth.ts +120 -0
- package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
- package/packages/proxy/src/http/local-api-identity.ts +194 -0
- package/packages/proxy/src/http/local-api-io.ts +82 -0
- package/packages/proxy/src/http/local-api-keys.ts +126 -0
- package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
- package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
- package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
- package/packages/proxy/src/http/local-api-retention.ts +28 -0
- package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
- package/packages/proxy/src/http/local-api-scope.ts +47 -0
- package/packages/proxy/src/http/local-api-state.ts +180 -0
- package/packages/proxy/src/http/local-api.ts +166 -0
- package/packages/proxy/src/http/password-policy.ts +5 -0
- package/packages/proxy/src/http/plugin-data.ts +38 -0
- package/packages/proxy/src/http/plugin-host.ts +87 -0
- package/packages/proxy/src/http/plugin-modules.ts +1 -0
- package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
- package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
- package/packages/proxy/src/http/provider-access.ts +33 -0
- package/packages/proxy/src/http/provider-http-test.ts +133 -0
- package/packages/proxy/src/http/provider-input.ts +39 -0
- package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
- package/packages/proxy/src/http/provider-test.ts +149 -0
- package/packages/proxy/src/http/proxy-identity.ts +78 -0
- package/packages/proxy/src/http/public-bind.ts +8 -0
- package/packages/proxy/src/http/request-finish.ts +62 -0
- package/packages/proxy/src/http/request-path.ts +8 -0
- package/packages/proxy/src/http/request-policy.ts +46 -0
- package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
- package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
- package/packages/proxy/src/http/runtime-settings.ts +199 -0
- package/packages/proxy/src/http/runtime-state.ts +198 -0
- package/packages/proxy/src/http/server-io.ts +80 -0
- package/packages/proxy/src/http/server-types.ts +17 -0
- package/packages/proxy/src/http/server.ts +190 -0
- package/packages/proxy/src/http/session-secret.ts +19 -0
- package/packages/proxy/src/http/streaming-proxy.ts +88 -0
- package/packages/proxy/src/http/usage-accounting.ts +100 -0
- package/packages/proxy/src/http/usage-restore.ts +15 -0
- package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
- package/packages/proxy/src/runtime/cli-env.ts +22 -0
- package/packages/proxy/src/runtime/cli-executor.ts +134 -0
- package/packages/proxy/src/runtime/cli-provider.ts +162 -0
- package/packages/proxy/src/runtime/cli-request.ts +79 -0
- package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
- package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { compressContext } from "../../../core/src/compression/context-compressor.ts";
|
|
4
|
+
import { AuditStore } from "../../../core/src/manifest/audit-store.ts";
|
|
5
|
+
import { RetrievalStore } from "../../../core/src/store/retrieval-store.ts";
|
|
6
|
+
import { startProxy } from "../http/server.ts";
|
|
7
|
+
import { parseArgs, type CliArgs } from "./args.ts";
|
|
8
|
+
import { loadProxyConfig } from "./config-loader.ts";
|
|
9
|
+
import { loadDefaultEnvFile, loadEnvFile } from "./env-file.ts";
|
|
10
|
+
import { resolveCliTarget } from "./target.ts";
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
const args = parseArgs(process.argv.slice(2));
|
|
14
|
+
if (args.command === "--help" || args.command === "help" || args.flags.has("help")) return usage(0);
|
|
15
|
+
if (args.command === "proxy") return proxy(args);
|
|
16
|
+
if (args.command === "compress-file") return compressFile(args);
|
|
17
|
+
if (args.command === "retrieve") return retrieve(args);
|
|
18
|
+
if (args.command === "inspect") return inspect(args);
|
|
19
|
+
if (args.command === "self-test") return selfTest();
|
|
20
|
+
usage(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function proxy(args: CliArgs) {
|
|
24
|
+
const envFile = args.flags.get("env-file");
|
|
25
|
+
if (typeof envFile === "string") await loadEnvFile(envFile);
|
|
26
|
+
loadDefaultEnvFile();
|
|
27
|
+
const loaded = await loadProxyConfig(args.flags);
|
|
28
|
+
const config = loaded.config;
|
|
29
|
+
const explicitConfig = loaded.source === "file" && Boolean(config);
|
|
30
|
+
const target = targetValue(args.flags, config?.target);
|
|
31
|
+
const port = Number(args.flags.get("port") ?? config?.server.port ?? 8787);
|
|
32
|
+
const host = String(args.flags.get("host") ?? config?.server.bindHost ?? "127.0.0.1");
|
|
33
|
+
const allowPublicBind = args.flags.has("allow-public-bind") || config?.server.allowPublicBind === true;
|
|
34
|
+
const dataDir = stringFlag(args.flags, "data-dir") ?? config?.server.dataDir;
|
|
35
|
+
if (host !== "127.0.0.1" && allowPublicBind) console.warn("warning: public bind enabled");
|
|
36
|
+
let running: Awaited<ReturnType<typeof startProxy>>;
|
|
37
|
+
try {
|
|
38
|
+
running = await startProxy({
|
|
39
|
+
target,
|
|
40
|
+
port,
|
|
41
|
+
host,
|
|
42
|
+
allowPublicBind,
|
|
43
|
+
dataDir,
|
|
44
|
+
providers: config?.providers,
|
|
45
|
+
activeProviderId: config?.activeProviderId,
|
|
46
|
+
configAgents: config?.agents,
|
|
47
|
+
providerCatalogMode: explicitConfig ? "explicit" : "auto",
|
|
48
|
+
configSource: { kind: loaded.source, path: displayConfigPath(loaded.configPath) }
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isAddressInUse(error) && await isMolenkopfRunning(host, port)) {
|
|
52
|
+
console.log(`Molenkopf proxy already running on http://${host}:${port}`);
|
|
53
|
+
console.log(`Dashboard: http://${host}:${port}/__molenkopf/dashboard`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isAddressInUse(error)) throw new Error(`port already in use: ${host}:${port}; stop that process or pass --port <free-port>`);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
console.log(`Molenkopf proxy listening on http://${host}:${running.port}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function compressFile(args: CliArgs) {
|
|
63
|
+
const file = args.values[0];
|
|
64
|
+
if (!file) throw new Error("compress-file requires a file path");
|
|
65
|
+
const text = await readFile(file, "utf8");
|
|
66
|
+
const store = new RetrievalStore();
|
|
67
|
+
const result = await compressContext(text, store);
|
|
68
|
+
console.log(result.text);
|
|
69
|
+
if (result.retrievalId) console.log(`\nretrieve: ${result.retrievalId}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function retrieve(args: CliArgs) {
|
|
73
|
+
const id = args.values[0];
|
|
74
|
+
if (!id) throw new Error("retrieve requires a retrieval id");
|
|
75
|
+
console.log(await new RetrievalStore().retrieve(id));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function inspect(args: CliArgs) {
|
|
79
|
+
if (!args.flags.has("last")) throw new Error("inspect currently supports --last");
|
|
80
|
+
const latest = await new AuditStore().latest();
|
|
81
|
+
console.log(JSON.stringify(latest ?? {}, null, 2));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function selfTest() {
|
|
85
|
+
const sample = Array.from({ length: 260 }, (_, i) => `line ${i}`).join("\n") + "\nERROR sample";
|
|
86
|
+
const result = await compressContext(sample, new RetrievalStore());
|
|
87
|
+
if (!result.compressed || !result.retrievalId) throw new Error("compression self-test failed");
|
|
88
|
+
const original = await new RetrievalStore().retrieve(result.retrievalId);
|
|
89
|
+
if (!original.includes("Context excerpt only") || !original.includes("TRUNCATED_CONTEXT")) throw new Error("retrieval self-test failed");
|
|
90
|
+
if (original.includes("ERROR sample")) throw new Error("retrieval self-test persisted a raw tail");
|
|
91
|
+
console.log("self-test ok");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function usage(code: number): never {
|
|
95
|
+
console.error("usage: proxy [--env-file FILE] [--config FILE]|compress-file|retrieve|inspect|self-test");
|
|
96
|
+
process.exit(code);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function targetValue(flags: Map<string, string | boolean>, configTarget?: string): string {
|
|
100
|
+
const explicit = stringFlag(flags, "target");
|
|
101
|
+
return explicit ?? configTarget ?? resolveCliTarget(flags);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stringFlag(flags: Map<string, string | boolean>, name: string): string | undefined {
|
|
105
|
+
const value = flags.get(name);
|
|
106
|
+
return typeof value === "string" && value ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function displayConfigPath(path: string | undefined): string | undefined {
|
|
110
|
+
if (!path) return undefined;
|
|
111
|
+
const rel = relative(process.cwd(), path);
|
|
112
|
+
return rel && !rel.startsWith("..") && !rel.includes(":") ? rel : path;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAddressInUse(error: unknown): boolean {
|
|
116
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function isMolenkopfRunning(host: string, port: number): Promise<boolean> {
|
|
120
|
+
const healthHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`http://${healthHost}:${port}/__molenkopf/health`, { signal: AbortSignal.timeout(800) });
|
|
123
|
+
return response.ok && (await response.json()).ok === true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
main().catch((error) => {
|
|
130
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { ensurePrivateDirSync } from "../../../core/src/storage/private-state.ts";
|
|
6
|
+
import { validateProviderTarget } from "../../../core/src/security/target-policy.ts";
|
|
7
|
+
import { loadDefaultEnvFile } from "./env-file.ts";
|
|
8
|
+
|
|
9
|
+
const WATCH_DIRS = ["packages/core/src", "packages/proxy/src"];
|
|
10
|
+
const RESTART_DEBOUNCE_MS = 250;
|
|
11
|
+
const FORCE_KILL_MS = 1500;
|
|
12
|
+
const DASHBOARD_DEV_PORT = 5173;
|
|
13
|
+
|
|
14
|
+
const PROFILE_DEFAULTS = {
|
|
15
|
+
dev: { port: 8787, dataDir: ".molenkopf/dev" },
|
|
16
|
+
test: { port: 8798, dataDir: ".molenkopf/test" },
|
|
17
|
+
prod: { port: 8787, dataDir: ".molenkopf/prod" }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ProfileName = keyof typeof PROFILE_DEFAULTS;
|
|
21
|
+
type Profile = { name: ProfileName; port: number; host: string; target: string; dataDir: string };
|
|
22
|
+
|
|
23
|
+
export function resolveProfile(name: string, env: Record<string, string | undefined> = process.env): Profile {
|
|
24
|
+
if (!isProfileName(name)) throw new Error("profile must be one of: dev, test, prod");
|
|
25
|
+
const upper = name.toUpperCase();
|
|
26
|
+
const base = PROFILE_DEFAULTS[name];
|
|
27
|
+
const port = parsePort(env[`MOLENKOPF_${upper}_PORT`] || String(base.port));
|
|
28
|
+
const dataDir = env[`MOLENKOPF_${upper}_DATA_DIR`] || base.dataDir;
|
|
29
|
+
const target = env[`MOLENKOPF_${upper}_TARGET`] || env.MOLENKOPF_TARGET || "https://api.openai.com/v1";
|
|
30
|
+
const host = env[`MOLENKOPF_${upper}_HOST`] || "127.0.0.1";
|
|
31
|
+
validateHost(host);
|
|
32
|
+
validateTarget(target);
|
|
33
|
+
if (!dataDir.trim()) throw new Error("dataDir must not be empty");
|
|
34
|
+
return { name, port, host, target, dataDir: resolve(dataDir) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function proxyArgs(profile: Profile) {
|
|
38
|
+
return [
|
|
39
|
+
"--experimental-strip-types", "--experimental-sqlite", "--disable-warning=ExperimentalWarning",
|
|
40
|
+
"packages/proxy/src/cli/main.ts", "proxy",
|
|
41
|
+
"--target", profile.target, "--host", profile.host,
|
|
42
|
+
"--port", String(profile.port), "--data-dir", profile.dataDir
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function devWatchEnabled(profile: Profile, env: Record<string, string | undefined> = process.env) {
|
|
47
|
+
return profile.name === "dev" && env.MOLENKOPF_DEV_WATCH !== "0";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function spawnProxy(profile: Profile, revision?: string) {
|
|
51
|
+
const env: NodeJS.ProcessEnv = { ...process.env, MOLENKOPF_PROFILE: profile.name };
|
|
52
|
+
if (revision) env.MOLENKOPF_DEV_REVISION = revision;
|
|
53
|
+
if (profile.name === "dev" && dashboardDevEnabled()) env.MOLENKOPF_DASHBOARD_DEV_ORIGIN = dashboardDevOrigin(env);
|
|
54
|
+
return spawnLogged(process.execPath, proxyArgs(profile), { env });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function run() {
|
|
58
|
+
loadDefaultEnvFile();
|
|
59
|
+
const profile = resolveProfile(process.argv[2] || "dev");
|
|
60
|
+
ensurePrivateDirSync(profile.dataDir);
|
|
61
|
+
console.log(`Molenkopf ${profile.name}: http://${profile.host}:${profile.port}`);
|
|
62
|
+
console.log(`data-dir: ${profile.dataDir}`);
|
|
63
|
+
if (devWatchEnabled(profile)) return runWatched(profile);
|
|
64
|
+
const child = spawnProxy(profile);
|
|
65
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runWatched(profile: Profile) {
|
|
69
|
+
const watchers: ReturnType<typeof watch>[] = [];
|
|
70
|
+
let child: ReturnType<typeof spawnProxy>;
|
|
71
|
+
const dashboard = dashboardDevEnabled() ? spawnDashboardDev() : undefined;
|
|
72
|
+
let revision = String(Date.now());
|
|
73
|
+
let restartTimer: ReturnType<typeof setTimeout>;
|
|
74
|
+
let restarting = false;
|
|
75
|
+
|
|
76
|
+
const startChild = () => {
|
|
77
|
+
console.log(`dev-revision: ${revision}`);
|
|
78
|
+
child = spawnProxy(profile, revision);
|
|
79
|
+
child.on("exit", (code, signal) => {
|
|
80
|
+
if (restarting) { restarting = false; startChild(); return; }
|
|
81
|
+
closeWatchers(watchers);
|
|
82
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const scheduleRestart = (reason: string) => {
|
|
87
|
+
if (restarting) return;
|
|
88
|
+
clearTimeout(restartTimer);
|
|
89
|
+
restartTimer = setTimeout(() => {
|
|
90
|
+
revision = String(Date.now());
|
|
91
|
+
console.log(`Dev change: ${reason}; restarting Molenkopf...`);
|
|
92
|
+
restarting = true;
|
|
93
|
+
let exited = false;
|
|
94
|
+
child.once("exit", () => { exited = true; });
|
|
95
|
+
child.kill();
|
|
96
|
+
const forceKill = setTimeout(() => { if (!exited) child.kill("SIGKILL"); }, FORCE_KILL_MS);
|
|
97
|
+
forceKill.unref?.();
|
|
98
|
+
}, RESTART_DEBOUNCE_MS);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
startChild();
|
|
102
|
+
for (const dir of WATCH_DIRS) {
|
|
103
|
+
try {
|
|
104
|
+
const root = resolve(dir);
|
|
105
|
+
watchers.push(watch(root, { recursive: true }, (_event, file) => scheduleRestart(file ? join(dir, String(file)) : dir)));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.warn(`Dev watch disabled for ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const stop = (code: number) => {
|
|
112
|
+
closeWatchers(watchers);
|
|
113
|
+
child.kill();
|
|
114
|
+
if (dashboard) dashboard.kill();
|
|
115
|
+
process.exit(code);
|
|
116
|
+
};
|
|
117
|
+
process.on("SIGINT", () => stop(130));
|
|
118
|
+
process.on("SIGTERM", () => stop(143));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function dashboardDevEnabled(env = process.env) {
|
|
122
|
+
return env.MOLENKOPF_DASHBOARD_DEV !== "0";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dashboardDevOrigin(env = process.env) {
|
|
126
|
+
const port = Number(env.MOLENKOPF_DASHBOARD_DEV_PORT || DASHBOARD_DEV_PORT);
|
|
127
|
+
return `http://127.0.0.1:${port}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function spawnDashboardDev() {
|
|
131
|
+
const command = process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "npm";
|
|
132
|
+
const args = process.platform === "win32" ? ["/d", "/s", "/c", "npm --prefix packages/dashboard run dev"] : ["--prefix", "packages/dashboard", "run", "dev"];
|
|
133
|
+
const profile = resolveProfile(process.argv[2] || "dev");
|
|
134
|
+
return spawnLogged(command, args, {
|
|
135
|
+
env: {
|
|
136
|
+
...process.env,
|
|
137
|
+
MOLENKOPF_DASHBOARD_DEV_PORT: String(Number(process.env.MOLENKOPF_DASHBOARD_DEV_PORT || DASHBOARD_DEV_PORT)),
|
|
138
|
+
MOLENKOPF_DASHBOARD_API_ORIGIN: `http://${profile.host}:${profile.port}`
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function closeWatchers(watchers: ReturnType<typeof watch>[]) {
|
|
144
|
+
for (const watcher of watchers) watcher.close();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function spawnLogged(command: string, args: string[], options: { env: NodeJS.ProcessEnv }) {
|
|
148
|
+
const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
149
|
+
child.stdout?.pipe(process.stdout);
|
|
150
|
+
child.stderr?.pipe(process.stderr);
|
|
151
|
+
return child;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isProfileName(name: string): name is ProfileName {
|
|
155
|
+
return name === "dev" || name === "test" || name === "prod";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parsePort(value: string): number {
|
|
159
|
+
const port = Number(value);
|
|
160
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("invalid profile port");
|
|
161
|
+
return port;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateHost(value: string): void {
|
|
165
|
+
if (!value.trim() || /[/:?#]/.test(value)) throw new Error("invalid profile host");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateTarget(value: string): void {
|
|
169
|
+
try {
|
|
170
|
+
validateProviderTarget(value, { path: "profile target", allowPrivate: true });
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error("invalid profile target");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) run();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function resolveCliTarget(flags: Map<string, string | boolean>, env: Record<string, string | undefined> = process.env): string {
|
|
2
|
+
const explicit = flags.get("target");
|
|
3
|
+
if (typeof explicit === "string" && explicit) return explicit;
|
|
4
|
+
if (env.ANTHROPIC_BASE_URL) return env.ANTHROPIC_BASE_URL;
|
|
5
|
+
if (env.OPENAI_BASE_URL) return env.OPENAI_BASE_URL;
|
|
6
|
+
return "https://api.openai.com/v1";
|
|
7
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { pluginCatalog } from "../../../core/src/plugins/plugin-catalog.ts";
|
|
2
|
+
import { clientIdForAgent } from "./client-identity.ts";
|
|
3
|
+
import { CONTROL_PLANE_LIMITS, emptyUsage, enabledPluginIds, type AgentDraftMetadata, type AgentDraftView, type RuntimeState, type RuntimeStateResult } from "./runtime-state.ts";
|
|
4
|
+
|
|
5
|
+
export function listAgentDrafts(state: RuntimeState): AgentDraftView[] {
|
|
6
|
+
return state.agentDrafts.slice(0, CONTROL_PLANE_LIMITS.agentDrafts).map((draft) => viewDraft(draft, state));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function upsertAgentDraft(state: RuntimeState, input: Record<string, unknown>, now = new Date().toISOString()): RuntimeStateResult<AgentDraftView> {
|
|
10
|
+
const rawField = rawCredentialField(input);
|
|
11
|
+
if (rawField) return fail(400, "raw_token_rejected", `${rawField} must be sent as tokenHash`);
|
|
12
|
+
|
|
13
|
+
const id = cleanId(input.id);
|
|
14
|
+
if (!id) return fail(400, "invalid_agent_id");
|
|
15
|
+
const existingIndex = state.agentDrafts.findIndex((item) => item.id === id);
|
|
16
|
+
const existing = existingIndex >= 0 ? state.agentDrafts[existingIndex] : undefined;
|
|
17
|
+
if (!existing && state.agentDrafts.length >= CONTROL_PLANE_LIMITS.agentDrafts) return fail(409, "agent_draft_limit");
|
|
18
|
+
|
|
19
|
+
const label = cleanLabel(input.label ?? input.name ?? existing?.label ?? id);
|
|
20
|
+
if (!label) return fail(400, "invalid_agent_label");
|
|
21
|
+
const kind = cleanAgentKind(input.kind ?? existing?.kind ?? "CI agent");
|
|
22
|
+
const providerId = cleanId(input.providerId ?? existing?.providerId ?? state.activeProviderId);
|
|
23
|
+
const provider = providerId ? state.providers.find((item) => item.id === providerId) : undefined;
|
|
24
|
+
if (!provider) return fail(404, "unknown_provider");
|
|
25
|
+
if (provider.enabled === false) return fail(409, "provider_disabled");
|
|
26
|
+
|
|
27
|
+
const plugins = normalizePluginIds(input.enabledPluginIds ?? input.pluginIds, state, existing);
|
|
28
|
+
if (plugins.ok === false) return plugins;
|
|
29
|
+
const tokenHash = normalizeTokenHash(input, existing);
|
|
30
|
+
if (tokenHash.ok === false) return tokenHash;
|
|
31
|
+
|
|
32
|
+
let tokenLimit = existing?.tokenLimit;
|
|
33
|
+
if (input.tokenLimit !== undefined) {
|
|
34
|
+
if (input.tokenLimit === null || input.tokenLimit === 0) tokenLimit = undefined;
|
|
35
|
+
else if (typeof input.tokenLimit === "number" && Number.isInteger(input.tokenLimit) && input.tokenLimit > 0) tokenLimit = input.tokenLimit;
|
|
36
|
+
else return fail(400, "invalid_token_limit");
|
|
37
|
+
}
|
|
38
|
+
const disabled = typeof input.disabled === "boolean" ? input.disabled : existing?.disabled;
|
|
39
|
+
|
|
40
|
+
const draft: AgentDraftMetadata = { id, label, kind, providerId: provider.id, enabledPluginIds: plugins.value, status: "draft", createdAt: existing?.createdAt ?? now, updatedAt: now };
|
|
41
|
+
if (tokenHash.value) {
|
|
42
|
+
draft.tokenHash = tokenHash.value;
|
|
43
|
+
draft.tokenHashAlgorithm = "sha256";
|
|
44
|
+
}
|
|
45
|
+
if (disabled) draft.disabled = true;
|
|
46
|
+
if (tokenLimit) draft.tokenLimit = tokenLimit;
|
|
47
|
+
if (existingIndex >= 0) state.agentDrafts[existingIndex] = draft;
|
|
48
|
+
else state.agentDrafts.push(draft);
|
|
49
|
+
return { ok: true, value: viewDraft(draft, state) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizePluginIds(input: unknown, state: RuntimeState, existing?: AgentDraftMetadata): RuntimeStateResult<string[]> {
|
|
53
|
+
if (input === undefined) return { ok: true, value: existing?.enabledPluginIds ?? enabledPluginIds(state) };
|
|
54
|
+
if (!Array.isArray(input) || input.length > CONTROL_PLANE_LIMITS.pluginIds) return fail(400, "invalid_plugin_ids");
|
|
55
|
+
const known = new Set(pluginCatalog.map((plugin) => plugin.id));
|
|
56
|
+
const ids = [...new Set(input.map(cleanId))];
|
|
57
|
+
if (ids.some((id) => !id)) return fail(400, "invalid_plugin_id");
|
|
58
|
+
const unknown = ids.find((id) => !known.has(id as string));
|
|
59
|
+
if (unknown) return fail(404, "unknown_plugin", unknown);
|
|
60
|
+
return { ok: true, value: ids as string[] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeTokenHash(input: Record<string, unknown>, existing?: AgentDraftMetadata): RuntimeStateResult<string | undefined> {
|
|
64
|
+
if (input.tokenHash === undefined) return { ok: true, value: existing?.tokenHash };
|
|
65
|
+
if (input.tokenHash === null || input.tokenHash === "") return { ok: true, value: undefined };
|
|
66
|
+
if (input.tokenHashAlgorithm !== undefined && input.tokenHashAlgorithm !== "sha256") return fail(400, "unsupported_token_hash");
|
|
67
|
+
if (typeof input.tokenHash !== "string" || !/^(?:sha256:)?[a-f0-9]{64}$/i.test(input.tokenHash)) return fail(400, "invalid_token_hash");
|
|
68
|
+
const hash = input.tokenHash.toLowerCase();
|
|
69
|
+
return { ok: true, value: hash.startsWith("sha256:") ? hash : `sha256:${hash}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cleanId(value: unknown): string | undefined {
|
|
73
|
+
if (typeof value !== "string") return undefined;
|
|
74
|
+
const id = value.trim();
|
|
75
|
+
return id.length <= CONTROL_PLANE_LIMITS.idLength && /^[a-z0-9][a-z0-9._:-]*$/i.test(id) ? id : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cleanLabel(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value !== "string") return undefined;
|
|
80
|
+
const label = value.trim().replace(/\s+/g, " ");
|
|
81
|
+
return label && label.length <= CONTROL_PLANE_LIMITS.labelLength ? label : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanAgentKind(value: unknown): AgentDraftMetadata["kind"] {
|
|
85
|
+
return value === "Local agent" || value === "External agent" ? value : "CI agent";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rawCredentialField(input: Record<string, unknown>): string | undefined {
|
|
89
|
+
const forbidden = new Set(["token", "apikey", "api_key", "secret", "credential", "password", "authorization"]);
|
|
90
|
+
return Object.keys(input).find((key) => forbidden.has(key.toLowerCase()));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function viewDraft(draft: AgentDraftMetadata, state: RuntimeState): AgentDraftView {
|
|
94
|
+
const { tokenHash, ...rest } = draft;
|
|
95
|
+
const usage = state.usageByAgent[clientIdForAgent(draft.id)] ?? emptyUsage();
|
|
96
|
+
const view: AgentDraftView = { ...rest, enabledPluginIds: [...draft.enabledPluginIds], tokenHashPresent: Boolean(tokenHash), usage: { ...usage } };
|
|
97
|
+
if (tokenHash) view.tokenFingerprint = tokenHash.slice(0, 15);
|
|
98
|
+
return view;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fail(status: number, error: string, reason?: string): RuntimeStateResult<never> {
|
|
102
|
+
return { ok: false, status, error, reason };
|
|
103
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
2
|
+
import { chooseByDistribution } from "../../../core/src/routing/distribution.ts";
|
|
3
|
+
import { activeProvider, agentTokensUsed, distributionEligible, providerWeight, type RuntimeState } from "./runtime-state.ts";
|
|
4
|
+
import { clientIdForAgent, type ClientIdentity } from "./client-identity.ts";
|
|
5
|
+
import { providerAllowedForClient } from "./provider-access.ts";
|
|
6
|
+
|
|
7
|
+
export type RoutingResult = { ok: true; provider: ProviderConfig } | { ok: false; status: number; error: string };
|
|
8
|
+
|
|
9
|
+
// Resolves the upstream provider for a request and enforces agent access/budget.
|
|
10
|
+
// Priority: explicit agent binding -> distribution mode -> global active provider.
|
|
11
|
+
export function resolveRouting(state: RuntimeState, headers: Headers, client: ClientIdentity): RoutingResult {
|
|
12
|
+
const budget = state.consumerBudgets[client.id];
|
|
13
|
+
if (budget && agentTokensUsed(state, client.id) >= budget) return { ok: false, status: 429, error: "consumer_budget" };
|
|
14
|
+
const agentId = cleanAgentId(headers.get("x-molenkopf-agent"));
|
|
15
|
+
if (agentId) {
|
|
16
|
+
const draft = state.agentDrafts.find((item) => item.id === agentId);
|
|
17
|
+
const agentUsageId = clientIdForAgent(agentId);
|
|
18
|
+
if (draft?.disabled) return { ok: false, status: 403, error: "agent_disabled" };
|
|
19
|
+
if (draft?.tokenLimit && agentTokensUsed(state, agentUsageId) >= draft.tokenLimit) {
|
|
20
|
+
return { ok: false, status: 429, error: "agent_token_limit" };
|
|
21
|
+
}
|
|
22
|
+
const configAgent = state.configAgents.find((item) => item.id === agentId);
|
|
23
|
+
if (configAgent?.enabled === false) return { ok: false, status: 403, error: "agent_disabled" };
|
|
24
|
+
if (configAgent && (client.source !== "api_key" || client.keyAgentLabel !== agentId)) return { ok: false, status: 403, error: "agent_forbidden" };
|
|
25
|
+
const pinned = draft?.providerId ?? configAgent?.providerId;
|
|
26
|
+
if (pinned) {
|
|
27
|
+
const provider = enabledProvider(state, pinned);
|
|
28
|
+
if (provider) return requireProviderAccess(client, provider);
|
|
29
|
+
return { ok: false, status: 409, error: "provider_unavailable" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (state.routingMode === "distribute") {
|
|
33
|
+
const distributed = distribute(state, client);
|
|
34
|
+
if (distributed) return { ok: true, provider: distributed };
|
|
35
|
+
return { ok: false, status: 409, error: "no_eligible_provider" };
|
|
36
|
+
}
|
|
37
|
+
return requireProviderAccess(client, activeProvider(state));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function distribute(state: RuntimeState, client: ClientIdentity): ProviderConfig | undefined {
|
|
41
|
+
const eligible = state.providers.filter((provider) => distributionEligible(provider) && providerAllowedForClient(client, provider.id));
|
|
42
|
+
const shares = eligible.map((provider) => ({
|
|
43
|
+
id: provider.id,
|
|
44
|
+
weight: providerWeight(state, provider.id),
|
|
45
|
+
usedTokens: tokensFor(state, provider.id)
|
|
46
|
+
}));
|
|
47
|
+
const chosen = chooseByDistribution(shares);
|
|
48
|
+
return eligible.find((provider) => provider.id === chosen);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tokensFor(state: RuntimeState, id: string): number {
|
|
52
|
+
const usage = state.usageByProvider[id];
|
|
53
|
+
return usage ? usage.inputTokens + usage.outputTokens : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function enabledProvider(state: RuntimeState, id: string): ProviderConfig | undefined {
|
|
57
|
+
return state.providers.find((item) => item.id === id && item.enabled !== false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requireProviderAccess(client: ClientIdentity, provider: ProviderConfig): RoutingResult {
|
|
61
|
+
if (!providerAllowedForClient(client, provider.id)) return { ok: false, status: 403, error: "provider_forbidden" };
|
|
62
|
+
return { ok: true, provider };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cleanAgentId(value: string | null): string | undefined {
|
|
66
|
+
if (typeof value !== "string") return undefined;
|
|
67
|
+
const id = value.trim();
|
|
68
|
+
return /^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(id) ? id : undefined;
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AuditManifest } from "../../../core/src/manifest/audit-store.ts";
|
|
2
|
+
import { auditPath } from "./request-path.ts";
|
|
3
|
+
|
|
4
|
+
export function auditView(manifest: AuditManifest): AuditManifest {
|
|
5
|
+
return {
|
|
6
|
+
...manifest,
|
|
7
|
+
path: auditPath(manifest.path),
|
|
8
|
+
retrievalIds: manifest.retrievalIds.slice(0, 10),
|
|
9
|
+
warnings: manifest.warnings.slice(0, 10)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function auditViews(manifests: AuditManifest[]): AuditManifest[] {
|
|
14
|
+
return manifests.map(auditView);
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { verifySessionPayload } from "../../../core/src/auth/session.ts";
|
|
2
|
+
import type { User } from "../../../core/src/identity/types.ts";
|
|
3
|
+
import type { RuntimeState } from "./runtime-state.ts";
|
|
4
|
+
|
|
5
|
+
// Auth lives on the Identity store (users/teams). With no password-bearing user,
|
|
6
|
+
// Molenkopf stays in first-run mode until the browser flow creates an admin.
|
|
7
|
+
|
|
8
|
+
export type AuthUser = User;
|
|
9
|
+
|
|
10
|
+
export function authRequired(state: RuntimeState): boolean {
|
|
11
|
+
const users = state.identity?.data.users ?? {};
|
|
12
|
+
return Object.values(users).some((u) => Boolean(u.password?.hash));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function currentUser(state: RuntimeState, cookieHeader: string | null): User | undefined {
|
|
16
|
+
const token = readCookie(cookieHeader, "molenkopf_session");
|
|
17
|
+
const session = verifySessionPayload(token, state.sessionSecret);
|
|
18
|
+
const user = session ? state.identity?.getUser(session.userId) : undefined;
|
|
19
|
+
if (user && (user.sessionVersion ?? 0) !== session?.sessionVersion) return undefined;
|
|
20
|
+
return user && !user.disabled ? user : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function canManage(_state: RuntimeState, user: User | undefined): boolean {
|
|
24
|
+
return Boolean(user && user.role === "admin");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function providerAllowed(state: RuntimeState, user: User | undefined, providerId: string): boolean {
|
|
28
|
+
if (!user || user.role === "admin") return true;
|
|
29
|
+
return (user.teamIds ?? []).some((id) => {
|
|
30
|
+
const allowed = state.identity?.getTeam(id)?.allowedProviders;
|
|
31
|
+
return allowed === "*" || (Array.isArray(allowed) && allowed.includes(providerId));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readCookie(header: string | null, name: string): string | undefined {
|
|
36
|
+
if (!header) return undefined;
|
|
37
|
+
for (const part of header.split(";")) {
|
|
38
|
+
const [k, ...v] = part.trim().split("=");
|
|
39
|
+
if (k === name) {
|
|
40
|
+
try { return decodeURIComponent(v.join("=")); } catch { return undefined; }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Budget } from "../../../core/src/identity/types.ts";
|
|
2
|
+
import { keyCostUsed, keyTokensUsed, orgCostUsed, orgTokensUsed, teamCostUsed, teamTokensUsed, userCostUsed, userTokensUsed, type RuntimeState } from "./runtime-state.ts";
|
|
3
|
+
import type { ClientIdentity } from "./client-identity.ts";
|
|
4
|
+
|
|
5
|
+
// Hierarchical budget enforcement: key -> user -> team(s) -> org. The first
|
|
6
|
+
// exceeded tier with onExceed="block" wins (429). "warn" tiers are reported but
|
|
7
|
+
// allowed. Periodic budgets read only the active UTC usage bucket.
|
|
8
|
+
|
|
9
|
+
export type BudgetCheck =
|
|
10
|
+
| { ok: true; warnings: string[] }
|
|
11
|
+
| { ok: false; status: 429; error: string; tier: string; scopeId: string; metric: "tokens" | "cost" };
|
|
12
|
+
|
|
13
|
+
export function checkBudgets(state: RuntimeState, client: ClientIdentity, now = new Date()): BudgetCheck {
|
|
14
|
+
const identity = state.identity;
|
|
15
|
+
if (!identity) return { ok: true, warnings: [] };
|
|
16
|
+
const warnings: string[] = [];
|
|
17
|
+
|
|
18
|
+
const tiers: { tier: string; scopeId: string; budget?: Budget; tokens: number; cost: number }[] = [];
|
|
19
|
+
const keyBudget = client.keyId ? identity.data.keys[client.keyId]?.budget : undefined;
|
|
20
|
+
if (client.keyId) tiers.push({ tier: "key", scopeId: client.keyId, budget: keyBudget, tokens: keyTokensUsed(state, client.keyId, keyBudget?.period, now), cost: keyCostUsed(state, client.keyId, keyBudget?.period, now) });
|
|
21
|
+
const userBudget = client.userId ? identity.getUser(client.userId)?.budget : undefined;
|
|
22
|
+
if (client.userId) tiers.push({ tier: "user", scopeId: client.userId, budget: userBudget, tokens: userTokensUsed(state, client.userId, userBudget?.period, now), cost: userCostUsed(state, client.userId, userBudget?.period, now) });
|
|
23
|
+
for (const teamId of client.teamIds ?? []) {
|
|
24
|
+
const budget = identity.getTeam(teamId)?.budget;
|
|
25
|
+
tiers.push({ tier: "team", scopeId: teamId, budget, tokens: teamTokensUsed(state, teamId, budget?.period, now), cost: teamCostUsed(state, teamId, budget?.period, now) });
|
|
26
|
+
}
|
|
27
|
+
const orgBudget = identity.data.orgBudget;
|
|
28
|
+
tiers.push({ tier: "org", scopeId: "org", budget: orgBudget, tokens: orgTokensUsed(state, orgBudget?.period, now), cost: orgCostUsed(state, orgBudget?.period, now) });
|
|
29
|
+
|
|
30
|
+
for (const t of tiers) {
|
|
31
|
+
const metric = exceededMetric(t);
|
|
32
|
+
if (!metric) continue;
|
|
33
|
+
if (t.budget?.onExceed === "warn") { warnings.push(`${t.tier}:${t.scopeId} over ${metric} budget`); continue; }
|
|
34
|
+
return { ok: false, status: 429, error: `budget_exceeded_${t.tier}`, tier: t.tier, scopeId: t.scopeId, metric };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, warnings };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function exceededMetric(t: { budget?: Budget; tokens: number; cost: number }): "tokens" | "cost" | undefined {
|
|
40
|
+
const tokenLimit = t.budget?.tokenLimit;
|
|
41
|
+
if (typeof tokenLimit === "number" && tokenLimit > 0 && t.tokens >= tokenLimit) return "tokens";
|
|
42
|
+
const costLimit = t.budget?.costLimitEur;
|
|
43
|
+
if (typeof costLimit === "number" && costLimit > 0 && t.cost >= costLimit) return "cost";
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RewriteAudit } from "../../../core/src/pipeline/openai-request-rewriter.ts";
|
|
2
|
+
|
|
3
|
+
export function withBudgetWarnings(audit: RewriteAudit | undefined, warnings: string[]): RewriteAudit | undefined {
|
|
4
|
+
if (!warnings.length) return audit;
|
|
5
|
+
const base = audit ?? { compressedItems: 0, estimatedOriginalTokens: 0, estimatedCompressedTokens: 0, estimatedSavedTokens: 0, redactedSecrets: 0, retrievalIds: [], compressorsUsed: [], warnings: [] };
|
|
6
|
+
return { ...base, warnings: [...warnings, ...(base.warnings ?? [])] };
|
|
7
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import type { ProviderConfig } from "../../../core/src/providers/provider-catalog.ts";
|
|
3
|
+
import { estimateTokens } from "../../../core/src/utils/tokens.ts";
|
|
4
|
+
import { executeCliProvider } from "../runtime/cli-executor.ts";
|
|
5
|
+
import { cliRequest } from "../runtime/cli-request.ts";
|
|
6
|
+
import {
|
|
7
|
+
isOpenAiResponses,
|
|
8
|
+
openAiResponsesStreamFailure,
|
|
9
|
+
openAiResponsesStreamOutput,
|
|
10
|
+
openAiResponsesStreamStart,
|
|
11
|
+
wantsStream
|
|
12
|
+
} from "../runtime/cli-provider.ts";
|
|
13
|
+
|
|
14
|
+
export type CliStreamResult = {
|
|
15
|
+
status: number;
|
|
16
|
+
usage?: { inputTokens?: number; outputTokens?: number };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function canStreamOpenAiCli(path: string, body: string): boolean {
|
|
20
|
+
return isOpenAiResponses(path) && wantsStream(body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function streamOpenAiCliProvider(
|
|
24
|
+
provider: ProviderConfig,
|
|
25
|
+
body: string,
|
|
26
|
+
requestId: string,
|
|
27
|
+
res: ServerResponse
|
|
28
|
+
): Promise<CliStreamResult> {
|
|
29
|
+
const request = cliRequest(body, provider);
|
|
30
|
+
res.writeHead(200, {
|
|
31
|
+
"content-type": "text/event-stream",
|
|
32
|
+
"cache-control": "no-cache",
|
|
33
|
+
connection: "keep-alive"
|
|
34
|
+
});
|
|
35
|
+
res.write(openAiResponsesStreamStart(requestId, request.responseModel));
|
|
36
|
+
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 10000);
|
|
37
|
+
keepAlive.unref?.();
|
|
38
|
+
try {
|
|
39
|
+
const output = await executeCliProvider(provider, request.prompt, request.runModel);
|
|
40
|
+
const usage = { inputTokens: estimateTokens(request.prompt), outputTokens: estimateTokens(output) };
|
|
41
|
+
res.write(openAiResponsesStreamOutput(requestId, request.responseModel, output, usage));
|
|
42
|
+
res.end("data: [DONE]\n\n");
|
|
43
|
+
return { status: 200, usage };
|
|
44
|
+
} catch {
|
|
45
|
+
res.write(openAiResponsesStreamFailure(requestId, request.responseModel));
|
|
46
|
+
res.end();
|
|
47
|
+
return { status: 502 };
|
|
48
|
+
} finally {
|
|
49
|
+
clearInterval(keepAlive);
|
|
50
|
+
}
|
|
51
|
+
}
|