Merge media job fixes: GPU floor, settings test, data-sharing recipients

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:05:48 +00:00
commit 0b3e0638ca
No known key found for this signature in database
17 changed files with 591 additions and 28 deletions

View File

@ -29,6 +29,7 @@ function setup() {
},
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) },
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)),
},
@ -134,6 +135,28 @@ describe("consent grants", () => {
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
});
it("discloses the deployment's media servers before model use, keyed by server", async () => {
const { deps, actor } = setup();
const settings = {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
};
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext",
} as never);
deps.secrets = {
load: vi.fn(() => JSON.stringify(settings)),
} as unknown as RouterDeps["secrets"];
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
"ComfyUI (http://127.0.0.1:8188)",
"Transcription (http://127.0.0.1:8080)",
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -3,6 +3,9 @@ import { ORPCError } from "@orpc/server";
import {
aiRecipient,
cloudAgentsEnabled,
loadMediaSettings,
MEDIA_COMFYUI_PROVIDER_ID,
MEDIA_TRANSCRIPTION_PROVIDER_ID,
parseModelSecret,
selectConfiguredModel,
toStringRecord,
@ -210,6 +213,30 @@ export async function aiConsentStatus(
) {
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
}
if (uses.includes("model")) {
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
const media = await loadMediaSettings(deps);
if (media.comfyui) {
add(
aiRecipient({
provider: MEDIA_COMFYUI_PROVIDER_ID,
use: "model",
baseUrl: media.comfyui.baseUrl,
modelId: media.comfyui.imageModel,
}),
);
}
if (media.transcription) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "model",
baseUrl: media.transcription.baseUrl,
modelId: media.transcription.model,
}),
);
}
}
return {
scope: createHash("sha256")
.update(JSON.stringify([actor.userId, actor.spaceId]))

View File

@ -1,7 +1,7 @@
import { ORPCError } from "@orpc/server";
import type { MediaRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
type EncryptedSecretStore,
loadMediaSettings,
MediaJobRejectedError,
MediaSettingsInvalidError,

View File

@ -0,0 +1,278 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { MediaSettingsUpdate, MediaSettingsView } from "@rakazo/contracts";
import { Button, Field, FieldLabel, Input, Textarea } from "@rakazo/ui-web";
import { useEffect, useId, useState } from "react";
import { rpc } from "../lib/rpc";
import { SuccessPop } from "./ai/primitives";
type KeyDraft = { value: string; remove: boolean };
const emptyKey: KeyDraft = { value: "", remove: false };
/** Omitted keeps the saved key, empty removes it, anything else replaces it. */
function keyUpdate(draft: KeyDraft): string | undefined {
if (draft.value.trim()) return draft.value.trim();
return draft.remove ? "" : undefined;
}
function ApiKeyField({
saved,
draft,
disabled,
onChange,
}: {
saved: boolean;
draft: KeyDraft;
disabled: boolean;
onChange: (draft: KeyDraft) => void;
}) {
const { t } = useLingui();
const id = useId();
const showSaved = saved && !draft.remove;
return (
<Field className="mt-4">
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor={id}>
<Trans>API key</Trans>
</FieldLabel>
{showSaved && !draft.value ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={disabled}
onClick={() => onChange({ value: "", remove: true })}
>
<Trans>Remove</Trans>
</Button>
) : null}
</div>
<Input
id={id}
type="password"
autoComplete="new-password"
value={draft.value}
disabled={disabled}
placeholder={showSaved ? t`Saved` : t`Optional`}
onChange={(event) => onChange({ ...draft, value: event.target.value })}
/>
</Field>
);
}
/** The deployment owner's media servers: ComfyUI for generation, a transcription server. */
export function MediaSettings() {
const { t } = useLingui();
const ids = useId();
const [view, setView] = useState<MediaSettingsView | null>(null);
const [comfyUrl, setComfyUrl] = useState("");
const [comfyKey, setComfyKey] = useState<KeyDraft>(emptyKey);
const [imageModel, setImageModel] = useState("");
const [imageWorkflow, setImageWorkflow] = useState("");
const [videoWorkflow, setVideoWorkflow] = useState("");
const [transcriptionUrl, setTranscriptionUrl] = useState("");
const [transcriptionModel, setTranscriptionModel] = useState("");
const [transcriptionKey, setTranscriptionKey] = useState<KeyDraft>(emptyKey);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
function apply(next: MediaSettingsView) {
setView(next);
setComfyUrl(next.comfyui?.baseUrl ?? "");
setImageModel(next.comfyui?.imageModel ?? "");
setImageWorkflow(next.comfyui?.imageWorkflow ?? "");
setVideoWorkflow(next.comfyui?.videoWorkflow ?? "");
setTranscriptionUrl(next.transcription?.baseUrl ?? "");
setTranscriptionModel(next.transcription?.model ?? "");
setComfyKey(emptyKey);
setTranscriptionKey(emptyKey);
}
useEffect(() => {
let cancelled = false;
rpc.media.settings
.get()
.then((next) => {
if (!cancelled) apply(next);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
});
return () => {
cancelled = true;
};
}, [t]);
async function save() {
if (busy) return;
setBusy(true);
setSaved(false);
setError(null);
const update: MediaSettingsUpdate = {
comfyui: comfyUrl.trim()
? {
baseUrl: comfyUrl.trim(),
apiKey: keyUpdate(comfyKey),
imageModel,
imageWorkflow,
videoWorkflow,
}
: null,
transcription: transcriptionUrl.trim()
? {
baseUrl: transcriptionUrl.trim(),
model: transcriptionModel,
apiKey: keyUpdate(transcriptionKey),
}
: null,
};
try {
apply(await rpc.media.settings.update(update));
setSaved(true);
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
} finally {
setBusy(false);
}
}
const disabled = busy || view === null;
const advancedSaved = Boolean(
view?.comfyui?.imageModel || view?.comfyui?.imageWorkflow || view?.comfyui?.videoWorkflow,
);
const workflowClass = "max-h-48 overflow-auto font-mono text-xs";
return (
<div data-testid="media-settings" className="space-y-5">
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">ComfyUI</h3>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-comfy-url`}>
<Trans>Server URL</Trans>
</FieldLabel>
<Input
id={`${ids}-comfy-url`}
value={comfyUrl}
disabled={disabled}
placeholder="http://127.0.0.1:8188"
autoComplete="off"
onChange={(event) => setComfyUrl(event.target.value)}
/>
</Field>
<ApiKeyField
saved={Boolean(view?.comfyui?.hasApiKey)}
draft={comfyKey}
disabled={disabled}
onChange={setComfyKey}
/>
<details
key={advancedSaved ? "saved" : "empty"}
open={advancedSaved}
data-testid="media-settings-advanced"
className="group mt-4"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-ui-body text-muted-foreground">
<span>
<Trans>Advanced</Trans>
</span>
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
›
</span>
</summary>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-image-model`}>
<Trans>Image model</Trans>
</FieldLabel>
<Input
id={`${ids}-image-model`}
value={imageModel}
disabled={disabled}
placeholder={t`Optional`}
autoComplete="off"
onChange={(event) => setImageModel(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-image-workflow`}>
<Trans>Image workflow</Trans>
</FieldLabel>
<Textarea
id={`${ids}-image-workflow`}
className={workflowClass}
value={imageWorkflow}
disabled={disabled}
placeholder={t`Optional`}
spellCheck={false}
onChange={(event) => setImageWorkflow(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-video-workflow`}>
<Trans>Video workflow</Trans>
</FieldLabel>
<Textarea
id={`${ids}-video-workflow`}
className={workflowClass}
value={videoWorkflow}
disabled={disabled}
placeholder={t`Optional`}
spellCheck={false}
onChange={(event) => setVideoWorkflow(event.target.value)}
/>
</Field>
</details>
</section>
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Transcription</Trans>
</h3>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-transcription-url`}>
<Trans>Server URL</Trans>
</FieldLabel>
<Input
id={`${ids}-transcription-url`}
value={transcriptionUrl}
disabled={disabled}
placeholder="http://127.0.0.1:8080/v1"
autoComplete="off"
onChange={(event) => setTranscriptionUrl(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-transcription-model`}>
<Trans>Model</Trans>
</FieldLabel>
<Input
id={`${ids}-transcription-model`}
value={transcriptionModel}
disabled={disabled}
placeholder={t`Optional`}
autoComplete="off"
onChange={(event) => setTranscriptionModel(event.target.value)}
/>
</Field>
<ApiKeyField
saved={Boolean(view?.transcription?.hasApiKey)}
draft={transcriptionKey}
disabled={disabled}
onChange={setTranscriptionKey}
/>
</section>
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
<div className="flex items-center gap-3">
<Button className="rounded-full" disabled={disabled} onClick={() => void save()}>
{busy ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
</Button>
{saved ? <SuccessPop label={t`Saved`} /> : null}
</div>
</div>
);
}

View File

@ -3,6 +3,7 @@ import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import {
Brain,
Clapperboard,
CloudDownload,
Cpu,
Gauge,
@ -16,6 +17,7 @@ import {
} from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { MediaSettings } from "../components/MediaSettings";
import { UserProfileSettings } from "../components/UserProfileSettings";
import type { TradingBotUiState } from "../lib/trading-bot-status";
import {
@ -37,6 +39,7 @@ export type SettingsSection =
| "memory"
| "profile"
| "voice"
| "media"
| "usage"
| "computer"
| "updates"
@ -111,6 +114,7 @@ export function SettingsOverlay({
{ id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 },
...(isDeploymentOwner ? [{ id: "media" as const, label: t`Media`, icon: Clapperboard }] : []),
{ id: "usage", label: t`Usage`, icon: Gauge },
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
{ id: "updates", label: t`Updates`, icon: CloudDownload },
@ -250,6 +254,7 @@ export function SettingsOverlay({
/>
) : null}
{section === "profile" ? <UserProfileSettings /> : null}
{section === "media" && isDeploymentOwner ? <MediaSettings /> : null}
{section === "usage" ? (
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
) : null}

View File

@ -41,6 +41,18 @@ describe("mobile AI recipient disclosure", () => {
expect(recipient.name).toBe("Embeddings (http://192.168.1.20:8080)");
expect(recipient.privacyUrl).toBeUndefined();
});
it("names the media servers that media jobs send files to", () => {
expect(
aiRecipient({ provider: "comfyui", use: "model", baseUrl: "http://127.0.0.1:8188" })?.name,
).toBe("ComfyUI (http://127.0.0.1:8188)");
expect(
aiRecipient({
provider: "media-transcription",
use: "model",
baseUrl: "http://127.0.0.1:8080/v1",
})?.name,
).toBe("Transcription (http://127.0.0.1:8080)");
});
it("supports local models without a hosted dependency", () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);

View File

@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import type { AiDataUse, AiRecipient } from "@rakazo/contracts";
import { BUILTIN_MEMORY_PROVIDER_ID } from "./builtin-memory-provider.js";
import { MEDIA_COMFYUI_PROVIDER_ID, MEDIA_TRANSCRIPTION_PROVIDER_ID } from "./media-settings.js";
import { localBaseUrl } from "./pi-local-provider.js";
import { listPiCatalog } from "./pi-models.js";
import { voiceCatalogEntry } from "./voice-factory.js";
@ -59,5 +60,7 @@ function recipientName(provider: string, use: AiDataUse) {
return provider;
}
if (provider === "cursor") return "Cursor";
if (provider === MEDIA_COMFYUI_PROVIDER_ID) return "ComfyUI";
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
return listPiCatalog().find((entry) => entry.provider === provider)?.providerName ?? provider;
}

View File

@ -11,18 +11,14 @@ import type {
} from "@rakazo/adapter-kit";
import { MediaJobDeferredError, mediaRunJobKey } from "@rakazo/adapter-kit";
import type { MediaJobBlock, MessageBlock } from "@rakazo/contracts";
import { clearThread, createDb, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { clearThread, createDb } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { storeArtifactFromFile } from "./artifact-files.js";
import { LocalArtifactStore } from "./artifacts.js";
import {
createMediaRuntime,
MEDIA_WAKE_TRIGGER,
MediaJobRejectedError,
type MediaOperationRegistration,
type MediaRuntimeOptions,
} from "./media-runtime.js";
import type { MediaOperationRegistration, MediaRuntimeOptions } from "./media-runtime.js";
import { createMediaRuntime, MEDIA_WAKE_TRIGGER, MediaJobRejectedError } from "./media-runtime.js";
const describePostgres =
process.env.VERIFY_DATABASE && process.env.DATABASE_URL ? describe.sequential : describe.skip;
@ -424,6 +420,38 @@ describePostgres("media job runtime (PostgreSQL, fake operations)", () => {
expect((await ctx.job(jobId)).status).toBe("done");
});
it("runs on a card smaller than the floor once it is nearly free, and fails on far too small", async () => {
let free = 5_000;
const ctx = await setup({
gpu: {
minVramMb: 12_000,
status: async () => ({ vramFreeMb: free, vramTotalMb: 8_192 }),
},
});
const { jobId } = await ctx.start({ prompt: "fox" });
await ctx.runtime.runJob(jobId);
expect(await ctx.job(jobId)).toMatchObject({ status: "waiting", deferrals: 1 });
free = 7_600;
await prisma.mediaJob.update({ where: { id: jobId }, data: { nextAttemptAt: new Date() } });
await ctx.runtime.runJob(jobId);
expect((await ctx.job(jobId)).status).toBe("done");
const tiny = await setup({
gpu: {
minVramMb: 12_000,
status: async () => ({ vramFreeMb: 4_000, vramTotalMb: 4_096 }),
},
});
const small = await tiny.start({ prompt: "fox" });
await tiny.runtime.runJob(small.jobId);
expect(tiny.handlerCalls).toHaveLength(0);
expect(await tiny.job(small.jobId)).toMatchObject({
status: "failed",
error: "The GPU does not have enough memory for this job.",
deferrals: 0,
});
});
it("cancels a queued job at once and a running job through the lease heartbeat", async () => {
const ctx = await setup({
run: async (context) => {

View File

@ -27,24 +27,24 @@ import {
mediaRunJobKey,
runContinueJob,
} from "@rakazo/adapter-kit";
import type {
MediaJobBlock,
MediaJobStatus,
MediaOperation,
MediaOutput,
MessageBlock,
} from "@rakazo/contracts";
import {
attachmentKindOf,
MEDIA_JOB_ACTIVE_STATUSES,
type MediaJobBlock,
type MediaJobStatus,
type MediaOperation,
MediaOperationSchema,
type MediaOutput,
type MessageBlock,
} from "@rakazo/contracts";
import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core";
import type { MediaJob, PrismaClient, ThreadEvents } from "@rakazo/db";
import {
appendEventInTransaction,
createThreadMessageInTransaction,
type MediaJob,
Prisma,
type PrismaClient,
type ThreadEvents,
withTransactionRetry,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -143,6 +143,7 @@ const TIMEOUT_ERROR = "The job took too long.";
const FAILED_ERROR = "The job failed.";
const MISSING_SOURCE_ERROR = "A source file is no longer available.";
const TOO_LARGE_ERROR = "The result is too large to keep.";
const GPU_TOO_SMALL_ERROR = "The GPU does not have enough memory for this job.";
type AbortReason = "cancel" | "lease" | "abandoned" | "timeout";
@ -191,6 +192,17 @@ function nonNegativeInt(value: number | undefined): number | undefined {
return rounded >= 0 ? rounded : undefined;
}
/**
* Free VRAM a job waits for: the operation's floor, capped below the card's total so a smaller
* card that can still run it (with offloading) does not wait forever. Null when the card is far
* too small to try.
*/
export function requiredVramMb(minVramMb: number, gpu: GpuStatus): number | null {
if (gpu.vramTotalMb > 0 && gpu.vramTotalMb * 2 < minVramMb) return null;
if (gpu.vramTotalMb <= 0) return minVramMb;
return Math.min(minVramMb, Math.floor(gpu.vramTotalMb * 0.9));
}
function insideDir(dir: string, file: string): string {
const resolved = path.resolve(dir, file);
if (!resolved.startsWith(`${path.resolve(dir)}${path.sep}`)) {
@ -754,7 +766,9 @@ export function createMediaRuntime(
const gpu = await registration.gpu
.status(AbortSignal.any([signal, AbortSignal.timeout(GPU_STATUS_TIMEOUT_MS)]))
.catch(() => undefined);
if (gpu && gpu.vramFreeMb < registration.gpu.minVramMb) {
const needed = gpu ? requiredVramMb(registration.gpu.minVramMb, gpu) : undefined;
if (needed === null) throw new MediaJobRejectedError(GPU_TOO_SMALL_ERROR);
if (gpu && needed !== undefined && gpu.vramFreeMb < needed) {
throw new MediaJobDeferredError("Waiting for free GPU memory", GPU_RETRY_MS);
}
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
loadMediaSettings,
MEDIA_SETTINGS_ID,
MediaSettingsInvalidError,
mediaSettingsView,
normalizeComfyUiBaseUrl,
normalizeTranscriptionBaseUrl,
saveMediaSettings,
} from "./media-settings.js";
import { EncryptedSecretStore } from "./secrets.js";
function store() {
const rows = new Map<string, { id: string; ciphertext: string }>();
const prisma = {
integrationProviderConfig: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
upsert: vi.fn(
async ({ where, create }: { where: { id: string }; create: { ciphertext: string } }) => {
const row = { id: where.id, ciphertext: create.ciphertext };
rows.set(where.id, row);
return row;
},
),
deleteMany: vi.fn(async ({ where }: { where: { id: string } }) => {
rows.delete(where.id);
return { count: 1 };
}),
},
};
const secrets = new EncryptedSecretStore("test-encryption-key-for-media-settings");
return { deps: { prisma: prisma as never, secrets }, rows, prisma, secrets };
}
describe("media settings", () => {
it("normalizes server URLs and refuses ones that are not allowed", () => {
expect(normalizeComfyUiBaseUrl(" http://127.0.0.1:8188/ ")).toBe("http://127.0.0.1:8188");
expect(normalizeComfyUiBaseUrl("http://192.168.1.20:8188/comfy/?x=1#y")).toBe(
"http://192.168.1.20:8188/comfy",
);
expect(normalizeTranscriptionBaseUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080/v1");
expect(normalizeTranscriptionBaseUrl("http://127.0.0.1:8080/v1/")).toBe(
"http://127.0.0.1:8080/v1",
);
for (const url of ["ftp://127.0.0.1", "http://169.254.169.254", "not a url"]) {
expect(() => normalizeComfyUiBaseUrl(url), url).toThrow();
}
expect(() => normalizeComfyUiBaseUrl("http://user:pass@127.0.0.1:8188")).toThrow("credentials");
});
it("stores the settings encrypted and returns a view without keys", async () => {
const { deps, rows } = store();
const view = await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key" },
transcription: null,
});
expect(view).toEqual({
comfyui: { baseUrl: "http://127.0.0.1:8188", hasApiKey: true },
transcription: null,
});
expect(rows.get(MEDIA_SETTINGS_ID)?.ciphertext).not.toContain("comfy-key");
expect(await loadMediaSettings(deps)).toEqual({
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key" },
transcription: null,
});
expect(
mediaSettingsView({
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", apiKey: "k", model: "m" },
}),
).toEqual({
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "m", hasApiKey: true },
});
});
it("decrypts once per saved version", async () => {
const { deps, secrets } = store();
await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188" },
transcription: null,
});
const load = vi.spyOn(secrets, "load");
await loadMediaSettings(deps);
await loadMediaSettings(deps);
expect(load).not.toHaveBeenCalled();
});
it("keeps a saved key only for the same server, removes it when cleared", async () => {
const { deps } = store();
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080", apiKey: "t-key" },
});
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBe("t-key");
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBeUndefined();
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090", apiKey: "new" },
});
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090", apiKey: "" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBeUndefined();
});
it("refuses invalid settings with a message for the owner and removes empty ones", async () => {
const { deps, rows } = store();
await expect(
saveMediaSettings(deps, {
comfyui: { baseUrl: "http://169.254.169.254" },
transcription: null,
}),
).rejects.toBeInstanceOf(MediaSettingsInvalidError);
expect(rows.size).toBe(0);
await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188" },
transcription: null,
});
expect(await saveMediaSettings(deps, { comfyui: null, transcription: null })).toEqual({
comfyui: null,
transcription: null,
});
expect(rows.size).toBe(0);
expect(await loadMediaSettings(deps)).toEqual({ comfyui: null, transcription: null });
});
});

View File

@ -9,6 +9,10 @@ import {
} from "./openai-compatible-url.js";
import type { EncryptedSecretStore } from "./secrets.js";
/** Recipient ids for AI data-sharing disclosures of the media servers. */
export const MEDIA_COMFYUI_PROVIDER_ID = "comfyui";
export const MEDIA_TRANSCRIPTION_PROVIDER_ID = "media-transcription";
/** The IntegrationProviderConfig row that stores the media backends, encrypted as one record. */
export const MEDIA_SETTINGS_ID = "media";
const RECORD_ID = `integration-provider:${MEDIA_SETTINGS_ID}`;

View File

@ -70,6 +70,36 @@ describe("media tools", () => {
expect(selectBuiltinToolsForRun(base).some((tool) => isMediaToolName(tool.name))).toBe(false);
});
it("exposes a media tool only for operations the space can run, within the bot allowlist", () => {
const base = {
graphicalToolsAllowed: true,
groupId: null,
trigger: "user",
semanticMemoryEnabled: false,
messagingChannelRun: false,
bot: { kind: "specialist", restrictBuiltinTools: false, allowedBuiltinTools: [] as string[] },
};
const mediaNames = (options: Partial<Parameters<typeof selectBuiltinToolsForRun>[0]>) =>
selectBuiltinToolsForRun({ ...base, ...options })
.map((tool) => tool.name)
.filter((name) => isMediaToolName(name));
expect(mediaNames({})).toEqual([]);
expect(mediaNames({ mediaCapabilities: none })).toEqual([]);
expect(mediaNames({ mediaCapabilities: { ...none, generate_image: true } })).toEqual([
"generate_image",
]);
expect(
mediaNames({
mediaCapabilities: { ...none, generate_image: true, edit_image: true },
bot: {
kind: "specialist",
restrictBuiltinTools: true,
allowedBuiltinTools: ["edit_image"],
},
}),
).toEqual(["edit_image"]);
});
it("adds continue_after to every media tool schema", () => {
expect(
mediaToolInputSchema({

View File

@ -1,5 +1,6 @@
import type { ConnectorTool, MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
import { type MediaOperation, MediaOperationSchema } from "@rakazo/contracts";
import type { MediaOperation } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import { MediaJobRejectedError } from "./media-runtime.js";
/** Media tools are named exactly like their operations and dispatched by one module. */

View File

@ -8,7 +8,10 @@ const comfyui = (workflows: { imageWorkflow?: string; videoWorkflow?: string })
describe("ComfyUI settings", () => {
it("refuses custom workflows the provider could not fill", async () => {
const deps = {} as Parameters<typeof saveMediaSettings>[0];
// Nothing saved yet; validation fails before anything is stored.
const deps = {
prisma: { integrationProviderConfig: { findUnique: async () => null } },
} as unknown as Parameters<typeof saveMediaSettings>[0];
await expect(
saveMediaSettings(
deps,

View File

@ -86,6 +86,7 @@ describe("foreground AI consent", () => {
expect(aiDataUsesForProcedure("threads/get")).toEqual([]);
expect(aiDataUsesForProcedure("threads/send")).toEqual(["model", "memory"]);
expect(aiDataUsesForProcedure("voice/prepare")).toEqual(["voice"]);
expect(aiDataUsesForProcedure("media/jobs/start")).toEqual(["model"]);
expect(
aiDataUsesForProcedure("routines/update", { routineId: "routine", active: false }),
).toEqual([]);

View File

@ -32,6 +32,7 @@ export function aiDataUsesForProcedure(procedure: string, input?: unknown): AiDa
].includes(path)
)
return ["model", "memory"];
if (path === "media/jobs/start") return ["model"];
if (["voice/prepare", "voice/speak", "voice/transcribe"].includes(path)) return ["voice"];
return [];
}

View File

@ -2,13 +2,10 @@ import { mkdtempSync, rmSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator, type MediaOperationRegistration } from "@rakazo/adapters";
import {
type MediaJobBlock,
MediaOutputSchema,
type MediaSettingsView,
type MessageBlock,
} from "@rakazo/contracts";
import type { MediaOperationRegistration } from "@rakazo/adapters";
import { ComposioEmulator } from "@rakazo/adapters";
import type { MediaJobBlock, MediaSettingsView, MessageBlock } from "@rakazo/contracts";
import { MediaOutputSchema } from "@rakazo/contracts";
import { createThreadMessage } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
@ -154,7 +151,7 @@ describeWithDatabase("media RPCs (PostgreSQL)", () => {
baseUrl: "http://127.0.0.1:8188/",
apiKey: "comfy-test-key",
imageModel: "sd_xl_base_1.0.safetensors",
imageWorkflow: '{"3":{"class_type":"KSampler","inputs":{}}}',
imageWorkflow: '{"6":{"class_type":"CLIPTextEncode","inputs":{"text":"{{prompt}}"}}}',
videoWorkflow: "",
},
transcription: { baseUrl: "http://127.0.0.1:8080", model: "whisper-1", apiKey: "" },