Skip to content
Merged
56 changes: 56 additions & 0 deletions apps/gateway/src/models/models.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,60 @@ describe("Models API", () => {
"image",
]);
});

test("GET /v1/models should include stability information for models", async () => {
const res = await app.request("/v1/models");
expect(res.status).toBe(200);

const json = await res.json();

// Check that the stability field exists in the model schema (models may or may not have it set)
const modelsWithStability = json.data.filter(
(model: any) => model.stability !== undefined,
);
// At least one model should have stability information (our DeepSeek models)
expect(modelsWithStability.length).toBeGreaterThan(0);

// Find DeepSeek models to test specific stability flags
const deepSeekR1Distill = json.data.find(
(model: any) => model.id === "deepseek-r1-distill-llama-70b",
);
const deepSeekV31 = json.data.find(
(model: any) => model.id === "deepseek-v3.1",
);

if (deepSeekR1Distill) {
expect(deepSeekR1Distill.stability).toBe("beta");
}

// DeepSeek v3.1 should default to stable (undefined in response means stable)
if (deepSeekV31) {
expect(
deepSeekV31.stability === undefined ||
deepSeekV31.stability === "stable",
).toBe(true);
}
});

test("GET /v1/models should handle stability field values correctly", async () => {
const res = await app.request("/v1/models");
expect(res.status).toBe(200);

const json = await res.json();

// Validate that stability field contains only valid values
const validStabilityValues = [
"stable",
"beta",
"unstable",
"experimental",
undefined,
];

for (const model of json.data) {
if (model.stability !== undefined) {
expect(validStabilityValues).toContain(model.stability);
}
}
});
});
6 changes: 6 additions & 0 deletions apps/gateway/src/models/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const modelSchema = z.object({
tools: z.boolean(),
parallelToolCalls: z.boolean(),
reasoning: z.boolean(),
stability: z
.enum(["stable", "beta", "unstable", "experimental"])
.optional(),
}),
),
pricing: z.object({
Expand All @@ -62,6 +65,7 @@ const modelSchema = z.object({
free: z.boolean().optional(),
deprecated_at: z.string().optional(),
deactivated_at: z.string().optional(),
stability: z.enum(["stable", "beta", "unstable", "experimental"]).optional(),
});

const listModelsResponseSchema = z.object({
Expand Down Expand Up @@ -198,6 +202,7 @@ modelsApi.openapi(listModels, async (c) => {
tools: provider.tools || false,
parallelToolCalls: provider.parallelToolCalls || false,
reasoning: provider.reasoning || false,
stability: provider.stability || model.stability,
};
}),
pricing: {
Expand All @@ -222,6 +227,7 @@ modelsApi.openapi(listModels, async (c) => {
free: model.free || false,
deprecated_at: model.deprecatedAt?.toISOString(),
deactivated_at: model.deactivatedAt?.toISOString(),
stability: model.stability,
};
});

Expand Down
86 changes: 82 additions & 4 deletions apps/ui/src/app/models/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import {
models as modelDefinitions,
providers as providerDefinitions,
type StabilityLevel,
type ModelDefinition,
} from "@llmgateway/models";
import { AlertTriangle } from "lucide-react";
import { notFound } from "next/navigation";

import Footer from "@/components/landing/footer";
import { Navbar } from "@/components/landing/navbar";
import { CopyModelName } from "@/components/models/copy-model-name";
import { ProviderCard } from "@/components/models/provider-card";
import { Badge } from "@/lib/components/badge";

interface PageProps {
params: Promise<{ name: string }>;
Expand All @@ -17,12 +21,43 @@ export default async function ModelPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);

const modelDef = modelDefinitions.find((m) => m.id === decodedName);
const modelDef = modelDefinitions.find(
(m) => m.id === decodedName,
) as ModelDefinition;

Comment on lines +24 to 27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid unsafe cast; let notFound() narrow the type

-const modelDef = modelDefinitions.find(
-  (m) => m.id === decodedName,
-) as ModelDefinition;
+const modelDef = modelDefinitions.find((m) => m.id === decodedName);

Type narrows after notFound().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const modelDef = modelDefinitions.find(
(m) => m.id === decodedName,
) as ModelDefinition;
const modelDef = modelDefinitions.find((m) => m.id === decodedName);
🤖 Prompt for AI Agents
In apps/ui/src/app/models/[name]/page.tsx around lines 24 to 27, remove the
unsafe "as ModelDefinition" cast on modelDef and instead let TypeScript narrow
the type by checking for a falsy value and calling notFound() when modelDef is
undefined; i.e., assign modelDef directly from modelDefinitions.find(...), then
immediately if (!modelDef) notFound(), so subsequent code treats modelDef as a
ModelDefinition without an explicit cast.

if (!modelDef) {
notFound();
}

const getStabilityBadgeProps = (stability?: StabilityLevel) => {
switch (stability) {
case "beta":
return {
variant: "secondary" as const,
color: "text-blue-600",
label: "BETA",
};
case "unstable":
return {
variant: "destructive" as const,
color: "text-red-600",
label: "UNSTABLE",
};
case "experimental":
return {
variant: "destructive" as const,
color: "text-orange-600",
label: "EXPERIMENTAL",
};
default:
return null;
}
};

const shouldShowStabilityWarning = (stability?: StabilityLevel) => {
return stability && ["unstable", "experimental"].includes(stability);
};

const modelProviders = modelDef.providers.map((provider) => {
const providerInfo = providerDefinitions.find(
(p) => p.id === provider.providerId,
Expand All @@ -39,11 +74,33 @@ export default async function ModelPage({ params }: PageProps) {
<div className="min-h-screen bg-background py-32">
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold tracking-tight mb-2">
{modelDef.id}
</h1>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-4xl font-bold tracking-tight">
{modelDef.id}
</h1>
{shouldShowStabilityWarning(modelDef.stability) && (
<AlertTriangle className="h-8 w-8 text-orange-500" />
)}
</div>
<div className="flex items-center gap-2 mb-4">
<CopyModelName modelName={decodedName} />
{(() => {
const stabilityProps = getStabilityBadgeProps(
modelDef.stability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className="text-sm px-3 py-1"
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-sm px-3 py-1">
STABLE
</Badge>
);
})()}
</div>
Comment on lines +87 to 104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Apply badge color classes

-<Badge
-  variant={stabilityProps.variant}
-  className="text-sm px-3 py-1"
->
+<Badge
+  variant={stabilityProps.variant}
+  className={`text-sm px-3 py-1 ${stabilityProps.color ?? ""}`}
+>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{(() => {
const stabilityProps = getStabilityBadgeProps(
modelDef.stability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className="text-sm px-3 py-1"
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-sm px-3 py-1">
STABLE
</Badge>
);
})()}
</div>
{(() => {
const stabilityProps = getStabilityBadgeProps(
modelDef.stability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className={`text-sm px-3 py-1 ${stabilityProps.color ?? ""}`}
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-sm px-3 py-1">
STABLE
</Badge>
);
})()}
🤖 Prompt for AI Agents
In apps/ui/src/app/models/[name]/page.tsx around lines 87 to 104, the badge
currently always uses the same styling and doesn't apply the color classes
provided by the stability metadata; update the JSX so the Badge receives the
stability color classes by merging the existing "text-sm px-3 py-1" with the
stabilityProps.className (or stabilityProps.colorClass) when stabilityProps
exists, and when falling back to the default branch provide the appropriate
default color class(es) instead of only "outline" so the badge shows the
intended color; ensure you keep the variant prop and properly concatenate class
strings safely (e.g., conditional join) so absent fields don't break rendering.


<div className="flex flex-wrap gap-4 text-sm text-muted-foreground mb-4">
Expand Down Expand Up @@ -86,6 +143,26 @@ export default async function ModelPage({ params }: PageProps) {
different configurations, pricing, and performance
characteristics. Choose the provider that best fits your needs.
</p>
{shouldShowStabilityWarning(modelDef.stability) && (
<div className="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-medium text-orange-800 mb-1">
{modelDef.stability === "experimental"
? "Experimental"
: "Unstable"}{" "}
Model Warning
</h3>
<p className="text-sm text-orange-700">
This model is marked as {modelDef.stability} and may have
issues with reliability, performance, or consistency. Use
with caution in production environments.
</p>
</div>
</div>
</div>
)}
</div>

<div className="mb-8">
Expand All @@ -108,6 +185,7 @@ export default async function ModelPage({ params }: PageProps) {
key={provider.providerId}
provider={provider}
modelName={decodedName}
modelStability={modelDef.stability}
/>
))}
</div>
Expand Down
113 changes: 112 additions & 1 deletion apps/ui/src/components/models/all-models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ArrowDown,
Play,
ImagePlus,
AlertTriangle,
ExternalLink,
} from "lucide-react";
import Link from "next/link";
Expand Down Expand Up @@ -61,7 +62,11 @@ import {
} from "@/lib/components/tooltip";
import { cn, formatContextSize } from "@/lib/utils";

import type { ModelDefinition, ProviderModelMapping } from "@llmgateway/models";
import type {
ModelDefinition,
ProviderModelMapping,
StabilityLevel,
} from "@llmgateway/models";

interface ModelWithProviders extends ModelDefinition {
providerDetails: Array<{
Expand Down Expand Up @@ -369,6 +374,59 @@ export function AllModels({ children }: { children: React.ReactNode }) {
);
};

const getStabilityBadgeProps = (stability?: StabilityLevel) => {
switch (stability) {
case "beta":
return {
variant: "secondary" as const,
color: "text-blue-600",
label: "BETA",
};
case "unstable":
return {
variant: "destructive" as const,
color: "text-red-600",
label: "UNSTABLE",
};
case "experimental":
return {
variant: "destructive" as const,
color: "text-orange-600",
label: "EXPERIMENTAL",
};
default:
return null;
}
};
Comment on lines +377 to +400

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use neutral variant for EXPERIMENTAL (orange text), not “destructive” (red).
PR requires blue=beta, red=unstable, orange=experimental. “destructive” will look red; keep it neutral and color text orange.

Apply:

 case "experimental":
   return {
-    variant: "destructive" as const,
+    variant: "secondary" as const,
     color: "text-orange-600",
     label: "EXPERIMENTAL",
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getStabilityBadgeProps = (stability?: StabilityLevel) => {
switch (stability) {
case "beta":
return {
variant: "secondary" as const,
color: "text-blue-600",
label: "BETA",
};
case "unstable":
return {
variant: "destructive" as const,
color: "text-red-600",
label: "UNSTABLE",
};
case "experimental":
return {
variant: "destructive" as const,
color: "text-orange-600",
label: "EXPERIMENTAL",
};
default:
return null;
}
};
const getStabilityBadgeProps = (stability?: StabilityLevel) => {
switch (stability) {
case "beta":
return {
variant: "secondary" as const,
color: "text-blue-600",
label: "BETA",
};
case "unstable":
return {
variant: "destructive" as const,
color: "text-red-600",
label: "UNSTABLE",
};
case "experimental":
return {
variant: "secondary" as const,
color: "text-orange-600",
label: "EXPERIMENTAL",
};
default:
return null;
}
};
🤖 Prompt for AI Agents
In apps/ui/src/components/models/all-models.tsx around lines 377 to 400, the
EXPERIMENTAL case currently returns variant: "destructive" which renders as red;
change it to a neutral variant (match beta's neutral style) by returning
variant: "secondary" as const while keeping color: "text-orange-600" and label:
"EXPERIMENTAL" so the badge appears orange but not destructive.


const shouldShowStabilityWarning = (stability?: StabilityLevel) => {
return stability && ["unstable", "experimental"].includes(stability);
};

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const getMostUnstableStability = (model: any): StabilityLevel | undefined => {
const stabilityLevels: StabilityLevel[] = [
"experimental",
"unstable",
"beta",
"stable",
];

// Get all stability levels (model-level and provider-level)
const allStabilities = [
model.stability,
...model.providers.map((p: any) => p.stability || model.stability),
].filter(Boolean) as StabilityLevel[];

// Return the most unstable level
for (const level of stabilityLevels) {
if (allStabilities.includes(level)) {
return level;
}
}

return undefined;
};
Comment on lines +406 to +428

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid any and tighten types for stability computation.
Follow the repo guideline “never use any in TS”. Also, no need to duplicate model-level stability for each provider.

-const getMostUnstableStability = (model: any): StabilityLevel | undefined => {
+const getMostUnstableStability = (
+  model: ModelWithProviders,
+): StabilityLevel | undefined => {
   const stabilityLevels: StabilityLevel[] = [
     "experimental",
     "unstable",
     "beta",
     "stable",
   ];
 
-  // Get all stability levels (model-level and provider-level)
-  const allStabilities = [
-    model.stability,
-    ...model.providers.map((p: any) => p.stability || model.stability),
-  ].filter(Boolean) as StabilityLevel[];
+  // Collect model- and provider-level stabilities
+  const allStabilities = [
+    model.stability,
+    ...model.providers.map((p) => p.stability),
+  ].filter((s): s is StabilityLevel => Boolean(s));
 
   // Return the most unstable level
   for (const level of stabilityLevels) {
     if (allStabilities.includes(level)) {
       return level;
     }
   }
 
   return undefined;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getMostUnstableStability = (model: any): StabilityLevel | undefined => {
const stabilityLevels: StabilityLevel[] = [
"experimental",
"unstable",
"beta",
"stable",
];
// Get all stability levels (model-level and provider-level)
const allStabilities = [
model.stability,
...model.providers.map((p: any) => p.stability || model.stability),
].filter(Boolean) as StabilityLevel[];
// Return the most unstable level
for (const level of stabilityLevels) {
if (allStabilities.includes(level)) {
return level;
}
}
return undefined;
};
const getMostUnstableStability = (
model: ModelWithProviders,
): StabilityLevel | undefined => {
const stabilityLevels: StabilityLevel[] = [
"experimental",
"unstable",
"beta",
"stable",
];
// Collect model- and provider-level stabilities
const allStabilities = [
model.stability,
...model.providers.map((p) => p.stability),
].filter((s): s is StabilityLevel => Boolean(s));
// Return the most unstable level
for (const level of stabilityLevels) {
if (allStabilities.includes(level)) {
return level;
}
}
return undefined;
};
🤖 Prompt for AI Agents
In apps/ui/src/components/models/all-models.tsx around lines 405 to 427, tighten
types by replacing all `any` with concrete types: define or import a
StabilityLevel union type (e.g. "experimental" | "unstable" | "beta" |
"stable"), type the model parameter as an interface with stability?:
StabilityLevel and providers: { stability?: StabilityLevel }[], and update the
function signature accordingly; compute provider stabilities without duplicating
the model stability for each provider by mapping providers to (p.stability ??
model.stability), filter out undefined, and iterate the predefined stability
priority to return the first match or undefined.


const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
Expand Down Expand Up @@ -756,6 +814,7 @@ export function AllModels({ children }: { children: React.ReactNode }) {
<TableHead className="text-center bg-background/95 backdrop-blur-sm border-b">
Capabilities
</TableHead>
<TableHead className="text-center">Stability</TableHead>
<TableHead className="text-center bg-background/95 backdrop-blur-sm border-b">
Actions
</TableHead>
Expand All @@ -774,6 +833,11 @@ export function AllModels({ children }: { children: React.ReactNode }) {
<div className="space-y-1">
<div className="font-semibold text-sm flex items-center gap-2">
{model.name || model.id}
{shouldShowStabilityWarning(
getMostUnstableStability(model),
) && (
<AlertTriangle className="h-4 w-4 text-orange-500" />
)}
{model.free && (
<Badge
variant="secondary"
Expand Down Expand Up @@ -929,6 +993,28 @@ export function AllModels({ children }: { children: React.ReactNode }) {
</div>
</TableCell>

<TableCell className="text-center">
{(() => {
const mostUnstableStability =
getMostUnstableStability(model);
const stabilityProps = getStabilityBadgeProps(
mostUnstableStability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className="text-xs px-2 py-1"
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-xs px-2 py-1">
STABLE
</Badge>
);
})()}
</TableCell>

<TableCell className="text-center">
<Link
href={`/playground?model=${encodeURIComponent(model.id)}`}
Expand Down Expand Up @@ -966,6 +1052,9 @@ export function AllModels({ children }: { children: React.ReactNode }) {
<div className="flex-1 min-w-0">
<CardTitle className="text-base leading-tight flex items-center gap-2 flex-wrap">
{model.name || model.id}
{shouldShowStabilityWarning(model.stability) && (
<AlertTriangle className="h-4 w-4 text-orange-500" />
)}
{model.free && (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Badge
variant="secondary"
Expand Down Expand Up @@ -1117,6 +1206,28 @@ export function AllModels({ children }: { children: React.ReactNode }) {
))}
</div>

<div>
<div className="font-medium mb-2 text-sm">Stability:</div>
{(() => {
const mostUnstableStability = getMostUnstableStability(model);
const stabilityProps = getStabilityBadgeProps(
mostUnstableStability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className="text-xs px-2 py-1"
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-xs px-2 py-1">
STABLE
</Badge>
);
})()}
</div>
Comment on lines +1209 to +1229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Apply computed color to the Stability badge in the grid.
Same issue as the table badge.

-<Badge
-  variant={stabilityProps.variant}
-  className="text-xs px-2 py-1"
->
+<Badge
+  variant={stabilityProps.variant}
+  className={cn("text-xs px-2 py-1", stabilityProps.color)}
+>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div>
<div className="font-medium mb-2 text-sm">Stability:</div>
{(() => {
const mostUnstableStability = getMostUnstableStability(model);
const stabilityProps = getStabilityBadgeProps(
mostUnstableStability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className="text-xs px-2 py-1"
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-xs px-2 py-1">
STABLE
</Badge>
);
})()}
</div>
<div>
<div className="font-medium mb-2 text-sm">Stability:</div>
{(() => {
const mostUnstableStability = getMostUnstableStability(model);
const stabilityProps = getStabilityBadgeProps(
mostUnstableStability,
);
return stabilityProps ? (
<Badge
variant={stabilityProps.variant}
className={cn("text-xs px-2 py-1", stabilityProps.color)}
>
{stabilityProps.label}
</Badge>
) : (
<Badge variant="outline" className="text-xs px-2 py-1">
STABLE
</Badge>
);
})()}
</div>
🤖 Prompt for AI Agents
In apps/ui/src/components/models/all-models.tsx around lines 1182 to 1202, the
Stability badge rendered in the grid does not apply the computed color/variant
from getStabilityBadgeProps like the table badge does; update the JSX so when
stabilityProps exists you pass stabilityProps.variant (and any color/class names
it carries) to the Badge component and apply the same text/px/py classes, and
when stabilityProps is missing keep the fallback Badge with variant="outline";
ensure you reuse the same badge-props extraction (mostUnstableStability ->
getStabilityBadgeProps) and consistently apply variant/className so the computed
color shows in the grid.


<div className="pt-4 border-t">
<Link
href={`/playground?model=${encodeURIComponent(model.id)}`}
Expand Down
Loading
Loading