Add the harness, envs, per-bot models and chat attachments
- Harness: providers, model catalog synced hourly, automatic free picks, cooldowns, tool loop with context trimming, MCP computer tools - Bots queue runs replies and tasks through the harness; jobs wait (attempt refunded) while every model is resting - Envs: Settings > Environment, scoped general/workspace/computer, written to computers and redacted from tool output - Models: Settings > Models, per-harness provider select defaulting to OpenRouter, and a models modal on each bot's chat (the coordinator's edits the defaults) - Chat: thinking row while a reply runs, file attachments (upload, paste, drop) that replies read as images, audio and document text - Fix media uploads failing after a user's first file, and private files being served from the public media path Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
@@ -11,21 +12,43 @@ type Params = {
|
||||
*/
|
||||
worker: string;
|
||||
limit?: number;
|
||||
/**
|
||||
* Only jobs of this kind, so replies and tasks get their own slots.
|
||||
*/
|
||||
kind: BotJobKind;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claims the next pending jobs for a queue handler. Jobs whose handler
|
||||
* stopped sending updates are released back to the queue first, so a lost
|
||||
* job is picked up again (resume from its `checkpoint`).
|
||||
* Claims the next pending jobs of one kind for a queue handler. Jobs
|
||||
* whose handler stopped sending updates are released back to the queue
|
||||
* first, so a lost job is picked up again (resume from its `checkpoint`).
|
||||
*
|
||||
* bun-sqlite's `queue.claim` takes no filter, so the claim is its same
|
||||
* single statement (atomic: two workers never claim one job) plus `kind`.
|
||||
*/
|
||||
export default async function claimBotJobs({ worker, limit = 1 }: Params) {
|
||||
const res = await BunSQLite.queue.claim<
|
||||
export default async function claimBotJobs({ worker, limit = 1, kind }: Params) {
|
||||
const released = await BunSQLite.queue.release_stale<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bots_queue",
|
||||
worker,
|
||||
limit,
|
||||
>({ table: "bots_queue" });
|
||||
|
||||
if (!released.success) {
|
||||
throw new Error(`Couldn't release stale jobs: ${released.msg || released.error}`);
|
||||
}
|
||||
|
||||
if (limit < 1) return [];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `UPDATE bots_queue SET status = 'processing', attempts = attempts + 1,
|
||||
locked_at = ?, locked_by = ?, updated_at = ?
|
||||
WHERE id IN (
|
||||
SELECT id FROM bots_queue
|
||||
WHERE status = 'pending' AND available_at <= ? AND kind = ?
|
||||
ORDER BY id LIMIT ?
|
||||
) RETURNING *`,
|
||||
values: [now, worker, now, now, kind, Math.floor(limit)],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
|
||||
@@ -17,12 +17,17 @@ type Params = {
|
||||
* Flag the message as needing the user.
|
||||
*/
|
||||
needs_you?: boolean;
|
||||
/**
|
||||
* Finish without posting a message.
|
||||
*/
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks a job done, closes its task and sends the completion message back
|
||||
* to the user in the bot's conversation. The bot shows done (or needs-you)
|
||||
* unless it has other jobs open. Throws if this handler no longer holds
|
||||
* to the user in the bot's conversation. Unless it has other jobs open,
|
||||
* the bot shows needs-you if asked, else done after a task and idle after
|
||||
* a reply. Throws if this handler no longer holds
|
||||
* the job.
|
||||
*/
|
||||
export default async function completeBotJob({
|
||||
@@ -30,6 +35,7 @@ export default async function completeBotJob({
|
||||
worker,
|
||||
result,
|
||||
needs_you,
|
||||
silent,
|
||||
}: Params) {
|
||||
const job = await grabLockedBotJob({ job_id, worker });
|
||||
|
||||
@@ -60,11 +66,13 @@ export default async function completeBotJob({
|
||||
});
|
||||
}
|
||||
|
||||
const message = await insertBotMessage({
|
||||
bot_id: job.bot_id,
|
||||
text: result,
|
||||
needs_you,
|
||||
});
|
||||
const message = silent
|
||||
? null
|
||||
: await insertBotMessage({
|
||||
bot_id: job.bot_id,
|
||||
text: result,
|
||||
needs_you,
|
||||
});
|
||||
|
||||
const bot = needs_you
|
||||
? await setBotFace({
|
||||
@@ -74,7 +82,10 @@ export default async function completeBotJob({
|
||||
})
|
||||
: (await countOpenBotJobs({ bot_id: job.bot_id }))
|
||||
? null
|
||||
: await setBotFace({ bot_id: job.bot_id, face: "done" });
|
||||
: await setBotFace({
|
||||
bot_id: job.bot_id,
|
||||
face: job.kind == "reply" ? "idle" : "done",
|
||||
});
|
||||
|
||||
return { message, bot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
/**
|
||||
* Unix ms before which the job can't be claimed again.
|
||||
*/
|
||||
until: number;
|
||||
/**
|
||||
* Give back the attempt this claim used: nothing was tried.
|
||||
*/
|
||||
refund?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a job back without failing it: a pending job whose `available_at`
|
||||
* is pushed to `until` (capped at `AppData.HarnessMaxCooldownMs` from
|
||||
* now), or, for a job still held by `worker`, released back to pending
|
||||
* that way. False when the job is neither.
|
||||
*/
|
||||
export default async function deferBotJob({ job_id, worker, until, refund }: Params) {
|
||||
const now = Date.now();
|
||||
const at = Math.min(Math.max(until, now), now + AppData.HarnessMaxCooldownMs);
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET status = 'pending', locked_at = NULL, locked_by = NULL,
|
||||
attempts = CASE WHEN ? AND status = 'processing' THEN MAX(attempts - 1, 0) ELSE attempts END,
|
||||
available_at = MAX(COALESCE(available_at, 0), ?), updated_at = ?
|
||||
WHERE id = ? AND (status = 'pending' OR (status = 'processing' AND locked_by = ?))
|
||||
RETURNING id`,
|
||||
values: [refund ? 1 : 0, at, now, job_id, worker],
|
||||
});
|
||||
|
||||
return Boolean(res.payload?.length);
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import type {
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* `task` (the default) shows in the working-on panel; a `reply` answers
|
||||
* the user in chat and has no task.
|
||||
*/
|
||||
kind?: BotJobKind;
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
title: string;
|
||||
@@ -24,7 +30,7 @@ type Params = {
|
||||
message_id?: number | string;
|
||||
/**
|
||||
* The task for the working-on panel. One is created from `title` when
|
||||
* not given.
|
||||
* not given (replies have none).
|
||||
*/
|
||||
task_id?: number | string;
|
||||
/**
|
||||
@@ -44,6 +50,7 @@ type Params = {
|
||||
* every message needs a job; quick answers are replied to directly.
|
||||
*/
|
||||
export default async function enqueueBotJob({
|
||||
kind = "task",
|
||||
user_id,
|
||||
bot_id,
|
||||
title,
|
||||
@@ -76,7 +83,10 @@ export default async function enqueueBotJob({
|
||||
}
|
||||
}
|
||||
|
||||
const final_task_id = task_id || (await createJobTask({ bot_id: bot.id, title }));
|
||||
const final_task_id =
|
||||
kind == "reply"
|
||||
? undefined
|
||||
: task_id || (await createJobTask({ bot_id: bot.id, title }));
|
||||
|
||||
const res = await BunSQLite.queue.add<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
@@ -85,12 +95,13 @@ export default async function enqueueBotJob({
|
||||
table: "bots_queue",
|
||||
data: [
|
||||
{
|
||||
kind,
|
||||
bot_id: bot.id,
|
||||
title,
|
||||
instructions,
|
||||
params: params ? JSON.stringify(params) : undefined,
|
||||
message_id: message_id ? Number(message_id) : undefined,
|
||||
task_id: Number(final_task_id),
|
||||
task_id: final_task_id ? Number(final_task_id) : undefined,
|
||||
computer_id: computer_id ? Number(computer_id) : undefined,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import enqueueBotJob from "./enqueue-bot-job";
|
||||
import { BotReplyJobTitle } from "@/src/dict/bot-queue-dict";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The user message to answer.
|
||||
*/
|
||||
message_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Queues the bot's chat reply to the user's latest message(s). A reply
|
||||
* reads the whole recent conversation when it runs, so a burst of
|
||||
* messages shares one reply: while one is still pending, it is reused.
|
||||
*/
|
||||
export default async function enqueueBotReply({
|
||||
user_id,
|
||||
bot_id,
|
||||
message_id,
|
||||
}: Params) {
|
||||
const pending = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `SELECT id FROM bots_queue
|
||||
WHERE bot_id = ? AND kind = 'reply' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
values: [bot_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (pending?.id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET message_id = ? WHERE id = ?`,
|
||||
values: [message_id, pending.id],
|
||||
});
|
||||
|
||||
return pending.id;
|
||||
}
|
||||
|
||||
return await enqueueBotJob({
|
||||
kind: "reply",
|
||||
user_id,
|
||||
bot_id,
|
||||
title: BotReplyJobTitle,
|
||||
instructions: "Reply to the user's latest messages.",
|
||||
message_id,
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,12 @@ export default async function notifyBotJobFailed({
|
||||
bot_id: job.bot_id,
|
||||
text:
|
||||
failure_message ||
|
||||
[`I couldn't finish "${job.title || "the job"}".`, job.last_error]
|
||||
[
|
||||
job.kind == "reply"
|
||||
? `I couldn't reply.`
|
||||
: `I couldn't finish "${job.title || "the job"}".`,
|
||||
job.last_error,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
needs_you: true,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
media_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The session user's own uploaded files, in the given order. Throws when
|
||||
* any isn't theirs (or no longer exists), so a message can't carry
|
||||
* someone else's file.
|
||||
*/
|
||||
export default async function grabAttachableMedia({ user_id, media_ids }: Params) {
|
||||
const ids = [...new Set(media_ids)];
|
||||
|
||||
if (!ids.length) return [];
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
||||
sql: `SELECT * FROM media WHERE user_id = ? AND id IN (${ids.map(() => "?").join(", ")})`,
|
||||
values: [user_id, ...ids],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
if (rows.length != ids.length) {
|
||||
throw new AppError(`One of the files is gone. Attach it again.`);
|
||||
}
|
||||
|
||||
return ids.map((id) => rows.find((row) => row.id == id)!);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot by id, whoever owns it. Server-side work only (the queue); request
|
||||
* handlers use `grabUserBot` to check ownership.
|
||||
*/
|
||||
export default async function grabBotById({
|
||||
bot_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
|
||||
values: [bot_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabMessagesMedia from "./grab-messages-media";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
@@ -7,12 +10,13 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest messages of a bot's conversation, oldest first.
|
||||
* The latest messages of a bot's conversation, oldest first, with their
|
||||
* attachments.
|
||||
*/
|
||||
export default async function grabBotMessages({
|
||||
bot_id,
|
||||
limit = 100,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES[]> {
|
||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||
let sql = ``;
|
||||
|
||||
sql += `SELECT * FROM (\n`;
|
||||
@@ -27,5 +31,16 @@ export default async function grabBotMessages({
|
||||
values: [bot_id, limit],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
const messages = res.payload || [];
|
||||
const media = await grabMessagesMedia({
|
||||
message_ids: messages.map((message) => Number(message.id)),
|
||||
});
|
||||
|
||||
return messages.map((message) => {
|
||||
const files = media.get(Number(message.id));
|
||||
|
||||
return files
|
||||
? { ...message, attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
||||
: message;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
message_ids: number[];
|
||||
};
|
||||
|
||||
export type MessageMediaRow = BUN_SQLITE_COBBLE_MEDIA & { message_id: number };
|
||||
|
||||
/**
|
||||
* The files attached to these messages, by message id, in the order they
|
||||
* were attached.
|
||||
*/
|
||||
export default async function grabMessagesMedia({ message_ids }: Params) {
|
||||
const by_message = new Map<number, MessageMediaRow[]>();
|
||||
const ids = [...new Set(message_ids.filter(Boolean))];
|
||||
|
||||
if (!ids.length) return by_message;
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<MessageMediaRow>({
|
||||
sql: `SELECT m.*, l.message_id FROM bot_message_media l
|
||||
JOIN media m ON m.id = l.media_id
|
||||
WHERE l.message_id IN (${ids.map(() => "?").join(", ")})
|
||||
ORDER BY l.id ASC`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const row of rows) {
|
||||
const list = by_message.get(row.message_id) || [];
|
||||
list.push(row);
|
||||
by_message.set(row.message_id, list);
|
||||
}
|
||||
|
||||
return by_message;
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
text: string;
|
||||
/**
|
||||
* Files to attach, already checked to be the user's own
|
||||
* (`grabAttachableMedia`).
|
||||
*/
|
||||
media?: BUN_SQLITE_COBBLE_MEDIA[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a user message to a bot's conversation and bumps its activity.
|
||||
* Callers must check the bot belongs to the session user first.
|
||||
* Appends a user message (and its files) to a bot's conversation and
|
||||
* bumps its activity. Callers must check the bot belongs to the session
|
||||
* user first.
|
||||
*/
|
||||
export default async function insertUserMessage({
|
||||
bot_id,
|
||||
text,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
media = [],
|
||||
}: Params): Promise<BotMessageWithAttachments> {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
@@ -33,6 +43,13 @@ export default async function insertUserMessage({
|
||||
throw new Error(`Failed to save message: ${res.msg}`);
|
||||
}
|
||||
|
||||
for (const file of media) {
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT INTO bot_message_media (created_at, updated_at, message_id, media_id) VALUES (?, ?, ?, ?)`,
|
||||
values: [now, now, id, file.id!],
|
||||
});
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
|
||||
values: [now, bot_id],
|
||||
@@ -46,5 +63,6 @@ export default async function insertUserMessage({
|
||||
needs_you: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
attachments: media.map((file) => mediaToAttachment({ media: file })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||
import type { BotMessageAttachment, MediaType } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
media: BUN_SQLITE_COBBLE_MEDIA;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `media` row as a chat attachment: its original name (`title`), type
|
||||
* and the private URLs it's served at.
|
||||
*/
|
||||
export default function mediaToAttachment({ media }: Params): BotMessageAttachment {
|
||||
const { media_url, media_thumbnail_url } = grabMediaURL({ media: { ...media } });
|
||||
|
||||
return {
|
||||
id: Number(media.id),
|
||||
name: media.title || `File ${media.id}`,
|
||||
media_type: (media.media_type || "document") as MediaType,
|
||||
url: media_url || "",
|
||||
thumbnail_url: media_thumbnail_url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||
} from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
|
||||
|
||||
/**
|
||||
* Where a computer's MCP server answers. In order: the container's
|
||||
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
|
||||
* setting, `grabEnv`: for a server outside docker, e.g.
|
||||
* `http://localhost:8170`), else its docker hostname and port. `base_url`
|
||||
* is the same address without `/mcp`, for the sandbox's REST API. Null
|
||||
* when the computer has no container.
|
||||
*/
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<ComputerRow>({
|
||||
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
|
||||
FROM computers co JOIN containers c ON c.id = co.container_id
|
||||
WHERE co.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row?.hostname) return null;
|
||||
|
||||
const metadata = parseJobJson({ text: row.metadata }) || {};
|
||||
|
||||
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
|
||||
return {
|
||||
url: metadata.mcp_url,
|
||||
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
|
||||
name: row.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
const main_override = row.kind == "main"
|
||||
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
|
||||
: undefined;
|
||||
|
||||
const base = main_override
|
||||
? main_override.replace(/\/+$/, "")
|
||||
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
|
||||
|
||||
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { clearEnvCache } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
env_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an env. Computers drop it on their next run, when their env file
|
||||
* is rewritten.
|
||||
*/
|
||||
export default async function deleteEnv({ env_id }: Params) {
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `DELETE FROM envs WHERE id = ?`,
|
||||
values: [env_id],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't remove the env: ${res.error}`);
|
||||
|
||||
clearEnvCache();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
|
||||
export type DecryptedEnv = {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id: number | null;
|
||||
computer_id: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
|
||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
||||
|
||||
/**
|
||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||
* save and delete calls it). A value that can't be decrypted (the
|
||||
* encryption key changed) reads as empty.
|
||||
*/
|
||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||
cache ||= load().catch((error) => {
|
||||
cache = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function clearEnvCache() {
|
||||
cache = undefined;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT * FROM envs ORDER BY id ASC`,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return await Promise.all(
|
||||
rows.map(async (row): Promise<DecryptedEnv> => ({
|
||||
id: Number(row.id),
|
||||
key: row.key || "",
|
||||
value: row.value ? await decrypt(row.value).catch(() => "") : "",
|
||||
description: row.description || "",
|
||||
paradigm: (row.paradigm || "general") as EnvParadigm,
|
||||
workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null,
|
||||
computer_id: typeof row.computer_id == "number" ? row.computer_id : null,
|
||||
secret: row.secret != 0,
|
||||
expose_to_computers: row.expose_to_computers != 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
import resolveScopedEnvs from "./resolve-scoped-envs";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
/**
|
||||
* The bot using the computer. Only workspaces it shares with the
|
||||
* computer apply; without a bot, every workspace the computer is in.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs a run on this computer gets (`resolveScopedEnvs`). Workspaces
|
||||
* are non-archived ones, oldest first, so the oldest wins a tie.
|
||||
*/
|
||||
export default async function grabComputerEnvs({ computer_id, bot_id }: Params) {
|
||||
const workspaces =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT w.id FROM workspaces w
|
||||
JOIN workspace_computers wc ON wc.workspace_id = w.id AND wc.computer_id = ?
|
||||
WHERE w.archived = 0
|
||||
${bot_id ? `AND w.id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?)` : ""}
|
||||
ORDER BY w.id ASC`,
|
||||
values: bot_id ? [computer_id, bot_id] : [computer_id],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return resolveScopedEnvs({
|
||||
envs: await grabAllEnvs(),
|
||||
computer_id: Number(computer_id),
|
||||
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An app setting (an `EnvDict` key): the general value set in Settings,
|
||||
* else the process environment, else undefined.
|
||||
*/
|
||||
export default async function grabEnv({ key }: Params) {
|
||||
const saved = (await grabAllEnvs()).find(
|
||||
(env) => env.paradigm == "general" && env.key == key,
|
||||
);
|
||||
|
||||
return saved?.value || process.env[key] || undefined;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
} from "@/db/types/db";
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Environment settings page: where envs can be set (general, the
|
||||
* user's workspaces and computers), every env (secret values left out),
|
||||
* and the app settings with where each comes from.
|
||||
*/
|
||||
export default async function grabEnvsOverview({
|
||||
user_id,
|
||||
}: Params): Promise<EnvsOverviewPayload> {
|
||||
const [workspaces, computers, envs] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT id, name, archived FROM workspaces WHERE user_id = ? ORDER BY archived ASC, id ASC`,
|
||||
values: [user_id],
|
||||
}),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
|
||||
sql: `SELECT id, name, kind FROM computers WHERE user_id = ? ORDER BY kind = 'main' DESC, id ASC`,
|
||||
values: [user_id],
|
||||
}),
|
||||
grabAllEnvs(),
|
||||
]);
|
||||
|
||||
const workspace_ids = new Set((workspaces.payload || []).map((w) => Number(w.id)));
|
||||
const computer_ids = new Set((computers.payload || []).map((c) => Number(c.id)));
|
||||
const known_keys = new Set(EnvDict.map((env) => env.key));
|
||||
|
||||
const visible = envs.filter(
|
||||
(env) =>
|
||||
env.paradigm == "general" ||
|
||||
(env.workspace_id !== null && workspace_ids.has(env.workspace_id)) ||
|
||||
(env.computer_id !== null && computer_ids.has(env.computer_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
scopes: [
|
||||
{ paradigm: "general", id: null, title: "General" },
|
||||
...(workspaces.payload || []).map((workspace) => ({
|
||||
paradigm: "workspace" as const,
|
||||
id: Number(workspace.id),
|
||||
title: `Workspace: ${workspace.name}${workspace.archived ? " (archived)" : ""}`,
|
||||
})),
|
||||
...(computers.payload || []).map((computer) => ({
|
||||
paradigm: "computer" as const,
|
||||
id: Number(computer.id),
|
||||
title: `Computer: ${computer.name || computer.kind}`,
|
||||
})),
|
||||
],
|
||||
envs: visible.map((env) => ({
|
||||
id: env.id,
|
||||
key: env.key,
|
||||
description: env.description,
|
||||
paradigm: env.paradigm,
|
||||
workspace_id: env.workspace_id,
|
||||
computer_id: env.computer_id,
|
||||
secret: env.secret,
|
||||
expose_to_computers: env.expose_to_computers,
|
||||
value: env.secret ? undefined : env.value,
|
||||
known: known_keys.has(env.key),
|
||||
})),
|
||||
known: EnvDict.map((env) => ({
|
||||
...env,
|
||||
saved: envs.some((saved) => saved.paradigm == "general" && saved.key == env.key),
|
||||
from_process: Boolean(process.env[env.key]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { DecryptedEnv } from "./grab-all-envs";
|
||||
import resolveScopedEnvs from "./resolve-scoped-envs";
|
||||
|
||||
let next_id = 1;
|
||||
|
||||
const env = (fields: Partial<DecryptedEnv> & { key: string; value: string }): DecryptedEnv => ({
|
||||
id: next_id++,
|
||||
description: "",
|
||||
paradigm: "general",
|
||||
workspace_id: null,
|
||||
computer_id: null,
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
...fields,
|
||||
});
|
||||
|
||||
const values = (envs: DecryptedEnv[]) =>
|
||||
Object.fromEntries(envs.map((e) => [e.key, e.value]));
|
||||
|
||||
describe("resolveScopedEnvs", () => {
|
||||
test("computer beats workspace beats general", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "general" }),
|
||||
env({ key: "TOKEN", value: "workspace", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "TOKEN", value: "computer", paradigm: "computer", computer_id: 7 }),
|
||||
env({ key: "REGION", value: "eu", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "ONLY_GENERAL", value: "yes" }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2] }))).toEqual({
|
||||
TOKEN: "computer",
|
||||
REGION: "eu",
|
||||
ONLY_GENERAL: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores other computers and workspaces", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "general" }),
|
||||
env({ key: "TOKEN", value: "other", paradigm: "computer", computer_id: 8 }),
|
||||
env({ key: "TOKEN", value: "elsewhere", paradigm: "workspace", workspace_id: 3 }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2] }))).toEqual({
|
||||
TOKEN: "general",
|
||||
});
|
||||
});
|
||||
|
||||
test("the first workspace listed wins a tie", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "first", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "TOKEN", value: "second", paradigm: "workspace", workspace_id: 5 }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2, 5] }))).toEqual({
|
||||
TOKEN: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves out unexposed envs and app settings", () => {
|
||||
const envs = [
|
||||
env({ key: "HIDDEN", value: "x", expose_to_computers: false }),
|
||||
env({ key: "OPENROUTER_API_KEY", value: "sk-or" }),
|
||||
];
|
||||
|
||||
expect(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { DecryptedEnv } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
envs: DecryptedEnv[];
|
||||
computer_id: number;
|
||||
/**
|
||||
* The workspaces whose envs apply, in order of precedence (first wins).
|
||||
*/
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs that reach one computer, one per key: its own beat its
|
||||
* workspaces' (the first listed workspace wins a tie), which beat general
|
||||
* ones. Only envs exposed to computers; app settings never are.
|
||||
*/
|
||||
export default function resolveScopedEnvs({
|
||||
envs,
|
||||
computer_id,
|
||||
workspace_ids,
|
||||
}: Params): DecryptedEnv[] {
|
||||
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||
const exposed = envs.filter(
|
||||
(env) => env.expose_to_computers && !app_keys.has(env.key),
|
||||
);
|
||||
const resolved = new Map<string, DecryptedEnv>();
|
||||
|
||||
const layers = [
|
||||
exposed.filter((env) => env.paradigm == "general"),
|
||||
...[...workspace_ids].reverse().map((id) =>
|
||||
exposed.filter((env) => env.paradigm == "workspace" && env.workspace_id == id),
|
||||
),
|
||||
exposed.filter((env) => env.paradigm == "computer" && env.computer_id == computer_id),
|
||||
];
|
||||
|
||||
for (const layer of layers) {
|
||||
for (const env of layer) resolved.set(env.key, env);
|
||||
}
|
||||
|
||||
return [...resolved.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||
import {
|
||||
EnvDict,
|
||||
EnvKeyRegex,
|
||||
ProcessOnlyEnvKeys,
|
||||
type EnvParadigm,
|
||||
} from "@/src/dict/env-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import { clearEnvCache } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Owner of the workspace or computer the env is scoped to.
|
||||
*/
|
||||
user_id: number;
|
||||
/**
|
||||
* Edits this env; adds one when not given.
|
||||
*/
|
||||
env_id?: number;
|
||||
key: string;
|
||||
/**
|
||||
* Required when adding. Left out when editing, the saved value stays.
|
||||
*/
|
||||
value?: string;
|
||||
description?: string;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits an env. One per key and scope: SQLite unique constraints
|
||||
* treat NULL ids as distinct, so the check is here. App settings
|
||||
* (`EnvDict`) are general only, never go to computers, and keep their
|
||||
* dict's secret flag.
|
||||
*/
|
||||
export default async function saveEnv({
|
||||
user_id,
|
||||
env_id,
|
||||
key,
|
||||
value,
|
||||
description,
|
||||
paradigm,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
secret,
|
||||
expose_to_computers,
|
||||
}: Params) {
|
||||
const final_key = key.trim();
|
||||
|
||||
if (!EnvKeyRegex.test(final_key)) {
|
||||
throw new AppError(`Use letters, digits and underscores for the key, starting with a letter or underscore.`);
|
||||
}
|
||||
|
||||
if ((ProcessOnlyEnvKeys as readonly string[]).includes(final_key)) {
|
||||
throw new AppError(`${final_key} must stay in the server's environment.`);
|
||||
}
|
||||
|
||||
const known = EnvDict.find((env) => env.key == final_key);
|
||||
|
||||
if (known && paradigm != "general") {
|
||||
throw new AppError(`${final_key} is an app setting, so it can only be set in General.`);
|
||||
}
|
||||
|
||||
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id });
|
||||
|
||||
const duplicate = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ?
|
||||
AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ?
|
||||
AND id != ? LIMIT 1`,
|
||||
values: [final_key, paradigm, scope.workspace_id ?? 0, scope.computer_id ?? 0, env_id ?? 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(`${final_key} is already set here. Edit that one instead.`);
|
||||
}
|
||||
|
||||
const fields = {
|
||||
key: final_key,
|
||||
description: description?.trim() || known?.description || "",
|
||||
paradigm,
|
||||
workspace_id: scope.workspace_id,
|
||||
computer_id: scope.computer_id,
|
||||
secret: (known ? known.secret : secret) ? 1 : 0,
|
||||
expose_to_computers: !known && expose_to_computers ? 1 : 0,
|
||||
};
|
||||
|
||||
if (env_id) {
|
||||
const existing = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT id FROM envs WHERE id = ? LIMIT 1`,
|
||||
values: [env_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!existing) throw new AppError(`That env no longer exists.`);
|
||||
|
||||
const sets = Object.keys(fields).map((field) => `${field} = ?`);
|
||||
const values: unknown[] = Object.values(fields);
|
||||
|
||||
if (value !== undefined) {
|
||||
sets.push(`value = ?`);
|
||||
values.push(await encrypt(value));
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE envs SET ${sets.join(", ")}, updated_at = ? WHERE id = ?`,
|
||||
values: [...values, Date.now(), env_id] as any[],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
} else {
|
||||
if (value === undefined) {
|
||||
throw new AppError(`Enter a value.`);
|
||||
}
|
||||
|
||||
const row = { ...fields, value: await encrypt(value) };
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO envs (created_at, updated_at, ${Object.keys(row).join(", ")})
|
||||
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")})`,
|
||||
values: [now, now, ...Object.values(row)] as any[],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
}
|
||||
|
||||
clearEnvCache();
|
||||
}
|
||||
|
||||
async function resolveScope({
|
||||
user_id,
|
||||
paradigm,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
}: {
|
||||
user_id: number;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
}) {
|
||||
if (paradigm == "general") {
|
||||
return { workspace_id: null, computer_id: null };
|
||||
}
|
||||
|
||||
const table = paradigm == "workspace" ? "workspaces" : "computers";
|
||||
const id = paradigm == "workspace" ? workspace_id : computer_id;
|
||||
|
||||
const owned = id
|
||||
? (
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM ${table} WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
values: [id, user_id],
|
||||
})
|
||||
).payload?.[0]
|
||||
: undefined;
|
||||
|
||||
if (!owned) {
|
||||
throw new AppError(`Pick one of your ${table}.`);
|
||||
}
|
||||
|
||||
return paradigm == "workspace"
|
||||
? { workspace_id: Number(id), computer_id: null }
|
||||
: { workspace_id: null, computer_id: Number(id) };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import harnessModelRowToCatalog from "./harness-model-row-to-catalog";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
model_id: number;
|
||||
/**
|
||||
* The provider the setting is limited to, if any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the model is available, can do the harness's job and,
|
||||
* when the setting is limited to a provider, comes from it.
|
||||
*/
|
||||
export default async function assertHarnessModel({ harness, model_id, provider_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_MODELS>({
|
||||
sql: `SELECT * FROM harness_models WHERE id = ? AND available = 1 LIMIT 1`,
|
||||
values: [model_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) {
|
||||
throw new AppError(`That model isn't available anymore.`);
|
||||
}
|
||||
|
||||
if (provider_id && row.provider_id != provider_id) {
|
||||
throw new AppError(`That model isn't from the picked provider.`);
|
||||
}
|
||||
|
||||
if (!modelServesHarness({ model: harnessModelRowToCatalog({ row }), harness })) {
|
||||
throw new AppError(`That model can't do this job.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the provider exists and is turned on.
|
||||
*/
|
||||
export default async function assertHarnessProvider({ provider_id }: Params) {
|
||||
const provider = (await grabHarnessProviders({ provider_id }))[0];
|
||||
|
||||
if (!provider?.id || !provider.enabled) {
|
||||
throw new AppError(`That provider isn't available. Turn it on in Settings, under Models.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a provider and its models. Harnesses (default and per bot) that
|
||||
* picked one of its models, or were limited to it, go back to automatic
|
||||
* on any provider.
|
||||
*/
|
||||
export default async function deleteHarnessProvider({ provider_id }: Params) {
|
||||
const provider = (await grabHarnessProviders({ provider_id }))[0];
|
||||
|
||||
if (!provider?.id) {
|
||||
throw new AppError(`Provider not found.`);
|
||||
}
|
||||
|
||||
for (const table of ["harness_settings", "bot_harness_settings"]) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE ${table} SET model_id = NULL, provider_id = NULL, updated_at = ?
|
||||
WHERE provider_id = ? OR model_id IN (SELECT id FROM harness_models WHERE provider_id = ?)`,
|
||||
values: [Date.now(), provider.id, provider.id],
|
||||
});
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `DELETE FROM harness_providers WHERE id = ?`,
|
||||
values: [provider.id],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't remove the provider: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { DefaultHarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
|
||||
/**
|
||||
* Adds the default provider (OpenRouter) when no provider exists, so the
|
||||
* free models work as soon as a key is set. Idempotent; a super admin who
|
||||
* removed it and added others doesn't get it back.
|
||||
*/
|
||||
export default async function ensureDefaultHarnessProvider() {
|
||||
const existing = await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT id FROM harness_providers LIMIT 1`,
|
||||
});
|
||||
|
||||
if (existing.payload?.[0]) return;
|
||||
|
||||
const preset = grabProviderPreset({ kind: DefaultHarnessProviderKind });
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data: [
|
||||
{
|
||||
kind: preset.value,
|
||||
name: preset.title,
|
||||
base_url: preset.base_url,
|
||||
enabled: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't add the default model provider: ${res.msg}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import grabHarnessModelEntries from "./grab-harness-model-entries";
|
||||
import grabHarnessOverviewItem from "./grab-harness-overview-item";
|
||||
import grabHarnessSetting from "./grab-harness-setting";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A worker bot the caller has checked belongs to the session user.
|
||||
*/
|
||||
bot_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A worker bot's model settings page: per harness, the bot's own setting
|
||||
* (or the default it inherits), the models it can pick, what it runs on
|
||||
* now, and what the default runs on.
|
||||
*/
|
||||
export default async function grabBotHarnessOverview({
|
||||
bot_id,
|
||||
}: Params): Promise<BotHarnessOverviewPayload> {
|
||||
const [own, defaults] = await Promise.all([
|
||||
Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value, bot_id })),
|
||||
),
|
||||
Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
|
||||
),
|
||||
]);
|
||||
|
||||
const entries = await grabHarnessModelEntries({
|
||||
pinned_ids: [...own, ...defaults].flatMap((setting) =>
|
||||
setting.model_id ? [setting.model_id] : [],
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
harnesses: await Promise.all(
|
||||
own.map(async (setting, index) => {
|
||||
const [item, default_item] = await Promise.all([
|
||||
grabHarnessOverviewItem({ harness: setting.harness, setting, entries, bot_id }),
|
||||
grabHarnessOverviewItem({
|
||||
harness: setting.harness,
|
||||
setting: defaults[index]!,
|
||||
entries,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...item,
|
||||
inherit: setting.source == "default",
|
||||
default: {
|
||||
enabled: default_item.enabled,
|
||||
current: default_item.current,
|
||||
candidate_count: default_item.candidate_count,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import { DefaultHarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
|
||||
/**
|
||||
* The default provider (`DefaultHarnessProviderKind`, OpenRouter): the
|
||||
* oldest enabled one of that kind. Undefined once it's removed or turned
|
||||
* off, which leaves harnesses on any provider.
|
||||
*/
|
||||
export default async function grabDefaultHarnessProviderId() {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT id FROM harness_providers WHERE kind = ? AND enabled = 1 ORDER BY id ASC LIMIT 1`,
|
||||
values: [DefaultHarnessProviderKind],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return row?.id || undefined;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
import harnessModelRowToCatalog from "./harness-model-row-to-catalog";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Picked models, listed even when no longer available.
|
||||
*/
|
||||
pinned_ids?: number[];
|
||||
};
|
||||
|
||||
type ModelRow = BUN_SQLITE_COBBLE_HARNESS_MODELS & { provider_name?: string };
|
||||
|
||||
export type HarnessModelEntry = {
|
||||
row: ModelRow;
|
||||
model: HarnessCatalogModel;
|
||||
option: HarnessModelOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every model a setting can pick (available, on an enabled provider),
|
||||
* free first, plus the given picked ones, each with its picker option.
|
||||
*/
|
||||
export default async function grabHarnessModelEntries({
|
||||
pinned_ids = [],
|
||||
}: Params = {}): Promise<HarnessModelEntry[]> {
|
||||
const ids = [...new Set(pinned_ids)];
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<ModelRow>({
|
||||
sql: `SELECT m.*, p.name AS provider_name FROM harness_models m
|
||||
JOIN harness_providers p ON p.id = m.provider_id
|
||||
WHERE (m.available = 1 AND p.enabled = 1)
|
||||
${ids.length ? `OR m.id IN (${ids.map(() => "?").join(", ")})` : ""}
|
||||
ORDER BY m.free DESC, p.id ASC, m.name ASC`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return rows.map((row) => {
|
||||
const model = harnessModelRowToCatalog({ row });
|
||||
|
||||
return {
|
||||
row,
|
||||
model,
|
||||
option: {
|
||||
id: row.id || 0,
|
||||
provider_id: row.provider_id || 0,
|
||||
provider_name: row.provider_name || "",
|
||||
slug: model.slug,
|
||||
name: `${model.name || model.slug}${row.available == 0 ? " (no longer listed)" : ""}`,
|
||||
free: model.free,
|
||||
voices: model.voices,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
|
||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import type { HarnessModelEntry } from "./grab-harness-model-entries";
|
||||
import type { HarnessSetting } from "./grab-harness-setting";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
setting: HarnessSetting;
|
||||
entries: HarnessModelEntry[];
|
||||
/**
|
||||
* Resolve what runs now for this bot instead of the default.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One harness as the model settings pages show it: its setting, the
|
||||
* models it can be set to, and the model it would use right now.
|
||||
*/
|
||||
export default async function grabHarnessOverviewItem({
|
||||
harness,
|
||||
setting,
|
||||
entries,
|
||||
bot_id,
|
||||
}: Params): Promise<HarnessOverview> {
|
||||
const meta = Harnesses.find((h) => h.value == harness)!;
|
||||
const candidates = setting.enabled
|
||||
? await resolveHarnessCandidates({ harness, bot_id }).catch(() => [])
|
||||
: [];
|
||||
const first = candidates[0];
|
||||
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
|
||||
const providers = [
|
||||
...new Map(
|
||||
serving.map(({ option }) => [
|
||||
option.provider_id,
|
||||
{ id: option.provider_id, name: option.provider_name },
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
if (setting.provider_id && !providers.some((p) => p.id == setting.provider_id)) {
|
||||
providers.push({ id: setting.provider_id, name: "Unavailable provider" });
|
||||
}
|
||||
const current = first
|
||||
? entries.find(({ row, model }) => `${row.provider_id}:${model.slug}` == first.key)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
harness,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
required: meta.required,
|
||||
model_id: setting.model_id,
|
||||
provider_id: setting.provider_id,
|
||||
providers,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.options,
|
||||
models: serving.map(({ option }) => option),
|
||||
current: current && first ? { ...current.option, source: first.source } : undefined,
|
||||
candidate_count: candidates.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import type { HarnessOverviewPayload } from "@/src/types";
|
||||
import grabHarnessModelEntries from "./grab-harness-model-entries";
|
||||
import grabHarnessOverviewItem from "./grab-harness-overview-item";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
import grabHarnessSetting from "./grab-harness-setting";
|
||||
import toPublicHarnessProvider from "./to-public-harness-provider";
|
||||
|
||||
/**
|
||||
* Everything the Models settings page shows: the providers (without keys)
|
||||
* and, per harness, its default setting, the models it can be set to
|
||||
* (free first) and what it runs on right now.
|
||||
*/
|
||||
export default async function grabHarnessOverview(): Promise<HarnessOverviewPayload> {
|
||||
const providers = await grabHarnessProviders();
|
||||
const settings = await Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
|
||||
);
|
||||
const entries = await grabHarnessModelEntries({
|
||||
pinned_ids: settings.flatMap((setting) =>
|
||||
setting.model_id ? [setting.model_id] : [],
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
providers: providers.map((provider) => toPublicHarnessProvider({ provider })),
|
||||
harnesses: await Promise.all(
|
||||
settings.map((setting) =>
|
||||
grabHarnessOverviewItem({ harness: setting.harness, setting, entries }),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
provider_id?: number | string;
|
||||
enabled_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider rows, oldest first. They hold the encrypted key: server only,
|
||||
* never hand them to the client as they are (`toPublicHarnessProvider`).
|
||||
*/
|
||||
export default async function grabHarnessProviders({
|
||||
provider_id,
|
||||
enabled_only,
|
||||
}: Params = {}) {
|
||||
const where: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (provider_id) {
|
||||
where.push(`id = ?`);
|
||||
values.push(provider_id);
|
||||
}
|
||||
|
||||
if (enabled_only) {
|
||||
where.push(`enabled = 1`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT * FROM harness_providers ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY id ASC`,
|
||||
values,
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
|
||||
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
||||
} from "@/db/types/db";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import grabDefaultHarnessProviderId from "./grab-default-harness-provider-id";
|
||||
|
||||
export type HarnessSetting = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* The picked `harness_models` row; undefined picks automatically.
|
||||
*/
|
||||
model_id?: number;
|
||||
/**
|
||||
* The `harness_providers` row the harness is limited to (its pick
|
||||
* and its automatic picks); undefined: any provider.
|
||||
*/
|
||||
provider_id?: number;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options: Record<string, unknown>;
|
||||
/**
|
||||
* `bot` when a worker bot overrides the default for this harness.
|
||||
*/
|
||||
source: "default" | "bot";
|
||||
};
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* The bot the call is for. A worker bot's own setting wins; the
|
||||
* coordinator always uses the default.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
type SettingRow = Pick<
|
||||
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
||||
"model_id" | "provider_id" | "fallback" | "enabled" | "options"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A harness's setting for a call. With no row it is automatic on the
|
||||
* default provider (OpenRouter), on, with fallback. Text is always on.
|
||||
*/
|
||||
export default async function grabHarnessSetting({
|
||||
harness,
|
||||
bot_id,
|
||||
}: Params): Promise<HarnessSetting> {
|
||||
const bot_row = bot_id
|
||||
? await grabBotOverride({ harness, bot_id })
|
||||
: undefined;
|
||||
|
||||
if (bot_row) {
|
||||
return toSetting({ harness, row: bot_row, source: "bot" });
|
||||
}
|
||||
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_SETTINGS>({
|
||||
sql: `SELECT * FROM harness_settings WHERE harness = ? LIMIT 1`,
|
||||
values: [harness],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
...toSetting({ harness, source: "default" }),
|
||||
provider_id: await grabDefaultHarnessProviderId(),
|
||||
};
|
||||
}
|
||||
|
||||
return toSetting({ harness, row, source: "default" });
|
||||
}
|
||||
|
||||
async function grabBotOverride({
|
||||
harness,
|
||||
bot_id,
|
||||
}: {
|
||||
harness: HarnessKind;
|
||||
bot_id: number | string;
|
||||
}) {
|
||||
return (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS>({
|
||||
sql: `SELECT s.* FROM bot_harness_settings s
|
||||
JOIN bots b ON b.id = s.bot_id
|
||||
WHERE s.bot_id = ? AND s.harness = ? AND b.kind != 'coordinator'
|
||||
LIMIT 1`,
|
||||
values: [bot_id, harness],
|
||||
})
|
||||
).payload?.[0];
|
||||
}
|
||||
|
||||
function toSetting({
|
||||
harness,
|
||||
row,
|
||||
source,
|
||||
}: {
|
||||
harness: HarnessKind;
|
||||
row?: SettingRow;
|
||||
source: HarnessSetting["source"];
|
||||
}): HarnessSetting {
|
||||
return {
|
||||
harness,
|
||||
model_id: typeof row?.model_id == "number" ? row.model_id : undefined,
|
||||
provider_id: typeof row?.provider_id == "number" ? row.provider_id : undefined,
|
||||
fallback: row ? row.fallback != 0 : true,
|
||||
enabled: harness == "text" || !row || row.enabled != 0,
|
||||
options: parseJobJson({ text: row?.options }) || {},
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_HARNESS_MODELS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `harness_models` row as a catalog entry.
|
||||
*/
|
||||
export default function harnessModelRowToCatalog({
|
||||
row,
|
||||
}: Params): HarnessCatalogModel {
|
||||
return {
|
||||
slug: row.slug || "",
|
||||
name: row.name || row.slug || "",
|
||||
free: row.free == 1,
|
||||
input_modalities: parseList({ text: row.input_modalities }) || ["text"],
|
||||
output_modalities: parseList({ text: row.output_modalities }) || [
|
||||
"text",
|
||||
],
|
||||
supports_tools: row.supports_tools == 1,
|
||||
context_length: numberOrUndefined(row.context_length),
|
||||
max_output_tokens: numberOrUndefined(row.max_output_tokens),
|
||||
voices: parseList({ text: row.voices }),
|
||||
intelligence_score: numberOrUndefined(row.intelligence_score),
|
||||
agentic_score: numberOrUndefined(row.agentic_score),
|
||||
released_at: numberOrUndefined(row.released_at),
|
||||
expires_at: numberOrUndefined(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
function parseList({ text }: { text?: string | null }) {
|
||||
if (!text) return undefined;
|
||||
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return Array.isArray(value) ? value.map(String) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown) {
|
||||
return typeof value == "number" ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import type { HarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import type { HarnessProviderConfig } from "@/src/harness/types";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A provider row as the adapters need it, with its key decrypted (or, if
|
||||
* none is saved, the preset's env: set in Settings, under Environment, or
|
||||
* the process environment, `grabEnv`). `usable` is
|
||||
* false when the provider needs a key and has none.
|
||||
*/
|
||||
export default async function resolveProviderConfig({
|
||||
provider,
|
||||
}: Params): Promise<{ config: HarnessProviderConfig; usable: boolean }> {
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
let api_key: string | undefined;
|
||||
|
||||
if (provider.api_key) {
|
||||
api_key = await decrypt(provider.api_key).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (!api_key && preset.key_env) {
|
||||
api_key = await grabEnv({ key: preset.key_env });
|
||||
}
|
||||
|
||||
const config: HarnessProviderConfig = {
|
||||
id: provider.id || undefined,
|
||||
kind: preset.value as HarnessProviderKind,
|
||||
name: provider.name || preset.title,
|
||||
base_url: provider.base_url || preset.base_url,
|
||||
api_key,
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
usable: Boolean(config.base_url) && (!preset.needs_key || !!api_key),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertHarnessModel from "./assert-harness-model";
|
||||
import assertHarnessProvider from "./assert-harness-provider";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A worker bot the caller has checked belongs to the session user.
|
||||
*/
|
||||
bot_id: number;
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* Drop the bot's own setting and use the default again.
|
||||
*/
|
||||
inherit: boolean;
|
||||
model_id: number | null;
|
||||
/**
|
||||
* Limits the harness to one provider; null: any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves (or, with `inherit`, removes) a worker bot's own setting for one
|
||||
* harness. Same rules as the default: the model must be available and fit
|
||||
* the job, and text can't be turned off.
|
||||
*/
|
||||
export default async function saveBotHarnessSetting({
|
||||
bot_id,
|
||||
harness,
|
||||
inherit,
|
||||
model_id,
|
||||
provider_id = null,
|
||||
fallback,
|
||||
enabled,
|
||||
options,
|
||||
}: Params) {
|
||||
if (inherit) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM bot_harness_settings WHERE bot_id = ? AND harness = ?`,
|
||||
values: [bot_id, harness],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (harness == "text" && !enabled) {
|
||||
throw new AppError(`The text model can't be turned off.`);
|
||||
}
|
||||
|
||||
if (provider_id) {
|
||||
await assertHarnessProvider({ provider_id });
|
||||
}
|
||||
|
||||
if (model_id) {
|
||||
await assertHarnessModel({ harness, model_id, provider_id });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO bot_harness_settings (created_at, updated_at, bot_id, harness, model_id, provider_id, fallback, enabled, options)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bot_id, harness) DO UPDATE SET updated_at = excluded.updated_at,
|
||||
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
|
||||
enabled = excluded.enabled, options = excluded.options`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
bot_id,
|
||||
harness,
|
||||
model_id,
|
||||
provider_id,
|
||||
fallback ? 1 : 0,
|
||||
enabled ? 1 : 0,
|
||||
JSON.stringify(options || {}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the bot's model setting: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { HarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Updates this provider; adds one when not given.
|
||||
*/
|
||||
provider_id?: number;
|
||||
kind: HarnessProviderKind;
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
/**
|
||||
* A new key. Left out keeps the saved one.
|
||||
*/
|
||||
api_key?: string;
|
||||
clear_key?: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or updates a model provider. The key is encrypted before it's
|
||||
* stored and never read back to the client. Returns the provider row.
|
||||
*/
|
||||
export default async function saveHarnessProvider({
|
||||
provider_id,
|
||||
kind,
|
||||
name,
|
||||
base_url,
|
||||
api_key,
|
||||
clear_key,
|
||||
enabled,
|
||||
}: Params) {
|
||||
const preset = grabProviderPreset({ kind });
|
||||
const final_base_url = (base_url || preset.base_url).trim().replace(/\/+$/, "");
|
||||
|
||||
if (!/^https?:\/\/[^\s/]+/i.test(final_base_url)) {
|
||||
throw new AppError(`Enter the provider's API address, starting with http:// or https://.`);
|
||||
}
|
||||
|
||||
const existing = provider_id
|
||||
? (await grabHarnessProviders({ provider_id }))[0]
|
||||
: undefined;
|
||||
|
||||
if (provider_id && !existing) {
|
||||
throw new AppError(`Provider not found.`);
|
||||
}
|
||||
|
||||
const key_update = api_key?.trim()
|
||||
? { api_key: await encrypt(api_key.trim()) }
|
||||
: clear_key
|
||||
? { api_key: "" }
|
||||
: {};
|
||||
|
||||
const data: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS = {
|
||||
kind: preset.value,
|
||||
name: name?.trim() || preset.title,
|
||||
base_url: final_base_url,
|
||||
enabled: enabled ? 1 : 0,
|
||||
...key_update,
|
||||
};
|
||||
|
||||
if (existing?.id) {
|
||||
const res = await BunSQLite.update<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data,
|
||||
targetId: existing.id,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the provider: ${res.msg}`);
|
||||
}
|
||||
|
||||
return (await grabHarnessProviders({ provider_id: existing.id }))[0]!;
|
||||
}
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data: [data],
|
||||
});
|
||||
|
||||
const new_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !new_id) {
|
||||
throw new Error(`Couldn't add the provider: ${res.msg}`);
|
||||
}
|
||||
|
||||
return (await grabHarnessProviders({ provider_id: new_id }))[0]!;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertHarnessModel from "./assert-harness-model";
|
||||
import assertHarnessProvider from "./assert-harness-provider";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* A `harness_models` row, or null to pick automatically.
|
||||
*/
|
||||
model_id: number | null;
|
||||
/**
|
||||
* Limits the harness to one provider; null: any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a harness's model setting. The picked model must be available and
|
||||
* able to do the harness's job. Text can't be turned off.
|
||||
*/
|
||||
export default async function saveHarnessSetting({
|
||||
harness,
|
||||
model_id,
|
||||
provider_id = null,
|
||||
fallback,
|
||||
enabled,
|
||||
options,
|
||||
}: Params) {
|
||||
if (harness == "text" && !enabled) {
|
||||
throw new AppError(`The text model can't be turned off.`);
|
||||
}
|
||||
|
||||
if (provider_id) {
|
||||
await assertHarnessProvider({ provider_id });
|
||||
}
|
||||
|
||||
if (model_id) {
|
||||
await assertHarnessModel({ harness, model_id, provider_id });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO harness_settings (created_at, updated_at, harness, model_id, provider_id, fallback, enabled, options)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(harness) DO UPDATE SET updated_at = excluded.updated_at,
|
||||
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
|
||||
enabled = excluded.enabled, options = excluded.options`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
harness,
|
||||
model_id,
|
||||
provider_id,
|
||||
fallback ? 1 : 0,
|
||||
enabled ? 1 : 0,
|
||||
JSON.stringify(options || {}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the model setting: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import ensureDefaultHarnessProvider from "./ensure-default-harness-provider";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
import syncHarnessProviderModels from "./sync-harness-provider-models";
|
||||
|
||||
/**
|
||||
* Refreshes every enabled provider's model list, so automatic picks follow
|
||||
* free models as they come and go. One provider failing doesn't stop the
|
||||
* rest; its error is saved on its row.
|
||||
*/
|
||||
export default async function syncAllHarnessModels() {
|
||||
await ensureDefaultHarnessProvider();
|
||||
|
||||
const providers = await grabHarnessProviders({ enabled_only: true });
|
||||
const results: { provider_id?: number; count?: number; error?: string }[] =
|
||||
[];
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const count = await syncHarnessProviderModels({ provider });
|
||||
results.push({ provider_id: provider.id || undefined, count });
|
||||
} catch (error: unknown) {
|
||||
results.push({
|
||||
provider_id: provider.id || undefined,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import grabHarnessAdapter from "@/src/harness/providers/grab-harness-adapter";
|
||||
import resolveProviderConfig from "./resolve-provider-config";
|
||||
import upsertHarnessModels from "./upsert-harness-models";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches one provider's model list into `harness_models`, and records
|
||||
* when it ran and why it failed, if it did. Returns how many models the
|
||||
* provider listed. Throws when the list couldn't be fetched.
|
||||
*/
|
||||
export default async function syncHarnessProviderModels({ provider }: Params) {
|
||||
if (!provider.id) {
|
||||
throw new Error(`Provider has no id`);
|
||||
}
|
||||
|
||||
const { config } = await resolveProviderConfig({ provider });
|
||||
|
||||
try {
|
||||
const models = await grabHarnessAdapter({
|
||||
kind: config.kind,
|
||||
}).listModels({ provider: config });
|
||||
|
||||
const count = await upsertHarnessModels({
|
||||
provider_id: provider.id,
|
||||
models,
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_providers SET last_synced_at = ?, sync_error = NULL, updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), Date.now(), provider.id],
|
||||
});
|
||||
|
||||
return count;
|
||||
} catch (error: unknown) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_providers SET sync_error = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [
|
||||
(error instanceof Error ? error.message : String(error)).slice(
|
||||
0,
|
||||
500,
|
||||
),
|
||||
Date.now(),
|
||||
provider.id,
|
||||
],
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import type { PublicHarnessProvider } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A provider row safe for the client: the key is dropped, only whether
|
||||
* one is saved (or set in the environment) is kept.
|
||||
*/
|
||||
export default function toPublicHarnessProvider({
|
||||
provider,
|
||||
}: Params): PublicHarnessProvider {
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
|
||||
return {
|
||||
id: provider.id || 0,
|
||||
kind: preset.value,
|
||||
name: provider.name || preset.title,
|
||||
base_url: provider.base_url || preset.base_url,
|
||||
enabled: provider.enabled != 0,
|
||||
has_key: Boolean(provider.api_key),
|
||||
has_env_key: Boolean(preset.key_env && process.env[preset.key_env]),
|
||||
needs_key: preset.needs_key,
|
||||
key_env: preset.key_env || undefined,
|
||||
last_synced_at: provider.last_synced_at || undefined,
|
||||
sync_error: provider.sync_error || undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
models: HarnessCatalogModel[];
|
||||
now?: number;
|
||||
};
|
||||
|
||||
const Columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"provider_id",
|
||||
"slug",
|
||||
"name",
|
||||
"free",
|
||||
"available",
|
||||
"input_modalities",
|
||||
"output_modalities",
|
||||
"supports_tools",
|
||||
"context_length",
|
||||
"max_output_tokens",
|
||||
"voices",
|
||||
"intelligence_score",
|
||||
"agentic_score",
|
||||
"released_at",
|
||||
"expires_at",
|
||||
"last_seen_at",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Rows per statement, well under SQLite's bound-variable limit.
|
||||
*/
|
||||
const ChunkSize = 50;
|
||||
|
||||
/**
|
||||
* Writes a provider's model list: new models are added, listed ones are
|
||||
* refreshed and marked available, and models it no longer lists are kept
|
||||
* but marked unavailable (a super admin's pick may point at them).
|
||||
*/
|
||||
export default async function upsertHarnessModels({
|
||||
provider_id,
|
||||
models,
|
||||
now = Date.now(),
|
||||
}: Params) {
|
||||
const unique = [...new Map(models.map((m) => [m.slug, m])).values()];
|
||||
|
||||
for (let i = 0; i < unique.length; i += ChunkSize) {
|
||||
const chunk = unique.slice(i, i + ChunkSize);
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO harness_models (${Columns.join(", ")})
|
||||
VALUES ${chunk.map(() => `(${Columns.map(() => "?").join(", ")})`).join(", ")}
|
||||
ON CONFLICT(provider_id, slug) DO UPDATE SET
|
||||
${Columns.filter((c) => c != "created_at" && c != "provider_id" && c != "slug")
|
||||
.map((c) => `${c} = excluded.${c}`)
|
||||
.join(", ")}`,
|
||||
values: chunk.flatMap((model) => [
|
||||
now,
|
||||
now,
|
||||
provider_id,
|
||||
model.slug,
|
||||
model.name || model.slug,
|
||||
model.free ? 1 : 0,
|
||||
1,
|
||||
JSON.stringify(model.input_modalities),
|
||||
JSON.stringify(model.output_modalities),
|
||||
model.supports_tools ? 1 : 0,
|
||||
model.context_length ?? null,
|
||||
model.max_output_tokens ?? null,
|
||||
model.voices ? JSON.stringify(model.voices) : null,
|
||||
model.intelligence_score ?? null,
|
||||
model.agentic_score ?? null,
|
||||
model.released_at ?? null,
|
||||
model.expires_at ?? null,
|
||||
now,
|
||||
]),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the model list: ${res.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_models SET available = 0, updated_at = ?
|
||||
WHERE provider_id = ? AND available = 1 AND (last_seen_at IS NULL OR last_seen_at < ?)`,
|
||||
values: [now, provider_id, now],
|
||||
});
|
||||
|
||||
return unique.length;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default async function MediaDataToArrayBuffer(
|
||||
if (body instanceof ReadableStream) {
|
||||
return Bun.readableStreamToArrayBuffer(body);
|
||||
}
|
||||
// Blob, BunFile, Request, Response — wrap in Response and use Bun's reader
|
||||
// Blob, BunFile, Request, Response: wrap in Response and use Bun's reader
|
||||
return new Response(body as BodyInit)
|
||||
.bytes()
|
||||
.then((b) => b.buffer as ArrayBuffer);
|
||||
|
||||
@@ -230,7 +230,13 @@ export default async function uploadAndRecordMedia({
|
||||
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
|
||||
...media,
|
||||
user_id: Number(user.id),
|
||||
is_primary: is_primary ? 1 : 0,
|
||||
/**
|
||||
* NULL, not 0, when not primary: the (user_id, is_primary) unique
|
||||
* constraint only means one primary image per user, and SQLite
|
||||
* lets NULLs repeat.
|
||||
*/
|
||||
is_primary: is_primary ? 1 : (null as any),
|
||||
is_private: is_private ? 1 : 0,
|
||||
media_write_path: media_write_path,
|
||||
media_thumbnail_write_path:
|
||||
media_type === "image" ? media_thumbnail_write_path : undefined,
|
||||
|
||||
Reference in New Issue
Block a user