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
112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
import { serializeModelSecret } from "@rakazo/adapters";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { connectedOpenAiCompatibleServer, setModelTier } from "./model-tiers.js";
|
|
|
|
const actor = { userId: "user", spaceId: "space" } as Actor;
|
|
|
|
function setup(provider: string | null) {
|
|
const upsert = vi.fn(async () => ({}));
|
|
const deleteMany = vi.fn(async () => ({ count: 1 }));
|
|
const credentialFindFirst = vi.fn(async () =>
|
|
provider ? { provider, secretId: "router-secret" } : null,
|
|
);
|
|
const prisma = {
|
|
userModelCredential: { findFirst: credentialFindFirst },
|
|
spaceModelTier: { upsert, deleteMany, findMany: vi.fn(async () => []) },
|
|
secret: {
|
|
findFirst: vi.fn(async () => ({ id: "router-secret", ciphertext: "fake-ciphertext" })),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
return { prisma, upsert, deleteMany, credentialFindFirst };
|
|
}
|
|
|
|
describe("model tier settings", () => {
|
|
it("maps a tier to any model an OpenAI-compatible server serves", async () => {
|
|
const { prisma, upsert, credentialFindFirst } = setup("openai-compatible");
|
|
|
|
await setModelTier(prisma, actor, {
|
|
tier: "code",
|
|
model: { credentialId: "router", modelId: "router-code" },
|
|
});
|
|
|
|
expect(credentialFindFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: "router", userId: "user" } }),
|
|
);
|
|
expect(upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
create: expect.objectContaining({
|
|
tier: "code",
|
|
credentialId: "router",
|
|
modelId: "router-code",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refuses another user's credential and models a catalog provider does not run", async () => {
|
|
const missing = setup(null);
|
|
await expect(
|
|
setModelTier(missing.prisma, actor, {
|
|
tier: "fast",
|
|
model: { credentialId: "someone-else", modelId: "router-fast" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
expect(missing.upsert).not.toHaveBeenCalled();
|
|
|
|
const catalog = setup("xai");
|
|
await expect(
|
|
setModelTier(catalog.prisma, actor, {
|
|
tier: "fast",
|
|
model: { credentialId: "xai", modelId: "not-a-model" },
|
|
}),
|
|
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
await setModelTier(catalog.prisma, actor, {
|
|
tier: "fast",
|
|
model: { credentialId: "xai", modelId: "grok-4.6" },
|
|
});
|
|
expect(catalog.upsert).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("clears a tier without looking up a credential", async () => {
|
|
const { prisma, deleteMany, credentialFindFirst } = setup("openai-compatible");
|
|
|
|
await expect(setModelTier(prisma, actor, { tier: "standard", model: null })).resolves.toEqual(
|
|
[],
|
|
);
|
|
|
|
expect(credentialFindFirst).not.toHaveBeenCalled();
|
|
expect(deleteMany).toHaveBeenCalledWith({
|
|
where: { spaceId: "space", userId: "user", tier: "standard" },
|
|
});
|
|
});
|
|
|
|
it("probes a connected server with its stored address and key, for its owner only", async () => {
|
|
const { prisma, credentialFindFirst } = setup("openai-compatible");
|
|
const secrets = {
|
|
load: vi.fn(() =>
|
|
serializeModelSecret({
|
|
kind: "openai_compatible",
|
|
baseUrl: "https://router.example.test/v1",
|
|
apiKey: "fake-router-key",
|
|
}),
|
|
),
|
|
};
|
|
|
|
await expect(
|
|
connectedOpenAiCompatibleServer({ prisma, secrets }, actor, "router"),
|
|
).resolves.toEqual({ baseUrl: "https://router.example.test/v1", apiKey: "fake-router-key" });
|
|
expect(credentialFindFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "router", userId: "user", provider: "openai-compatible" },
|
|
}),
|
|
);
|
|
|
|
const missing = setup(null);
|
|
await expect(
|
|
connectedOpenAiCompatibleServer({ prisma: missing.prisma, secrets }, actor, "router"),
|
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
|
});
|
|
});
|