Ctrl K
ComunidadGuías…

lib/ai-model-config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * The AI knobs an admin can tune from the dashboard (`/admin`) — the single
 * source of truth shared by the admin API (allowed keys + validation) and the
 * admin UI (fields to render). Each entry maps 1:1 to a row in the `app_config`
 * key/value table (read/written via `lib/server/app-config.ts`).
 *
 * NO server-only imports here: this module is pulled into the client dashboard.
 * Keep it a plain data registry.
 *
 * `fallback` is the value used at runtime when the row is unset — the literal
 * written in code, mirrored by the migration `insert` for the keys that have one
 * (the newer keys have no seeded row at all). It is surfaced in the UI as the
 * placeholder so an admin sees what "empty" resolves to. **This is the ONLY
 * place a model id is written in code**: every caller reads its own from here
 * via `aiModelFallback` instead of redeclaring the constant on its side.
 */
import { AGENT_PROVIDERS } from "@/lib/agent-providers";
import {
  NUMO_WAKE_DAILY_LIMIT_DEFAULT,
  NUMO_WAKE_DAILY_LIMIT_KEY,
  NUMO_WAKE_DAILY_LIMIT_MAX,
} from "@/lib/numo-background";
import {
  ROLE_USAGE_KEYS,
  byokFeatureDefaultModelKey,
  isByokModelKey,
  roleUsageKey,
} from "@/lib/ai-surfaces";
import { DEFAULT_REASONING_LEVEL } from "@/lib/agent-reasoning";
import {
  modelCatalogCapabilityForKey,
  providerSupportsModelKey,
  type AdminCatalogCapability,
} from "@/lib/model-catalog-capability";

/**
 * `model` → id `provider/model` chosen in the platform key catalog;
 * `modelId` → id entered as is, in the namespace of a BYOK provider (`gpt-…`,
 * `claude-…`): the platform catalog would write invalid ids;
 * `reasoning` → one of the shared Numo reasoning levels;
 * `count` → a whole number from 0 to `max` (a cap, not a model).
 *
 * There is no on/off switch here: every AI feature is always on (MIN-733).
 */
export type AiConfigKind =
  | "model"
  | "modelId"
  | "reasoning"
  /** A whole number, such as a daily cap. Not part of the models file. */
  | "count";

/**
 * The dashboard sections (MIN-954): one per model role, plus the defaults of
 * personal keys. The LLM section also holds Numo's reasoning level, its
 * background cap.
 */
export type AiConfigGroup =
  | "assistant"
  | "embeddings"
  | "voice"
  | "decision"
  | "byok";

export interface AiConfigField {
  /** `app_config` key. */
  key: string;
  kind: AiConfigKind;
  /** Value used at runtime when the row is unset (the literal written in code). */
  fallback: string;
  /** Section the field is grouped under in the dashboard. */
  group: AiConfigGroup;
  /**
   * Restricts the admin picker to models usable by this runtime call. A
   * `decision` field is also validated on save (`/api/admin/app-config`).
   */
  catalogCapability?: AdminCatalogCapability;
  /** Technical label generated for provider × feature defects. */
  adminLabel?: string;
  /** Highest value of a `count`. */
  max?: number;
}

/**
 * Category defaults of MIN-829, retired by MIN-954. Still READ for their
 * role, below the role's own row, so an instance that only set a category
 * keeps the model it runs until an administrator picks one.
 */
const LEGACY_CATEGORY_KEYS: Record<string, string> = {
  assistant_model: "category_assistant_model",
  issue_embedding_model: "category_embeddings_model",
};

/**
 * Model keys that are not personal-key call types but still run the LLM:
 * the public FAQ box, merge commit messages, and the retired fallback model.
 */
const OTHER_LLM_KEYS = new Set(["faq_ask_model", "merge_message_model", "fallback_model"]);

/**
 * The `app_config` key that holds the model of `key`'s role (MIN-954): every
 * text task reads `assistant_model`, both embedding indexes read
 * `issue_embedding_model`. Other keys are their own.
 */
export function roleModelKey(key: string): string {
  if (isByokModelKey(key)) return roleUsageKey(key);
  return OTHER_LLM_KEYS.has(key) ? ROLE_USAGE_KEYS.llm : key;
}

/**
 * OpenRouter routing shortcuts (MIN-263): `:nitro`, `:floor` and `:exacto`
 * order the providers of one model. The dashboard no longer sets them
 * (MIN-954), but a model id may still carry one: a refused call is replayed
 * on the bare id (`stripModelSuffix`).
 */
const MODEL_SUFFIXES = ["nitro", "floor", "exacto"] as const;

type ModelSuffix = (typeof MODEL_SUFFIXES)[number];

function isModelSuffix(value: string): value is ModelSuffix {
  return (MODEL_SUFFIXES as readonly string[]).includes(value);
}

/**
 * Origin (×1) of the model cost multiplier that plan caps are expressed in
 * (`lib/model-multiplier.ts`, `BillingPlan.maxModelMultiplier`): Numo's
 * model, as configured in the admin dashboard (its code fallback when unset).
 * Changing that model moves the whole scale, and the caps in
 * `lib/billing-plans.ts` are read against the new price.
 */
export const PRICING_BASELINE_CONFIG_KEY = "assistant_model";

export const ASSISTANT_REASONING_CONFIG_KEY = "assistant_reasoning_level";

/** The global decision model (MIN-694); historical key name, see the registry entry. */
export const DECISION_MODEL_CONFIG_KEY = "jev_model";

