cartethyia 2.5.7 → 2.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/dashboard/assets/{BannedPage-DE2607Tb.js → BannedPage-Brvuj_Eb.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-Duv6mfrl.js → CliToolDetailPage-CqfXtI3y.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-C2PNYWzk.js → CliToolsPage-GQeS91bg.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-TXP9OfAM.js → CombosPage-BDRsTXBB.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-B-9xnAQN.js → ConsoleLogPage-zP8AFHX0.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-xeax9nJt.js → CustomizationPage-BmfrNZ14.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-Df4jjaHJ.js → HealthEventsModal-Dn3M6WZc.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-Dyojsm84.js → LoginPage-DoreIgm8.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-BJfg4ow6.js → OverviewPage-CvvD1Ugk.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  19. package/dist/dashboard/assets/{ProviderDetailPage-ChCX1gmT.js → ProviderDetailPage-Clbn1gsW.js} +1 -1
  20. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-DeWSOEUO.js → ProvidersPage-BnoPgA7w.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-CkpWln2S.js → ProxyPage-Bq_TGgdb.js} +1 -1
  24. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-B3VOgQmt.js → QuotaPage-D9DHXvom.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  27. package/dist/dashboard/assets/{SettingsPage-C_klIZom.js → SettingsPage-7xV8WkWL.js} +1 -1
  28. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-BYph-InK.js → SetupPage-Day3mCOn.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-CvyQCYWJ.js → SortableList-DQxllnB5.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-Cm1JYilI.js → StudioPage-uMExoWu1.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-uMExoWu1.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-DlFnc3_S.js → UsagePage-DXa1oR-4.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-Bog0p925.js → entry-ByMGkR3C.js} +3 -3
  38. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  39. package/dist/dashboard/assets/{index-CZQIrdoX.js → index-z3zDOSMM.js} +2 -2
  40. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  41. package/dist/dashboard/assets/{select-BPpSsBgL.js → select-C6MMiaio.js} +1 -1
  42. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  43. package/dist/dashboard/index.html +1 -1
  44. package/dist/dashboard/index.html.gz +0 -0
  45. package/dist/dashboard/precompressed-assets.json +21 -21
  46. package/package.json +3 -1
  47. package/scripts/build/aot.ts +143 -0
  48. package/scripts/build/build.ts +24 -0
  49. package/scripts/build/compile.ts +88 -0
  50. package/scripts/build/restart.ts +120 -0
  51. package/scripts/build/start.ts +25 -0
  52. package/scripts/health.ts +186 -0
  53. package/src/console/backup/nine-router.ts +43 -9
  54. package/src/providers/discovery/openrouter-free-roster.ts +0 -1
  55. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
  56. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +2 -1
  57. package/src/providers/integrations/opencode/provider.ts +0 -3
  58. package/src/providers/operations/provider-credential-service.ts +29 -3
  59. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  60. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  61. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  62. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  63. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  64. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  65. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  66. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  67. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  68. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  69. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  70. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  71. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  72. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  73. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  74. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  75. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  76. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  77. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  78. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  79. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Compiles the standalone gateway binary from the AOT build output.
