import { FEATURE_FLAGS, type FeatureFlagValue } from '../../featureFlags'; /** * Superagent model-picker data + selection logic. Mirrors the web builder's * Superagent selector (read, don't import — native boundary): * - components/chat/model-picker/modelPickerRegistry.ts * - pages/agent-editor/utils/modelSelection.ts * - pages/billing/BillingPlans/utils/tierNormalization.ts * The web registry is shared with builder chat and partitions the two with * builderChatOnly/superagentOnly flags; this package is superagent-only, so * `allModels` just lists the Superagent models without those partition flags. * The staff-vs-customer `employeesOnly` gate is kept — it's a different axis and * keeps any internal/experimental model out of the customer-facing picker. The * picker UI is native (modelPicker.tsx); this module owns only data + logic. */ export interface ModelEntry { id: string; displayName?: string; newSince?: string; flagRequired?: FeatureFlagValue; hiddenWhenFlag?: FeatureFlagValue; // Show only when the user's variant for `flag` is one of `variants`. flagVariantIn?: { flag: FeatureFlagValue; variants: string[] }; // Hide when the user's variant for `flag` is one of `variants`. hiddenWhenFlagVariantIn?: { flag: FeatureFlagValue; variants: string[] }; // Kept in the registry for label resolution but dropped from the rendered list. hiddenFromPicker?: boolean; // Staff-only model: never shown in the customer-facing picker. employeesOnly?: boolean; } export type VisibleModelEntry = ModelEntry & { isNew: boolean }; export const NEW_BADGE_DURATION_DAYS = 7; export const isModelNew = (entry: { newSince?: string }): boolean => { if (!entry.newSince) return false; const since = new Date(entry.newSince).getTime(); if (Number.isNaN(since)) return false; return Date.now() - since < NEW_BADGE_DURATION_DAYS * 24 * 60 * 60 * 1000; }; export const allModels: Record = { // Sonnet 5 is GA — it occupies the Sonnet slot for everyone (no flag gate). Sonnet 4.6 is // delisted (kept for label resolution) now that Sonnet 5 replaced it. 'Sonnet 4.6': { id: 'claude_sonnet_4_6', hiddenFromPicker: true }, 'Sonnet 5': { id: 'claude-sonnet-5', newSince: '2026-06-30' }, 'Opus 4.7': { id: 'claude_opus_4_7', hiddenFromPicker: true }, 'Opus 4.8': { id: 'claude_opus_4_8' }, 'GPT-5.6 Luna': { id: 'gpt_5_6_luna', newSince: '2026-07-30' }, 'GPT-5.6 Sol': { id: 'gpt_5_6_sol', newSince: '2026-07-22' }, 'GLM 5.2': { id: 'glm_5_2_superagent' }, // Retired from the picker but kept so a label still resolves for an agent that // already has one of these saved (chosen earlier or on web). 'Gemini 3.1 Pro': { id: 'gemini_3_1_pro', hiddenFromPicker: true }, 'Opus 4.6': { id: 'claude_opus_4_6', hiddenFromPicker: true }, 'GPT-5.4': { id: 'gpt_5_4', hiddenFromPicker: true }, 'GPT-5.5': { id: 'gpt_5_5', hiddenFromPicker: true }, }; type FlagReader = (flag: FeatureFlagValue) => boolean; type FlagVariantReader = (flag: FeatureFlagValue) => string | null; // Models offered in the picker: flag/variant-gated, minus label-only entries. export const getVisibleModels = ( hasFlag: FlagReader, getFlagVariant: FlagVariantReader, ): Record => Object.fromEntries( Object.entries(allModels) .filter(([, { flagRequired, hiddenWhenFlag, flagVariantIn, hiddenWhenFlagVariantIn, hiddenFromPicker }]) => !hiddenFromPicker && (!flagRequired || hasFlag(flagRequired)) && (!hiddenWhenFlag || !hasFlag(hiddenWhenFlag)) && (!flagVariantIn || flagVariantIn.variants.includes(getFlagVariant(flagVariantIn.flag) ?? '')) && (!hiddenWhenFlagVariantIn || !hiddenWhenFlagVariantIn.variants.includes(getFlagVariant(hiddenWhenFlagVariantIn.flag) ?? '')), ) .map(([name, entry]) => [name, { ...entry, isNew: isModelNew(entry) }]), ); export const bestSuperagentModelIds = new Set([ 'claude_opus_4_8', 'claude_opus_4_7', 'claude_opus_4_6', 'gpt_5_5', // retired; kept so agents that saved it still render as a best model 'gpt_5_6_sol', ]); export function getSuperagentModelId(modelId: string | null | undefined): string { return modelId || 'default'; } export function getSuperagentModelDisplayName( modelId: string | null | undefined, automaticLabel = 'Automatic', ): string { const selectedModelId = getSuperagentModelId(modelId); if (selectedModelId === 'default') return automaticLabel; const entry = Object.entries(allModels).find(([, { id }]) => id === selectedModelId); if (!entry) return selectedModelId; return entry[1].displayName ?? entry[0]; } // Base subscription tiers in ascending order. "Best" models are gated at Builder // and above (everything except free/starter). Graduated rungs (builder_3, elite_2, // business_6, enterprise_1) collapse to their base via `baseTierName`, so every // tier-name version ranks correctly — the web mirror leans on a workspace-level // enterprise mapping the native package doesn't have, which would otherwise leave // e.g. `enterprise_1` unmatched and lock a paying user out. const baseTierOrder = ['free', 'starter', 'builder', 'pro', 'elite', 'business', 'enterprise']; export function normalizeTierName(tier: string | null | undefined): string { if (!tier) return ''; return tier.replace(/_yearly|_ils|_eur|_gbp/gi, '').toLowerCase(); } // Collapse a tier to its base rung: strip billing/currency suffixes, then a // trailing graduated suffix (`builder_3` → `builder`, `enterprise_1` → `enterprise`). export function baseTierName(tier?: string | null): string { return normalizeTierName(tier).replace(/_\d+$/, '') || 'free'; } export function tierAllowsBestSuperagentModels(tier?: string | null): boolean { return baseTierOrder.indexOf(baseTierName(tier)) >= baseTierOrder.indexOf('builder'); } export const AUTOMATIC_LABEL = 'Automatic'; const AUTOMATIC_DESCRIPTION = 'Matched with the best AI model for each request'; export type ModelOption = { id: string; label: string; description?: string; isNew?: boolean; // "Best" (premium) model: shown locked below the Builder tier rather than hidden. isBest?: boolean; }; const AUTOMATIC_OPTION: ModelOption = { id: 'default', label: AUTOMATIC_LABEL, description: AUTOMATIC_DESCRIPTION, }; // "Automatic" first, then every visible Superagent model, minus staff-only entries // (native has no employee submenu). Best models stay in the list (flagged `isBest`) // so the picker can show them locked below the Builder tier. export function getSelectableModelOptions( hasFlag: FlagReader, getFlagVariant: FlagVariantReader, ): ModelOption[] { const models = Object.entries(getVisibleModels(hasFlag, getFlagVariant)) .filter(([, entry]) => !entry.employeesOnly) .map(([label, entry]) => ({ id: entry.id, label, isNew: entry.isNew, isBest: bestSuperagentModelIds.has(entry.id), })); return [AUTOMATIC_OPTION, ...models]; } export function isBestSuperagentModel(model?: string | null): boolean { return !!model && bestSuperagentModelIds.has(model); } // Resolves the trigger label even for a model that isn't currently selectable // (retired, chosen on web, or outside the user's cohort). export function resolveModelOption(model?: string | null): ModelOption { const id = getSuperagentModelId(model); return { id, label: getSuperagentModelDisplayName(model, AUTOMATIC_LABEL), description: id === 'default' ? AUTOMATIC_DESCRIPTION : undefined, isBest: bestSuperagentModelIds.has(id), }; } export function getModelLabel(model?: string | null): string { return getSuperagentModelDisplayName(model, AUTOMATIC_LABEL); }