A space can now map the tiers fast, standard and code to connected models, including several models served by one OpenAI-compatible router endpoint (one credential, many model ids). A bot can ask for a tier, and a single run_subagent call can ask for one. A tier with no model mapped falls back to the usual model choice, so nothing breaks before tiers are set up. - db: SpaceModelTier repo (list, find, set or clear); tier mappings are copied into a new space like model and voice preferences; createBot carries a tier, so duplicates keep it and spawned bots inherit it. - contracts/api: models.tiers.list and models.tiers.set; models.probeOpenAiCompatible can list a connected server's models by credential id with its stored address and key; bots.update accepts modelTier and keeps a pinned model and a tier mutually exclusive, so a model picked on a surface that knows no tiers (mobile) is never silently overridden. Thinking-level validation follows the tier's model. AI consent status discloses tier models; desktop local settings allow the tier procedures. - adapters: selectConfiguredModel runs a mapped tier ahead of the pinned model and the space default; the executor resolves bot tiers for runs and model lookups, and helper tiers through resolveModelTier. run_subagent takes model_tier; an explicit model wins over it. - web: a collapsed Tiers section in model settings (shown once a model is connected) and mapped tiers as choices in the bot Model select. - docs: tiers in self-host model setup. - tests: resolution order and fallbacks (selection, executor, Pi helper), repo, API and contract units, authorization and offline Pi Postgres suites, and a model-tiers web E2E spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
171 lines
7.1 KiB
TypeScript
171 lines
7.1 KiB
TypeScript
import { serializeModelSecret } from "@rakazo/adapters";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import { AI_DISCLOSURE_VERSION } from "@rakazo/contracts";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./thread-target.js", () => ({
|
|
resolveThreadTarget: async () => ({ kind: "bot", botId: "bot" }),
|
|
}));
|
|
|
|
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
|
import type { RouterDeps } from "./router.js";
|
|
|
|
function setup() {
|
|
const upsert = vi.fn(async () => ({}));
|
|
const deps = {
|
|
env: { agentRuntime: "scripted" },
|
|
prisma: {
|
|
spaceModelPreference: { findMany: vi.fn(async () => []), findFirst: vi.fn(async () => null) },
|
|
spaceModelTier: { findMany: vi.fn(async () => []) },
|
|
secret: { findMany: vi.fn(async () => []) },
|
|
bot: { findMany: vi.fn(async () => []) },
|
|
spaceVoicePreference: {
|
|
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
|
|
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
|
|
},
|
|
spaceMemoryConfig: {
|
|
findUnique: vi.fn(async () => null),
|
|
count: vi.fn(async () => 0),
|
|
},
|
|
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
|
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
|
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
|
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
|
},
|
|
} as unknown as RouterDeps;
|
|
return { deps, upsert, actor: { userId: "user", spaceId: "space" } as Actor };
|
|
}
|
|
describe("consent grants", () => {
|
|
it("includes reachable helper models and cloud services for a targeted send", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.agentRuntime = "pi";
|
|
deps.cloudAgent = {
|
|
key: "test",
|
|
spaceId: actor.spaceId,
|
|
provider: { describe: () => ({ id: "cursor", capabilities: { offline: false } }) },
|
|
} as RouterDeps["cloudAgent"];
|
|
vi.mocked(deps.prisma.spaceModelPreference.findMany).mockResolvedValue([
|
|
{
|
|
modelId: "helper-model",
|
|
isDefault: false,
|
|
credential: { provider: "anthropic", secretId: "test-secret" },
|
|
},
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
expect(status.recipients.map((recipient) => recipient.name)).toEqual(
|
|
expect.arrayContaining(["Anthropic", "Cursor"]),
|
|
);
|
|
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
|
expect(deps.prisma.bot.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: expect.objectContaining({ id: { in: ["bot"] } }) }),
|
|
);
|
|
});
|
|
it("discloses the model a bot's tier maps to on a router endpoint", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.agentRuntime = "pi";
|
|
deps.secrets = {
|
|
load: vi.fn(() =>
|
|
serializeModelSecret({ kind: "openai_compatible", baseUrl: "http://127.0.0.1:8080/v1" }),
|
|
),
|
|
} as unknown as RouterDeps["secrets"];
|
|
vi.mocked(deps.prisma.spaceModelTier.findMany).mockResolvedValue([
|
|
{
|
|
tier: "code",
|
|
credentialId: "router",
|
|
modelId: "router-code",
|
|
credential: {
|
|
id: "router",
|
|
userId: actor.userId,
|
|
provider: "openai-compatible",
|
|
label: "Router",
|
|
secretId: "router-secret",
|
|
supportsImages: false,
|
|
createdAt: new Date(0),
|
|
updatedAt: new Date(0),
|
|
preferences: [],
|
|
},
|
|
},
|
|
] as never);
|
|
vi.mocked(deps.prisma.bot.findMany).mockResolvedValue([
|
|
{ modelProvider: null, modelId: null, thinkingLevel: null, modelTier: "code" },
|
|
] as never);
|
|
vi.mocked(deps.prisma.secret.findMany).mockResolvedValue([
|
|
{ id: "router-secret", ciphertext: "fake-ciphertext" },
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
expect(status.recipients).toEqual([
|
|
expect.objectContaining({
|
|
name: expect.stringContaining("http://127.0.0.1:8080"),
|
|
detail: "Model: router-code.",
|
|
}),
|
|
]);
|
|
});
|
|
it("discloses only the selected voice provider before a voice action", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
|
|
{ credential: { provider: "openai" } },
|
|
{ credential: { provider: "elevenlabs" } },
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toBe("OpenAI");
|
|
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
|
});
|
|
it("discloses memory provider details to space owners", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceMemoryConfig.findUnique).mockResolvedValue({
|
|
provider: "supermemory",
|
|
settings: { baseUrl: "http://127.0.0.1:8123" },
|
|
} as never);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toContain("Supermemory");
|
|
expect(status.recipients[0]?.name).toContain("127.0.0.1:8123");
|
|
expect(deps.prisma.spaceMemoryConfig.count).not.toHaveBeenCalled();
|
|
});
|
|
it("redacts space memory provider details for non-owner members", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceMember.findUnique).mockResolvedValue({ role: "member" } as never);
|
|
vi.mocked(deps.prisma.spaceMemoryConfig.count).mockResolvedValue(1);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toBe("Space memory");
|
|
expect(JSON.stringify(status.recipients)).not.toContain("127.0.0.1");
|
|
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
|
|
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
it("returns an operator policy without requiring a hosted provider", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
|
expect((await aiConsentStatus(deps, actor)).privacyUrl).toBe("https://example.com/privacy");
|
|
});
|
|
it("rejects stale disclosures, unknown recipients, and a changed account or Space", async () => {
|
|
const { deps, actor, upsert } = setup();
|
|
const current = await aiConsentStatus(deps, actor);
|
|
const input = {
|
|
scope: current.scope,
|
|
version: current.version,
|
|
keys: [current.recipients[0]!.key],
|
|
};
|
|
for (const invalid of [
|
|
{ ...input, version: "old" },
|
|
{ ...input, scope: "different-account" },
|
|
{ ...input, keys: ["unknown"] },
|
|
]) {
|
|
await expect(allowAiConsent(deps, actor, invalid)).rejects.toThrow("changed");
|
|
}
|
|
expect(upsert).not.toHaveBeenCalled();
|
|
await allowAiConsent(deps, actor, input);
|
|
expect(upsert).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
create: {
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
recipientKey: input.keys[0],
|
|
version: AI_DISCLOSURE_VERSION,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|