lib/ai-model-config.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import {
AI_MODEL_CONFIG_FIELDS,
AI_MODEL_CONFIG_GROUPS,
aiModelFallback,
roleModelKey,
} from "@/lib/ai-model-config";
import en from "@/messages/en.json";
import fr from "@/messages/fr.json";
/**
* The contract between the REGISTER (`AI_MODEL_CONFIG_FIELDS`) and the catalogs:
* any setting rendered in `/admin` has a label in both languages.
*
* Why a test. The dashboard assembles its keys at runtime
* (`t(\`fields.${field.key}.label\`)`, cf. components/admin/admin-models-dashboard.tsx),
* so the key is cast to `MessageKey<"Admin">` and the compiler no longer checks
* for its existence. The i18n contract test checks the placeholders of the
* keys CALLED in hard form — an assembled key also escapes it. Between the two,
* a line added to the registry without its catalog entry does not break anything during the
* compilation and raises `MISSING_MESSAGE` on the screen, in console, at the admin.
* It happened with `demo_dictation_enabled` (MIN-150).
*
* The description remains optional: a field whose label is sufficient does not have
*, and the dashboard manages it (`t.has`). What is not optional is that it exists on BOTH sides as long as it exists on one.
*/
const CATALOGS = { en, fr } as const;
type FieldEntry = { label?: string; desc?: string };
type GroupEntry = { title?: string; desc?: string };
describe("AI settings registry and i18n catalogs", () => {
for (const [locale, messages] of Object.entries(CATALOGS)) {
const fields = messages.Admin.fields as Record<string, FieldEntry>;
const groups = messages.Admin.groups as Record<string, GroupEntry>;
it(`labels every setting (${locale})`, () => {
const missing = AI_MODEL_CONFIG_FIELDS.filter((f) => !f.adminLabel && !fields[f.key]?.label).map(
(f) => f.key,
);
expect(missing).toEqual([]);
});
it(`titles every group (${locale})`, () => {
const missing = AI_MODEL_CONFIG_GROUPS.filter((g) => !groups[g]?.title);
expect(missing).toEqual([]);
});
it(`has no orphan field label (${locale})`, () => {
const known = new Set(AI_MODEL_CONFIG_FIELDS.map((f) => f.key));
expect(Object.keys(fields).filter((k) => !known.has(k))).toEqual([]);
});
}
it("describes a setting in both languages or neither", () => {
const enFields = en.Admin.fields as Record<string, FieldEntry>;
const frFields = fr.Admin.fields as Record<string, FieldEntry>;
const diverging = AI_MODEL_CONFIG_FIELDS.filter(
(f) => Boolean(enFields[f.key]?.desc) !== Boolean(frFields[f.key]?.desc),
).map((f) => f.key);
expect(diverging).toEqual([]);
});
it("assigns non-text catalog capabilities to the matching role fields", () => {
const byKey = new Map(AI_MODEL_CONFIG_FIELDS.map((field) => [field.key, field]));
expect(byKey.get("transcription_model")?.catalogCapability).toBe("transcription");
expect(byKey.get("issue_embedding_model")?.catalogCapability).toBe("embedding");
expect(byKey.get("assistant_model")?.catalogCapability).toBeUndefined();
expect(byKey.get("byok_default_openai_transcription_model")?.catalogCapability).toBe("transcription");
expect(byKey.get("byok_default_google_issue_embedding_model")?.catalogCapability).toBe("embedding");
});
it("keeps one model field per role, never one per task (MIN-954)", () => {
const models = AI_MODEL_CONFIG_FIELDS.filter((field) => field.kind === "model").map((field) => field.key);
expect(models).toEqual(["assistant_model", "issue_embedding_model", "transcription_model", "jev_model"]);
expect(AI_MODEL_CONFIG_FIELDS.some((field) => field.key.startsWith("category_"))).toBe(false);
});
it("maps every former task key to its role and its role's default", () => {
for (const key of ["smart_fill_model", "conversation_title_model", "dictate_model", "faq_ask_model", "merge_message_model", "feedback_analysis_model"]) {
expect(roleModelKey(key)).toBe("assistant_model");
expect(aiModelFallback(key)).toBe(aiModelFallback("assistant_model"));
}
expect(roleModelKey("feedback_embedding_model")).toBe("issue_embedding_model");
expect(roleModelKey("transcription_model")).toBe("transcription_model");
expect(roleModelKey("jev_model")).toBe("jev_model");
});
it("gives personal keys one default per provider and role", () => {
const keys = new Set(AI_MODEL_CONFIG_FIELDS.map((field) => field.key));
expect(keys.has("byok_default_anthropic_assistant_model")).toBe(true);
expect(keys.has("byok_default_anthropic_smart_fill_model")).toBe(false);
expect(keys.has("byok_default_anthropic_transcription_model")).toBe(false);
expect(keys.has("byok_default_google_feedback_embedding_model")).toBe(false);
expect(keys.has("byok_default_google_issue_embedding_model")).toBe(true);
const byKey = new Map(AI_MODEL_CONFIG_FIELDS.map((field) => [field.key, field]));
expect(byKey.get("byok_default_openai_issue_embedding_model")?.fallback).toBe("text-embedding-3-small");
expect(byKey.get("byok_default_google_issue_embedding_model")?.fallback).toBe("gemini-embedding-001");
});
it("defaults the OpenAI transcription fallback to a non-deprecated model", () => {
// `gpt-4o-mini-transcribe` was deprecated on 2026-08-26 (shutdown
// 2027-02-26); the OpenAI changelog names `gpt-transcribe` as its
// replacement on the same /audio/transcriptions endpoint.
const byKey = new Map(AI_MODEL_CONFIG_FIELDS.map((field) => [field.key, field]));
expect(byKey.get("byok_default_openai_transcription_model")?.fallback).toBe("gpt-transcribe");
});
});