evOo-local/apps/api/src/model-tiers.test.ts
Claude f28f9b7d55
feat(models): map model tiers per space and let bots and helpers ask for one
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
2026-10-02 22:07:32 +00:00

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" });
});
});