/**
 * The registry (MIN-954): one model per role, never one per task. Every text
 * task — Numo, conversation titles, web search, background screening, the
 * home summary, Smart Fill, Smart Assign, relation decisions, CSV mapping,
 * brief breakdown, merge messages, voice cleanup, feedback review, the FAQ
 * box — runs `assistant_model`; both embedding indexes run
 * `issue_embedding_model`. `roleModelKey` maps every historical key to its
 * role, so callers keep asking for their own key.
 */
export const AI_MODEL_CONFIG_FIELDS: AiConfigField[] = [
  // The LLM: Numo and every text task. Its reasoning level is Numo's.
  { key: "assistant_model", kind: "model", fallback: "deepseek/deepseek-v4-flash", group: "assistant" },
  {
    key: ASSISTANT_REASONING_CONFIG_KEY,
    kind: "reasoning",
    fallback: DEFAULT_REASONING_LEVEL,
    group: "assistant",
  },
  // Background Numo (lib/server/numo/wake.ts, MIN-929): background turns one
  // user gets in 24 hours. Past it, a due batch closes without waking Numo.
  // 0 turns background Numo off on the instance.
  {
    key: NUMO_WAKE_DAILY_LIMIT_KEY,
    kind: "count",
    fallback: String(NUMO_WAKE_DAILY_LIMIT_DEFAULT),
    max: NUMO_WAKE_DAILY_LIMIT_MAX,
    group: "assistant",
  },
  // Embeddings: issues (search by meaning, relations, MIN-717) and feedback
  // (similar posts) share one model. Changing it versions each index
  // (provider:model:dimensions): old vectors are never compared with new ones.
  {
    key: "issue_embedding_model",
    kind: "model",
    fallback: "openai/text-embedding-3-small",
    group: "embeddings",
    catalogCapability: "embedding",
  },
  // Speech to text, for every dictation; the cleanup that follows is LLM work.
  {
    key: "transcription_model",
    kind: "model",
    fallback: "openai/whisper-large-v3",
    group: "voice",
    catalogCapability: "transcription",
  },
  // AI decision layer (MIN-557, MIN-694): ONE decision model for the whole
  // instance — Smart Fill, Smart Assign, relation decisions, background
  // screening and the feedback review all read it. A decision model answers
  // typed questions over a structured state on OpenRouter's decisions
  // endpoint. The picker lists only models publishing the `decisions` output
  // modality, and the save probes the candidate before writing it. The
  // `jev_*` key names are historical, kept so existing rows carry over; Jev
  // stays the default.
  {
    key: DECISION_MODEL_CONFIG_KEY,
    kind: "model",
    fallback: "typesafe/jev-1.13",
    group: "decision",
    catalogCapability: "decision",
  },
];

/**
 * Provider × call type matrix. OpenRouter is absent: its default is, by
 * definition, the platform setting of the same type. Empty fields of a generic
 * provider force the account to choose an id of its endpoint.
 */
for (const provider of AGENT_PROVIDERS.filter((entry) => entry.id !== "openrouter")) {
  for (const modelKey of Object.values(ROLE_USAGE_KEYS)) {
    if (!providerSupportsModelKey(provider.id, modelKey)) continue;
    let fallback = provider.defaultModel ?? "";
    if (modelKey === "transcription_model") {
      // `gpt-4o-mini-transcribe` is deprecated since 2026-08-26 (shutdown
      // 2027-02-26); the OpenAI changelog names `gpt-transcribe` as its
      // replacement on the same /audio/transcriptions endpoint.
      fallback = provider.id === "openai" ? "gpt-transcribe" : "";
    } else if (modelKey === "issue_embedding_model") {
      fallback =
        provider.id === "openai"
          ? "text-embedding-3-small"
          : provider.id === "google"
            ? "gemini-embedding-001"
            : "";
    }
    AI_MODEL_CONFIG_FIELDS.push({
      key: byokFeatureDefaultModelKey(provider.id, modelKey),
      kind: "modelId",
      fallback,
      group: "byok",
      catalogCapability: modelCatalogCapabilityForKey(modelKey),
      adminLabel: `${provider.label} · ${modelKey}`,
    });
  }
}

/** Display order of the dashboard sections. */
export const AI_MODEL_CONFIG_GROUPS: AiConfigGroup[] = [
  "assistant",
  "embeddings",
  "voice",
  "decision",
  "byok",
];

/** The retired category row a role key still reads below its own, if any. */
export function categoryKeyOf(key: string): string | null {
  return LEGACY_CATEGORY_KEYS[key] ?? null;
}

/**
 * The bare id, without its ROUTING suffix — what we replay on after a failure.
 *
 * We only cut `:nitro`, `:floor`, `:exacto`. The colon also serves
 * to denote a pattern VARIANT (`…:free`, `…:thinking`), which is another
 * pattern, not a routing order: cutting it would replay a denial on the
 * paid variant, silently.
 */
export function stripModelSuffix(model: string): string {
  const cut = model.lastIndexOf(":");
  if (cut < 0) return model;
  return isModelSuffix(model.slice(cut + 1)) ? model.slice(0, cut) : model;
}

/** Fast membership check for the admin API (write allowlist). */
export const AI_MODEL_CONFIG_KEYS = new Set(AI_MODEL_CONFIG_FIELDS.map((f) => f.key));

export function getAiConfigField(key: string): AiConfigField | undefined {
  return AI_MODEL_CONFIG_FIELDS.find((f) => f.key === key);
}

/**
 * Default of a setting, to be used when the `app_config` line is absent or empty.
 *
 * Throws on an unknown key: this is a programming fault, not a case of
 * production — better to break on the first call than route silently du
 * traffic to `undefined`.
 */
export function aiModelFallback(key: string): string {
  const field = getAiConfigField(roleModelKey(key));
  if (!field) throw new Error(`Unknown AI config key: ${key}`);
  return field.fallback;
}