3
+ *
4
+ * Two things here are load-bearing and neither is obvious:
5
+ *
6
+ * 1. **The entry is `dist/main.js`, not `src/main.ts`.** `aot.ts` runs
7
+ * Elysia's AOT plugin, which rewrites TypeBox imports into statically wired
8
+ * mirrors in its output. Bundling the raw source instead discards that work,
9
+ * and Elysia's CommonJS build reaches TypeBox through a lazy
10
+ * `require("typebox/type")` that the bundler cannot follow — the compiled
11
+ * binary then dies at startup with `Cannot find module 'typebox/type'`,
12
+ * because a standalone executable has no node_modules to resolve against.
13
+ *
14
+ * 2. **`NODE_ENV` is set to `production` here, at build time, because Bun bakes
15
+ * it into the binary.** `bun build --compile` replaces `process.env.NODE_ENV`
16
+ * with a literal, so the value present when this runs is the value the
17
+ * binary reports forever after — a runtime `NODE_ENV=production` cannot
18
+ * change it. What depends on it is the logger: the `pino-pretty` transport
19
+ * is attached only in development, and that transport spawns a worker thread
20
+ * loading `real-require`, which a standalone executable does not carry. A
21
+ * binary built without this line therefore bundles a logger that crashes on
22
+ * first use. The migrations folder is not part of this decision —
23
+ * `resolveMigrationsFolder()` resolves `<cwd>/migrations` unconditionally.
24
+ *
25
+ * Usage: `bun run scripts/build/compile.ts [--outfile dist/cartethyia]`
26
+ */
27
+ const DEFAULT_OUTFILE = "dist/cartethyia";
28
+
29
+ /**
30
+ * The artifact path `bun build --compile` actually writes for `outfile`.
31
+ *
32
+ * Bun appends `.exe` when it compiles for Windows, so the extensionless
33
+ * `dist/cartethyia` handed to it never exists on that platform. This is the one
34
+ * place that knows where the compiled binary lands; `start-production.ts`
35
+ * resolves its target from here instead of re-deriving the name, because a
36
+ * launcher that probed the requested path reported "run bun run build first"
37
+ * immediately after a successful build.
38
+ */
39
+ export function compiledBinaryPath(outfile: string = DEFAULT_OUTFILE): string {
40
+ return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")
41
+ ? `${outfile}.exe`
42
+ : outfile;
43
+ }
44
+
45
+ /** Reads `--outfile <path>`, falling back to a positional path or the default. */
46
+ export function resolveOutfile(argv: readonly string[]): string {
47
+ const flag = argv.indexOf("--outfile");
48
+ if (flag !== -1) {
49
+ const value = argv[flag + 1];
50
+ if (value === undefined || value.length === 0) {
51
+ throw new Error("--outfile requires a path");
52
+ }
53
+ return value;
54
+ }
55
+ return argv.find((arg) => arg.length > 0 && !arg.startsWith("-")) ?? DEFAULT_OUTFILE;
56
+ }
57
+
58
+ export async function buildBinary(
59
+ outfile: string = DEFAULT_OUTFILE,
60
+ buildFn: typeof Bun.build = Bun.build,
61
+ ): Promise<void> {
62
+ // Baked into the output; see the note above before changing it. Substituted
63
+ // through `define` rather than by assigning `process.env`, because this module
64
+ // is imported by tests in the same process — mutating the environment would
65
+ // flip `NODE_ENV` for every later suite in that process.
66
+ const result = await buildFn({
67
+ entrypoints: ["dist/main.js"],
68
+ minify: true,
69
+ target: "bun",
70
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
71
+ compile: {
72
+ outfile,
73
+ },
74
+ });
75
+
76
+ if (result.logs.length > 0) {
77
+ for (const log of result.logs) console.error(`[binary] ${log.message}`);
78
+ throw new Error(`binary build emitted ${result.logs.length} diagnostic(s)`);
79
+ }
80
+ console.log(`[binary] compiled ${compiledBinaryPath(outfile)}`);
81
+ }
82
+
83
+ if (import.meta.main) {
84
+ await buildBinary(resolveOutfile(process.argv.slice(2))).catch((error: unknown) => {
85
+ console.error("[binary] build failed:", error);
86
+ process.exit(1);
87
+ });
88
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * restart.ts — rebuild and restart the compiled gateway gracefully.
4
+ *
5
+ * Why this exists: a hard kill (`taskkill /F`, `kill -9`) truncates every
6
+ * in-flight response. On Linux the process handles SIGTERM/SIGINT and drains,
7
+ * but Windows has no deliverable catchable signal — `process.kill(pid,
8
+ * "SIGTERM")` of a console-less Bun process runs no JS handler (measured). So
9
+ * this script stops the running instance through the drain endpoint when
10
+ * `CARTETHYIA_DRAIN_TOKEN` is set (loopback + `x-drain-token`), and only falls
11
+ * back to a hard kill when no token is configured.
12
+ *
13
+ * Env:
14
+ * CARTETHYIA_DRAIN_TOKEN token for POST /admin/drain (required for graceful stop)
15
+ * PORT gateway port (default 12800)
16
+ * DRAIN_WAIT_MS how long to wait for the drain to finish (default 30000)
17
+ */
18
+ import { compiledBinaryPath } from "./compile";
19
+
20
+ function intEnv(name: string, fallback: number): number {
21
+ const raw = process.env[name];
22
+ if (raw === undefined || raw.trim() === "") return fallback;
23
+ const value = Number(raw);
24
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
25
+ }
26
+
27
+ const PORT = intEnv("PORT", 12800);
28
+ const DRAIN_WAIT_MS = intEnv("DRAIN_WAIT_MS", 30_000);
29
+
30
+ /** PIDs currently listening on `port` (best-effort, platform-specific). */
31
+ async function listeningPids(port: number): Promise<number[]> {
32
+ try {
33
+ if (process.platform === "win32") {
34
+ const proc = Bun.spawnSync(["netstat", "-ano", "-p", "TCP"]);
35
+ const out = new TextDecoder().decode(proc.stdout);
36
+ const pids = new Set<number>();
37
+ for (const line of out.split(/\r?\n/)) {
38
+ if (!line.includes(`:${port} `) || !line.includes("LISTENING")) continue;
39
+ const pid = Number(line.trim().split(/\s+/).pop());
40
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
41
+ }
42
+ return [...pids];
43
+ }
44
+ const proc = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"]);
45
+ return new TextDecoder()
46
+ .decode(proc.stdout)
47
+ .split(/\s+/)
48
+ .map(Number)
49
+ .filter((n) => Number.isInteger(n) && n > 0);
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() < deadline) {
58
+ if ((await listeningPids(port)).length === 0) return true;
59
+ await new Promise((r) => setTimeout(r, 250));
60
+ }
61
+ return (await listeningPids(port)).length === 0;
62
+ }
63
+
64
+ async function stopGracefully(): Promise<boolean> {
65
+ const token = process.env.CARTETHYIA_DRAIN_TOKEN?.trim();
66
+ if (token === undefined || token.length === 0) {
67
+ console.warn("[restart] CARTETHYIA_DRAIN_TOKEN unset — cannot drain gracefully");
68
+ return false;
69
+ }
70
+ try {
71
+ const response = await fetch(`http://127.0.0.1:${PORT}/admin/drain`, {
72
+ method: "POST",
73
+ headers: { "x-drain-token": token },
74
+ });
75
+ if (!response.ok) {
76
+ console.warn(`[restart] drain request returned HTTP ${response.status}`);
77
+ return false;
78
+ }
79
+ console.log("[restart] drain acknowledged; waiting for the process to exit");
80
+ return await waitForPortFree(PORT, DRAIN_WAIT_MS);
81
+ } catch (error) {
82
+ console.warn(`[restart] drain request failed: ${(error as Error).message}`);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ async function hardKill(): Promise<void> {
88
+ const pids = await listeningPids(PORT);
89
+ if (pids.length === 0) return;
90
+ console.warn(`[restart] force-killing pid(s) ${pids.join(", ")} on :${PORT}`);
91
+ for (const pid of pids) {
92
+ try {
93
+ process.kill(pid, "SIGKILL");
94
+ } catch {
95
+ // Already gone.
96
+ }
97
+ }
98
+ await waitForPortFree(PORT, 5_000);
99
+ }
100
+
101
+ async function main(): Promise<number> {
102
+ const graceful = await stopGracefully();
103
+ if (!graceful) await hardKill();
104
+
105
+ console.log("[restart] building");
106
+ const build = Bun.spawnSync(["bun", "run", "build"], { stdout: "inherit", stderr: "inherit" });
107
+ if (build.exitCode !== 0) {
108
+ console.error("[restart] build failed");
109
+ return build.exitCode ?? 1;
110
+ }
111
+
112
+ const binary = compiledBinaryPath();
113
+ console.log(`[restart] starting ${binary}`);
114
+ const child = Bun.spawn([binary], { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
115
+ return await child.exited;
116
+ }
117
+
118
+ if (import.meta.main) {
119
+ process.exit(await main());
120
+ }
@@ -0,0 +1,25 @@
1
+ /** Runs the compiled binary from the repository root. */
2
+ import { compiledBinaryPath } from "./compile";
3
+
4
+ /** Runs the compiled binary with inherited stdio; resolves to its exit code. */
5
+ export async function startBinary(): Promise<number> {
6
+ const binary = compiledBinaryPath();
7
+ if (!(await Bun.file(binary).exists())) {
8
+ throw new Error(`${binary} is missing; run bun run build first`);
9
+ }
10
+
11
+ process.env.NODE_ENV ??= "production";
12
+ process.env.CARTETHYIA_SERVER_MAX_BODY_BYTES ??= String(8 * 1024 * 1024 * 1024);
13
+
14
+ const child = Bun.spawn([binary], {
15
+ env: process.env,
16
+ stdin: "inherit",
17
+ stdout: "inherit",
18
+ stderr: "inherit",
19
+ });
20
+ return await child.exited;
21
+ }
22
+
23
+ if (import.meta.main) {
24
+ process.exit(await startBinary());
25
+ }
@@ -0,0 +1,186 @@
1
+ import { access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { Client } from "pg";
5
+ import Redis from "ioredis";
6
+ import { hasPlaceholderSecrets, readEnvFile } from "./cli/env";
7
+ import { parseServiceUrl, probeTcpService } from "./cli/services";
8
+ import { resolveDataDir, resolveDbMode } from "../src/persistence/db-mode";
9
+ import { bunVersionAtLeast, readRequiredBunVersion } from "./cli/version";
10
+
11
+ export type CheckPhase = "preflight" | "configuration" | "runtime";
12
+ export type CheckStatus = "pass" | "warn" | "fail" | "skip";
13
+ export interface CheckResult {
14
+ id: string;
15
+ phase: CheckPhase;
16
+ status: CheckStatus;
17
+ required: boolean;
18
+ summary: string;
19
+ remediation?: string;
20
+ }
21
+ export interface HealthContext {
22
+ target: "local" | "vps" | "docker";
23
+ env: Record<string, string>;
24
+ envPath: string;
25
+ port: number;
26
+ phases: readonly CheckPhase[];
27
+ }
28
+ export interface HealthDependencies {
29
+ dockerCompose: () => Promise<boolean>;
30
+ postgres: (url: string) => Promise<void>;
31
+ redis: (url: string) => Promise<void>;
32
+ tcp: typeof probeTcpService;
33
+ ready: (port: number) => Promise<string | null>;
34
+ writable: (path: string) => Promise<boolean>;
35
+ exists: (path: string) => Promise<boolean>;
36
+ }
37
+ const defaultDependencies: HealthDependencies = {
38
+ async dockerCompose() {
39
+ try {
40
+ return Bun.spawnSync(["docker", "compose", "version"], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
41
+ } catch {
42
+ return false;
43
+ }
44
+ },
45
+ async postgres(url) {
46
+ const client = new Client({ connectionString: url, connectionTimeoutMillis: 3_000 });
47
+ try {
48
+ await client.connect();
49
+ await client.query("SELECT 1");
50
+ } finally {
51
+ await client.end().catch(() => undefined);
52
+ }
53
+ },
54
+ async redis(url) {
55
+ const client = new Redis(url, { lazyConnect: true, connectTimeout: 3_000, maxRetriesPerRequest: 0 });
56
+ try {
57
+ await client.connect();
58
+ const result = await client.ping();
59
+ if (result !== "PONG") throw new Error(`Unexpected Redis response: ${result}`);
60
+ } finally {
61
+ client.disconnect();
62
+ }
63
+ },
64
+ tcp: probeTcpService,
65
+ async ready(port) {
66
+ try {
67
+ const response = await fetch(`http://127.0.0.1:${port}/health/ready`, { signal: AbortSignal.timeout(3_000) });
68
+ return response.ok ? null : `HTTP ${response.status}`;
69
+ } catch (error) {
70
+ return error instanceof Error ? error.message : "connection failed";
71
+ }
72
+ },
73
+ async writable(path) {
74
+ try {
75
+ await access(path, constants.W_OK);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+ async exists(path) {
82
+ try {
83
+ await access(path, constants.F_OK);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
89
+ };
90
+ function result(id: string, phase: CheckPhase, status: CheckStatus, summary: string, required = true, remediation?: string): CheckResult {
91
+ return { id, phase, status, required, summary, ...(remediation ? { remediation } : {}) };
92
+ }
93
+ function errorMessage(error: unknown): string {
94
+ return error instanceof Error ? error.message : String(error);
95
+ }
96
+ export async function runHealthChecks(
97
+ context: HealthContext,
98
+ dependencies: HealthDependencies = defaultDependencies,
99
+ ): Promise<CheckResult[]> {
100
+ const results: CheckResult[] = [];
101
+ const phases = new Set(context.phases);
102
+ const env = { ...(await readEnvFile(context.envPath)), ...context.env };
103
+ if (phases.has("preflight")) {
104
+ const minimum = await readRequiredBunVersion();
105
+ const bunOkay = bunVersionAtLeast(Bun.version, minimum);
106
+ results.push(result("runtime-bun", "preflight", bunOkay ? "pass" : "fail", bunOkay ? `Bun ${Bun.version} satisfies ${minimum}+` : `Bun ${Bun.version} is older than required ${minimum}`, true, bunOkay ? undefined : "Install the required Bun version"));
107
+ if (context.target === "docker") {
108
+ const available = await dependencies.dockerCompose();
109
+ results.push(result("docker-compose", "preflight", available ? "pass" : "fail", available ? "Docker Compose is available" : "Docker Compose is unavailable", true, "Install Docker Engine with the Compose plugin"));
110
+ }
111
+ }
112
+ if (phases.has("configuration")) {
113
+ if (!(await dependencies.exists(context.envPath))) {
114
+ results.push(result("env-file", "configuration", "fail", `.env not found at ${context.envPath}`, true, "Run setup or the installer"));
115
+ } else {
116
+ results.push(result("env-file", "configuration", "pass", `.env exists at ${context.envPath}`));
117
+ }
118
+ let mode: "lite" | "full" | null = null;
119
+ try {
120
+ mode = resolveDbMode(env);
121
+ results.push(result("db-mode", "configuration", "pass", `Database mode: ${mode}`));
122
+ } catch (error) {
123
+ results.push(result("db-mode", "configuration", "fail", errorMessage(error), true, "Set CARTETHYIA_DB_MODE=lite or full"));
124
+ }
125
+ const key = env.CARTETHYIA_ENCRYPTION_KEY?.trim() ?? "";
126
+ results.push(!key
127
+ ? result("encryption-key", "configuration", "fail", "CARTETHYIA_ENCRYPTION_KEY is missing")
128
+ : hasPlaceholderSecrets(key)
129
+ ? result("encryption-key", "configuration", "fail", "CARTETHYIA_ENCRYPTION_KEY is still a placeholder")
130
+ : result("encryption-key", "configuration", "pass", "Encryption key is configured"));
131
+ if (mode === "lite") {
132
+ const data = resolveDataDir(env);
133
+ const paths = [data, env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || join(data, "telemetry-payloads"), env.CARTETHYIA_INSTALL_ID_DIR?.trim() || join(data, ".cartethyia")];
134
+ for (const path of paths) {
135
+ if (context.target === "docker" && path.startsWith("/app/")) {
136
+ results.push(result(`container-path-${path}`, "configuration", "pass", `Container state path is declared: ${path}; runtime checks verify its writability`));
137
+ continue;
138
+ }
139
+ const writable = await dependencies.writable(resolve(path));
140
+ results.push(result(`writable-${path}`, "configuration", writable ? "pass" : "fail", `${writable ? "Writable" : "Not writable"}: ${path}`));
141
+ }
142
+ }
143
+ if (mode === "full") {
144
+ const url = env.DATABASE_URL?.trim() ?? "";
145
+ if (!url) results.push(result("postgres", "configuration", "fail", "DATABASE_URL is required in Full mode"));
146
+ else if (!parseServiceUrl(url)) results.push(result("postgres", "configuration", "fail", "DATABASE_URL must include a host and port"));
147
+ else if (context.target === "docker") results.push(result("postgres", "configuration", "pass", "External DATABASE_URL is configured; container readiness verifies connectivity"));
148
+ else {
149
+ try {
150
+ await dependencies.postgres(url);
151
+ results.push(result("postgres", "configuration", "pass", "PostgreSQL authenticated SELECT 1 succeeded"));
152
+ } catch (error) {
153
+ const parsed = parseServiceUrl(url)!;
154
+ const tcp = await dependencies.tcp(parsed.host, parsed.port, 3_000);
155
+ results.push(result("postgres", "configuration", tcp.success ? "warn" : "fail", tcp.success ? "PostgreSQL port is reachable, but authenticated query failed" : "PostgreSQL is unreachable or authentication failed", true, "Verify DATABASE_URL credentials and database availability"));
156
+ }
157
+ }
158
+ }
159
+ const redis = env.REDIS_URL?.trim() ?? "";
160
+ if (!redis) results.push(result("redis", "configuration", "pass", "Redis is not configured; in-memory coordination applies"));
161
+ else if (context.target === "docker") results.push(result("redis", "configuration", "pass", "Container Redis coordination is configured; container readiness verifies connectivity"));
162
+ else {
163
+ try {
164
+ await dependencies.redis(redis);
165
+ results.push(result("redis", "configuration", "pass", "Redis PING succeeded"));
166
+ } catch (error) {
167
+ results.push(result("redis", "configuration", "fail", "Configured Redis is unavailable or authentication failed", true, "Verify REDIS_URL and Redis availability"));
168
+ }
169
+ }
170
+ }
171
+ if (phases.has("runtime")) {
172
+ if (!Number.isInteger(context.port) || context.port < 1 || context.port > 65535) {
173
+ results.push(result("app-ready", "runtime", "fail", `Invalid application port: ${context.port}`));
174
+ } else {
175
+ const failure = await dependencies.ready(context.port);
176
+ results.push(result("app-ready", "runtime", failure ? "fail" : "pass", failure ? `Application is not ready: ${failure}` : `Application is ready on port ${context.port}`));
177
+ }
178
+ }
179
+ return results;
180
+ }
181
+ export function hasRequiredFailure(results: readonly CheckResult[]): boolean {
182
+ return results.some((item) => item.required && item.status === "fail");
183
+ }
184
+ export function skippedRuntimeCheck(): CheckResult {
185
+ return result("app-ready", "runtime", "skip", "Runtime readiness was not requested", false);
186
+ }
@@ -77,12 +77,47 @@ function isoDate(value: unknown, fallback: string): string {
77
77
  }
78
78
 
79
79
  /**
80
- * The credential on a connection row. The router writes it under one of
81
- * several keys depending on how the account was added, so all of them are
82
- * checked before the row is called credential-less.
80
+ * The credential and the kind to store it under.
81
+ *
82
+ * A router connection often carries both an `apiKey` and an OAuth access
83
+ * token for the same account. The two are not interchangeable: the api key
84
+ * is a bare bearer, while the access token has to be sent through the
85
+ * provider's OAuth-shaped envelope — Cline's chat path, for example,
86
+ * prepends `workos:` to anything it treats as OAuth. If we picked `apiKey`
87
+ * (the natural first choice because it is the longer-lived, bearer-ready
88
+ * secret) while persisting the kind as `oauth`, dispatch would wrap it as
89
+ * `Bearer workos:sk_...` and the upstream would answer "Unauthorized,
90
+ * re-authenticate".
91
+ *
92
+ * Resolution: when a connection carries an api key AND nothing explicitly
93
+ * says "treat this as OAuth", the api key is the credential and the kind
94
+ * is `api_key`. The caller still writes any refresh/client secret into
95
+ * `oauth_states` — the account can be rotated to OAuth later without
96
+ * losing it — but today's dispatch sends the key as a bare bearer and
97
+ * works. Only when `authType === "oauth"` is explicit, or when there is
98
+ * no api key at all, does the credential come from the OAuth bundle.
83
99
  */
84
- function credentialOf(entry: Row): string | null {
85
- return text(entry.apiKey) ?? text(entry.accessToken) ?? text(entry.credential) ?? text(entry.token);
100
+ interface ResolvedConnectionCredential {
101
+ readonly value: string;
102
+ readonly kind: "api_key" | "oauth";
103
+ }
104
+ function credentialOf(entry: Row, provider: string): ResolvedConnectionCredential | null {
105
+ const apiKey = text(entry.apiKey);
106
+ const accessToken = text(entry.accessToken);
107
+ const fallback = text(entry.credential) ?? text(entry.token);
108
+ const authType = text(entry.authType);
109
+ const oauthExplicit = authType === "oauth";
110
+ const apiKeyExplicit = authType === "apikey" || authType === "api_key";
111
+ if (apiKey !== null && !oauthExplicit) {
112
+ return { value: apiKey, kind: "api_key" };
113
+ }
114
+ if (accessToken !== null) {
115
+ return { value: accessToken, kind: apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
116
+ }
117
+ if (fallback !== null) {
118
+ return { value: fallback, kind: oauthExplicit ? "oauth" : apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
119
+ }
120
+ return null;
86
121
  }
87
122
 
88
123
  /** OAuth-backed providers store a refresh token rather than a static key. */
@@ -232,14 +267,13 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
232
267
  );
233
268
  return;
234
269
  }
235
- const credential = credentialOf(entry);
270
+ const credential = credentialOf(entry, provider);
236
271
  if (credential === null) {
237
272
  skipped.push(`${label}: no credential on the row`);
238
273
  return;
239
274
  }
240
275
  const accountId = crypto.randomUUID();
241
- const authType = text(entry.authType);
242
- const kind = authType === "oauth" || OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key";
276
+ const kind = credential.kind;
243
277
  accounts.push({
244
278
  id: accountId,
245
279
  provider_id: provider,
@@ -249,7 +283,7 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
249
283
  // `SEALED_COLUMNS`. The store re-seals it under this instance's key on the
250
284
  // way in; sealing it here would put ciphertext in a payload whose only
251
285
  // portable form is the plaintext, and the restore would drop it.
252
- access_token: credential,
286
+ access_token: credential.value,
253
287
  credential_kind: kind,
254
288
  status: bool(entry.isActive, true) ? "active" : "disabled",
255
289
  consecutive_failures: 0,
@@ -54,7 +54,6 @@ interface FreeRow {
54
54
  const FREE_ROWS: readonly FreeRow[] = [
55
55
  { id: "cohere/north-mini-code:free", ctx: 256_000, out: 64_000 },
56
56
  { id: "dots-studio/dots-3-note-preview:free", ctx: 512_000, out: 460_800, vision: true },
57
- { id: "minimax/minimax-m3.1-flash:free", ctx: 1_000_000, out: 524_288, vision: true },
58
57
  {
59
58
  id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
60
59
  ctx: 256_000,
@@ -123,6 +123,7 @@ export const BUDDY_PRO_ONLY_MODEL_IDS: ReadonlySet<string> = new Set([
123
123
  "gemini-3.8-flash",
124
124
  "grok-4.6",
125
125
  "grok-4.7",
126
+ "kimi-k3.1-pro-highspeed",
126
127
  ]);
127
128
 
128
129
  /**
@@ -259,7 +260,6 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
259
260
  ["claude-sonnet-4.6", "Claude Sonnet 4.6", true, true, 200_000, 32_000],
260
261
  ["deepseek-v4.1-flash", "DeepSeek V4.1 Flash", true, true, 1_000_000, 384_000],
261
262
  ["deepseek-v4.1-flash-sg", "DeepSeek V4.1 Flash SG", true, true, 1_000_000, 128_000],
262
- ["deepseek-v4.1-pro", "DeepSeek V4.1 Pro", true, false, 1_000_000, 384_000],
263
263
  ["gemini-2.5-flash-image", "Gemini 2.5 Flash Image", true, true, 1_000_000, 64_000],
264
264
  ["gemini-3.0-pro-image", "Gemini 3.0 Pro Image", true, true, 1_000_000, 64_000],
265
265
  ["gemini-3.1-flash-image", "Gemini 3.1 Flash Image", true, true, 1_000_000, 64_000],
@@ -278,7 +278,6 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
278
278
  ["gpt-5.6-sol", "GPT-5.6 Sol", true, true, 1_050_000, 128_000],
279
279
  ["gpt-5.6-terra", "GPT-5.6 Terra", true, true, 1_050_000, 128_000],
280
280
  ["gpt-6-luna", "GPT-6 Luna", true, true, 1_050_000, 128_000],
281
- ["deep-model", "Deep Model", true, true, null, null],
282
281
  ["gpt-6-sol", "GPT-6 Sol", true, true, 1_000_000, 128_000, true],
283
282
  ["gpt-6.1-sol", "GPT-6.1 Sol", true, true, 1_050_000, 128_000],
284
283
  ["gpt-image-2", "GPT-Image-2", true, true, 400_000, 128_000],
@@ -293,6 +292,8 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
293
292
  ["kimi-k2.7", "Kimi K2.7", true, false, 256_000, 65_536],
294
293
  ["kimi-k2.8-preview", "Kimi K2.8 Preview", true, false, 1_048_576, 65_536],
295
294
  ["kimi-k3", "Kimi K3", true, false, 1_048_576, 131_072],
295
+ ["kimi-k3.1-pro", "Kimi K3.1 Pro", true, true, 1_000_000, 64_000],
296
+ ["kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", true, true, 1_000_000, 64_000],
296
297
  ["minimax-m3", "MiniMax-M3", true, false, 512_000, 512_000],
297
298
  ["step-5-preview", "Step-5-Preview", true, true, 1_000_000, 64_000, true],
298
299
  ];
@@ -114,7 +114,8 @@ export const CODEBUDDY_CN_RAW: readonly BuddyRawEntry[] = [
114
114
  ["kimi-k2.7", "Kimi K2.7", true, true, 256_000, 65_536],
115
115
  ["kimi-k2.6", "Kimi K2.6", true, true, 256_000, 262_144],
116
116
  ["kimi-k2.5", "Kimi K2.5", true, true, 164_000, 262_144],
117
- ["kimi-k3-1", "Kimi K3", true, true, 1_048_576, 131_072],
117
+ ["kimi-k3.1-pro", "Kimi K3.1 Pro", true, true, 1_000_000, 64_000],
118
+ ["kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", true, true, 1_000_000, 64_000],
118
119
  ["hy3", "Hy3", true, true, 192_000, 64_000],
119
120
  ["hy4-preview", "Hy4 Preview", true, true, 1_000_000, 64_000],
120
121
  ["uto", "UTO", true, true, 256_000, 32_000],
@@ -230,7 +230,6 @@ const FREE_AGENT_TOOL_SCHEMA = {
230
230
  required: [] as string[],
231
231
  } as const;
232
232
 
233
-
234
233
  function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
235
234
  const present: Record<string, true> = {};
236
235
  for (const tool of request.tools ?? []) present[tool.name] = true;
@@ -251,8 +250,6 @@ function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
251
250
  };
252
251
  }
253
252
 
254
-
255
-
256
253
  function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
257
254
  if (!Array.isArray(payload.tools)) return;
258
255
  payload.tools = payload.tools.map((tool) => {
@@ -152,14 +152,33 @@ export async function loadAccountWithFreshness(
152
152
  const row = rows[0];
153
153
  if (!row) return undefined;
154
154
  const lead = skewMs ?? refreshLeadMs(row.providerId);
155
+ // A refresh-capable OAuth account with no stored expiry is unknown-liveness:
156
+ // we have no evidence the access token still works. The old rule left
157
+ // `dueAt` undefined for these, so `resolveCredentialForAccount` skipped the
158
+ // refresh branch and dispatch sent the pasted token as-is. For a token that
159
+ // had already expired on another installation (a 9router/Omniroute import
160
+ // carrying `accessToken` + `refreshToken` + a past `expiresAt` that was
161
+ // dropped by validation, a hand-pasted OAuth bundle with no expiry field,
162
+ // or an upstream that stopped minting expiries) that produced the exact
163
+ // "re-authenticate" refusal the refresh grant exists to prevent. Marking the
164
+ // token due immediately refreshes it on first use; the sweep handles the
165
+ // normal case where `expires_at` is populated.
166
+ const hasRefreshGrant = row.refreshCiphertext != null;
167
+ const kindOauth = row.credentialKind === "oauth";
168
+ const unknownExpiryButRefreshable =
169
+ kindOauth && !row.staticToken && hasRefreshGrant && row.expiresAt == null;
155
170
  return {
156
171
  row,
157
172
  // A static token is never "due": it is used as issued and there is no
158
173
  // refresh to run, so the dispatch path must not treat it as stale.
159
174
  dueAt:
160
- row.staticToken || row.expiresAt == null
175
+ row.staticToken
161
176
  ? undefined
162
- : row.expiresAt.getTime() - lead,
177
+ : row.expiresAt != null
178
+ ? row.expiresAt.getTime() - lead
179
+ : unknownExpiryButRefreshable
180
+ ? 0
181
+ : undefined,
163
182
  };
164
183
  }
165
184
 
@@ -223,7 +242,14 @@ export async function resolveCredentialForAccount(
223
242
  if (row.credentialKind === "oauth" && oauth && dueAt !== undefined && dueAt <= Date.now()) {
224
243
  const refresher = await oauth.resolveRefresher(row.providerId);
225
244
  if (refresher) {
226
- secret = await oauth.refreshService.ensureFreshAccessToken(accountId, refresher) ?? undefined;
245
+ // Try to refresh. If it fails (refresh grant dead, upstream unreachable,
246
+ // etc.) keep the already-decrypted stale secret rather than wiping it:
247
+ // dispatch then sends the token we have, and the upstream's own 401 is
248
+ // the authoritative verdict. Wiping it here turned "token expired" into
249
+ // a credential-less GatewayError before the request ever left, which is
250
+ // worse than letting the server say so.
251
+ const refreshed = await oauth.refreshService.ensureFreshAccessToken(accountId, refresher);
252
+ if (refreshed) secret = refreshed;
227
253
  }
228
254
  }
229
255