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:
commit
0b3e0638ca
@ -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";
|
||||
|
||||
@ -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]))
|
||||
|
||||
@ -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,
|
||||
|
||||
278
apps/web/src/components/MediaSettings.tsx
Normal file
278
apps/web/src/components/MediaSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
136
packages/adapters/src/media-settings.test.ts
Normal file
136
packages/adapters/src/media-settings.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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}`;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
@ -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: "" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user