TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { AlertTriangle, Check, GitCompareArrows, MessageSquare, Minus, Star, Swords, Tag } from "lucide-react";6import type { ModelCapabilities, PolyModel } from "@/lib/client/types";7import { useApp } from "@/components/app/store";8import { ProviderIcon } from "@/components/brand/provider-icon";9import { Button } from "@/components/ui/button";10import { Badge } from "@/components/ui/badge";11import { ResponsiveDialog } from "@/components/ui/sheet";12import { CopyButton } from "@/components/ui/misc";13import { PromptDialog } from "@/components/common/prompt-dialog";14import { ModelBadges, StatusBadge, useBadgeContext } from "@/components/chat/model-badges";15import { CAPABILITY_LABELS } from "./compare-table";16import { PROVIDERS } from "@/lib/client/providers";17import { PARAM_DEFS, SETTINGS_GROUPS } from "@/lib/models/params";18import { speedTier, shutdownDateOf } from "@/lib/models/badges";19import { formatContext, formatIsoDate, formatPrice } from "@/lib/models/format";20import { cn } from "@/lib/utils";2122/**23 * Model profile — every field the registry knows, "—" for everything it does not.24 * Release dates and knowledge cutoffs are shown only when the provider publishes them.25 */26export interface ModelProfileProps {27 model: PolyModel | null;28 open: boolean;29 onOpenChange: (open: boolean) => void;30 /** Override "Use in chat" (e.g. the selector picks the model instead of navigating). */31 onUse?: (m: PolyModel) => void;32 /** Optional current model key to seed "Compare" with a second column. */33 compareWith?: string | null;34}3536function metaStr(m: PolyModel, key: string): string | null {37 const v = m.metadata?.[key];38 return typeof v === "string" && v ? v : null;39}4041export function ModelProfile({ model: m, open, onOpenChange, onUse, compareWith }: ModelProfileProps) {42 const router = useRouter();43 const { connectedProviders, favorites, labels, toggleFavorite, setLabel, setSelectedModelKey } = useApp();44 const ctx = useBadgeContext();45 const [labelOpen, setLabelOpen] = React.useState(false);46 if (!m) return null;4748 const connected = connectedProviders.has(m.provider);49 const fav = favorites.has(m.key);50 const label = labels[m.key];51 const sd = shutdownDateOf(m);52 const lc = m.pricing?.longContext;53 const tier = speedTier(m, ctx);54 const aliases = Array.isArray(m.metadata?.aliases) ? (m.metadata!.aliases as string[]) : [];55 const description = metaStr(m, "description") ?? metaStr(m, "notes");56 const compareKeys = [...new Set([compareWith, m.key].filter((k): k is string => Boolean(k)))];57 const compareHref = `/app/models/compare?m=${encodeURIComponent(compareKeys.join(","))}`;5859 const use = () => {60 onOpenChange(false);61 if (onUse) return onUse(m);62 setSelectedModelKey(m.key);63 router.push("/app/chat");64 };6566 const facts: { label: string; value: React.ReactNode; hint?: string }[] = [67 { label: "Context", value: formatContext(m.limits?.contextTokens) },68 { label: "Max output", value: formatContext(m.limits?.maxOutputTokens) },69 { label: "Input $/1M", value: formatPrice(m.pricing?.inputPerMillion) },70 { label: "Output $/1M", value: formatPrice(m.pricing?.outputPerMillion) },71 { label: "Cached input $/1M", value: formatPrice(m.pricing?.cachedInputPerMillion) },72 { label: "Long-context tier", value: lc ? `> ${formatContext(lc.thresholdTokens)}: ${formatPrice(lc.inputPerMillion)} / ${formatPrice(lc.outputPerMillion)}` : "—", hint: lc ? "input / output above the threshold" : undefined },73 { label: "Speed tier", value: { fast: "Fast", standard: "Standard", frontier: "Frontier" }[tier] },74 { label: "Release date", value: metaStr(m, "releaseDate") ? formatIsoDate(metaStr(m, "releaseDate")) : "—", hint: "as published by the provider" },75 { label: "Knowledge cutoff", value: metaStr(m, "knowledgeCutoff") ?? "—", hint: "as published by the provider" },76 { label: "Listed by provider", value: formatIsoDate(metaStr(m, "createdAt")), hint: "creation timestamp from the provider’s model list" },77 { label: "First seen in PolyLLM", value: formatIsoDate(metaStr(m, "firstSeenAt")) },78 { label: "Prices as of", value: m.pricing?.asOf ?? "—" },79 ];8081 return (82 <>83 <ResponsiveDialog84 open={open}85 onOpenChange={onOpenChange}86 size="lg"87 snap="full"88 hideTitle89 title={m.displayName}90 description={`${PROVIDERS[m.provider].name} model profile`}91 footer={92 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">93 <span className="text-xs text-fg-subtle">{connected ? `${PROVIDERS[m.provider].shortName} key connected` : `Connect a ${PROVIDERS[m.provider].shortName} key to use this model`}</span>94 <div className="grid grid-cols-3 gap-2 sm:flex">95 <Button variant="outline" size="sm" className="h-10 sm:h-8" onClick={() => { onOpenChange(false); router.push(compareHref); }}>96 <GitCompareArrows /> <span className="truncate">Compare</span>97 </Button>98 <Button variant="outline" size="sm" className="h-10 sm:h-8" asChild>99 <Link href={`/app/arena?models=${encodeURIComponent(m.key)}`} onClick={() => onOpenChange(false)}>100 <Swords /> <span className="truncate">Arena</span>101 </Link>102 </Button>103 {connected ? (104 <Button size="sm" className="h-10 sm:h-8" onClick={use} disabled={m.status === "deprecated"}>105 <MessageSquare /> <span className="truncate">Use in chat</span>106 </Button>107 ) : (108 <Button size="sm" className="h-10 sm:h-8" asChild>109 <Link href="/app/settings/providers">Connect</Link>110 </Button>111 )}112 </div>113 </div>114 }115 >116 <div className="space-y-6 pt-1">117 {/* Header */}118 <div className="flex items-start gap-3">119 <span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-bg-subtle">120 <ProviderIcon provider={m.provider} size={22} />121 </span>122 <div className="min-w-0 flex-1">123 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">124 <h2 className="text-lg font-semibold leading-6 tracking-tight">{m.displayName}</h2>125 {label ? (126 <span className="inline-flex items-center gap-1 rounded-md bg-accent-soft pl-1.5 pr-0.5 text-[12px] font-medium text-accent">127 {label}128 <button type="button" className="tap rounded p-0.5 text-accent/70 hover:text-accent" aria-label="Remove custom label" onClick={() => void setLabel(m.key, null)}>129 <Minus className="size-3" />130 </button>131 </span>132 ) : null}133 <StatusBadge model={m} connected={connected} ctx={ctx} showActive />134 </div>135 <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-fg-muted">136 <span className="inline-flex items-center gap-1 font-mono">137 {m.key}138 <CopyButton value={m.key} label="Copy model key" className="size-6" />139 </span>140 {m.family ? <span>· {m.family}</span> : null}141 </div>142 <ModelBadges model={m} ctx={ctx} max={7} className="mt-2" />143 </div>144 <div className="flex shrink-0 flex-col gap-1">145 <Button variant="ghost" size="icon" aria-pressed={fav} aria-label={fav ? "Remove from favorites" : "Add to favorites"} onClick={() => void toggleFavorite(m.key)} className={cn(fav && "text-warning")}>146 <Star key={fav ? "on" : "off"} className={cn(fav && "fill-current animate-pop")} />147 </Button>148 <Button variant="ghost" size="icon" aria-label="Set custom label" onClick={() => setLabelOpen(true)}>149 <Tag />150 </Button>151 </div>152 </div>153154 {sd ? (155 <div className="flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-xs text-warning">156 <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />157 <p>158 {PROVIDERS[m.provider].name} has scheduled this model for shutdown on <span className="font-medium">{formatIsoDate(sd)}</span>. Move presets to a newer model before then.159 </p>160 </div>161 ) : null}162163 {description ? <p className="text-[13px] leading-5 text-fg-muted">{description}</p> : null}164165 {/* Facts */}166 <dl className="grid grid-cols-2 gap-2 sm:grid-cols-3">167 {facts.map((f) => (168 <div key={f.label} className="panel px-3 py-2">169 <dt className="text-[11px] text-fg-subtle">{f.label}</dt>170 <dd className="mt-0.5 truncate font-mono text-[13px] tabular-nums" title={typeof f.value === "string" ? f.value : undefined}>171 {f.value}172 </dd>173 {f.hint ? <dd className="text-[10px] text-fg-subtle">{f.hint}</dd> : null}174 </div>175 ))}176 </dl>177178 {/* Capabilities */}179 <section>180 <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Capabilities</h3>181 <ul className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">182 {(Object.keys(CAPABILITY_LABELS) as (keyof ModelCapabilities)[]).map((k) => {183 const on = m.capabilities[k];184 return (185 <li key={k} className={cn("flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[12.5px]", on ? "bg-bg-subtle text-fg" : "text-fg-subtle")}>186 {on ? <Check className="size-3.5 text-success" aria-hidden /> : <Minus className="size-3.5 text-border-strong" aria-hidden />}187 {CAPABILITY_LABELS[k]}188 </li>189 );190 })}191 </ul>192 </section>193194 {/* Parameters */}195 <section>196 <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Supported parameters</h3>197 <div className="space-y-3">198 {SETTINGS_GROUPS.map((g) => {199 const defs = PARAM_DEFS.filter((d) => d.group === g.key);200 return (201 <div key={g.key}>202 <p className="mb-1 text-[11px] font-medium text-fg-muted">{g.label}</p>203 <ul className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">204 {defs.map((d) => {205 const on = d.supports(m);206 const detail = on ? d.detail?.(m) : null;207 return (208 <li key={d.key} className={cn("rounded-md px-2.5 py-1.5 text-[12.5px]", on ? "bg-bg-subtle text-fg" : "text-fg-subtle line-through decoration-border-strong")}>209 <span>{d.label}</span>210 {detail ? <span className="mt-0.5 block font-mono text-[10.5px] text-fg-muted no-underline">{detail}</span> : null}211 </li>212 );213 })}214 </ul>215 </div>216 );217 })}218 </div>219 {m.metadata?.samplingMode === "conditional" ? <p className="mt-2 text-[11.5px] text-fg-subtle">Sampling parameters are accepted only when reasoning effort is “none”.</p> : null}220 {m.metadata?.alwaysReasoning ? <p className="mt-1 text-[11.5px] text-fg-subtle">This model always reasons; effort only changes how much.</p> : null}221 </section>222223 {/* Lifecycle */}224 <section>225 <h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wide text-fg-subtle">Lifecycle</h3>226 <dl className="grid grid-cols-2 gap-2 text-[12.5px] sm:grid-cols-3">227 <div className="panel px-3 py-2">228 <dt className="text-[11px] text-fg-subtle">Provider status</dt>229 <dd className="mt-0.5 capitalize">{m.status}</dd>230 </div>231 <div className="panel px-3 py-2">232 <dt className="text-[11px] text-fg-subtle">Shutdown date</dt>233 <dd className="mt-0.5 font-mono">{sd ? formatIsoDate(sd) : "—"}</dd>234 </div>235 <div className="panel px-3 py-2">236 <dt className="text-[11px] text-fg-subtle">Availability</dt>237 <dd className="mt-0.5">{connected ? "Usable with your key" : "No key connected"}</dd>238 </div>239 </dl>240 {aliases.length ? (241 <p className="mt-2 text-xs text-fg-muted">242 Aliases: <span className="font-mono">{aliases.join(", ")}</span>243 </p>244 ) : null}245 {m.metadata?.source ? (246 <p className="mt-1 text-[11px] text-fg-subtle">247 Source: <Badge variant="outline">{String(m.metadata.source)}</Badge> {m.pricing?.source ? <>· pricing from {String(m.pricing.source)}</> : null}248 </p>249 ) : null}250 </section>251 </div>252 </ResponsiveDialog>253254 <PromptDialog open={labelOpen} onOpenChange={setLabelOpen} title="Custom label" description={`Shown next to ${m.displayName} everywhere in PolyLLM.`} label="Label" placeholder="My research model" defaultValue={label ?? ""} maxLength={60} confirmLabel="Save" onSubmit={(v) => setLabel(m.key, v)} />255 </>256 );257}258