Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/api/src/routes/admin-routing-analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ const [providerA, providerB] = testModel.providers
.filter(isRoutableMapping)
.map((p) => p.providerId);

// One model per side of "now": a retired mapping and a scheduled one. Mappings
// are never removed from the catalogue, only dated, so the retired side always
// exists; the scheduled side is whatever is still ahead today and is simply
// omitted if nothing is. The test asserts the past/future rule as a property of
// the response rather than pinning a fixture, so it cannot start failing on its
// own once a scheduled date passes.
function findModelsWithDeactivatedMappings(): string[] {
const now = new Date();
let retired: string | undefined;
let scheduled: string | undefined;
for (const model of models) {
for (const mapping of model.providers as ProviderModelMapping[]) {
if (!mapping.deactivatedAt) {
continue;
}
if (mapping.deactivatedAt <= now) {
retired ??= model.id;
} else {
scheduled ??= model.id;
}
}
if (retired && scheduled) {
break;
}
}
const modelIds = [
...new Set([retired, scheduled].filter(Boolean)),
] as string[];
if (modelIds.length === 0) {
throw new Error(
"No catalogue mapping carries a deactivatedAt; update this fixture.",
);
}
return modelIds;
}

const deactivationModelIds = findModelsWithDeactivatedMappings();

function currentHourStart(): Date {
const hour = new Date();
hour.setUTCMinutes(0, 0, 0);
Expand Down Expand Up @@ -182,6 +220,38 @@ describe("admin routing analytics endpoint", () => {
expect(summaryA.uptime).toBe(80);
});

it("only excludes a mapping once its deactivation date has passed", async () => {
const now = new Date();
let dated = 0;
for (const modelId of deactivationModelIds) {
const res = await get(`?modelId=${modelId}&window=24h`, cookie);
expect(res.status).toBe(200);
const body = await res.json();

for (const mapping of body.mappings as {
providerId: string;
deactivatedAt: string | null;
routable: boolean;
excludedReasons: string[];
}[]) {
if (!mapping.deactivatedAt) {
expect(mapping.excludedReasons).not.toContain("deactivated");
continue;
}
dated++;
if (new Date(mapping.deactivatedAt) <= now) {
expect(mapping.excludedReasons).toContain("deactivated");
expect(mapping.routable).toBe(false);
} else {
// Scheduled, not deactivated: routing compares against the date, so
// the mapping still elects and must stay scoreable here.
expect(mapping.excludedReasons).not.toContain("deactivated");
}
}
}
expect(dated).toBeGreaterThan(0);
});

it("counts a mapping's regional traffic once", async () => {
const hour = currentHourStart();
// A mapping with regions is stored as a region-less root row plus one row
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/routes/admin-routing-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
models,
type ProviderModelMapping,
} from "@llmgateway/models";
import { isMappingDeactivated } from "@llmgateway/shared/deactivation";
import { getDefaultRoutingConfig } from "@llmgateway/shared/routing-config";
import { routingSelectionKind } from "@llmgateway/shared/routing-telemetry";

Expand Down Expand Up @@ -383,7 +384,12 @@ async function buildMappingInfos(
const stability = mapping.stability ?? modelStability ?? "stable";
const priority = providerDef?.priority ?? 1;
const excludedReasons: string[] = [];
if (mapping.deactivatedAt) {
// Only a deactivation date that has actually passed excludes a mapping.
// Routing itself compares against the date, so a scheduled (future)
// deactivation still elects and serves traffic — flagging it here would
// show the mapping as unroutable and drop it from the score table while
// it is demonstrably receiving requests.
if (isMappingDeactivated(mapping)) {
excludedReasons.push("deactivated");
}
if (stability === "unstable" || stability === "experimental") {
Expand Down
34 changes: 31 additions & 3 deletions ee/admin/src/app/routing-analytics/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
ModelMappingSelector,
parseMappingValue,
} from "@llmgateway/shared/components";
import { isDeactivationScheduledSoon } from "@llmgateway/shared/deactivation";
import {
ROUTING_EXCLUSION_REASON_LABELS,
ROUTING_SELECTION_KIND_LABELS,
Expand Down Expand Up @@ -198,6 +199,19 @@ function formatSelectionPrice(price: number, isImageModel: boolean): string {
return `$${(price * 1e6).toFixed(2)}/M`;
}

function formatDeactivationDate(value: string | null): string {
if (!value) {
return "";
}
// Catalogue deactivation dates are UTC-midnight calendar dates, not instants:
// rendering them in the viewer's zone shows the previous day west of UTC.
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function formatContribution(value: number): string {
if (value === 0) {
return "—";
Expand Down Expand Up @@ -863,9 +877,23 @@ export function RoutingAnalyticsClient() {
</TableCell>
<TableCell>
{mapping.routable ? (
<Badge variant="outline" className="text-xs">
{mapping.stability}
</Badge>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs">
{mapping.stability}
</Badge>
{isDeactivationScheduledSoon(mapping) ? (
<Badge
variant="outline"
className="text-xs border-amber-500 text-amber-600 dark:text-amber-400"
title="Scheduled deactivation. The mapping still routes normally until this date."
>
deactivates{" "}
{formatDeactivationDate(
mapping.deactivatedAt,
)}
</Badge>
) : null}
</div>
) : (
<div className="flex flex-wrap gap-1">
{mapping.excludedReasons.map((reason) => (
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"types": "./dist/video-access.d.ts",
"import": "./dist/video-access.js"
},
"./deactivation": {
"types": "./dist/deactivation.d.ts",
"import": "./dist/deactivation.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "../deactivation";
export * from "./integration-guides-grid";
export * from "./integration-icons";
export * from "./log-card";
Expand All @@ -6,7 +7,6 @@ export * from "./model-mapping-selector";
export * from "./model-selector";
export * from "./models-directory/all-models";
export * from "./models-directory/api-types";
export * from "./models-directory/deactivation";
export * from "./models-directory/format";
export * from "./models-directory/model-card";
export * from "./models-directory/model-category-filters";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isMappingDeactivated } from "@/deactivation";
import { cn } from "@/lib/utils";

import { isMappingDeactivated } from "./deactivation";
import { formatDeprecationDate, formatPerImagePriceRange } from "./format";
import { ModelCard } from "./model-card";
import { applyCategoryFilter } from "./model-category-filters";
Expand Down

This file was deleted.

13 changes: 0 additions & 13 deletions packages/shared/src/components/models-directory/deactivation.ts

This file was deleted.

81 changes: 81 additions & 0 deletions packages/shared/src/deactivation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vitest";

import {
isDeactivationScheduledSoon,
isMappingDeactivated,
} from "./deactivation";

const now = new Date("2026-07-27T00:00:00.000Z");

describe("isMappingDeactivated", () => {
test("is false without a deactivation date", () => {
expect(isMappingDeactivated({}, now)).toBe(false);
expect(isMappingDeactivated({ deactivatedAt: null }, now)).toBe(false);
});

test("is true once the date has passed", () => {
expect(
isMappingDeactivated({ deactivatedAt: new Date("2026-07-26") }, now),
).toBe(true);
expect(isMappingDeactivated({ deactivatedAt: "2026-01-01" }, now)).toBe(
true,
);
});

test("is false for a scheduled future deactivation", () => {
expect(
isMappingDeactivated({ deactivatedAt: new Date("2026-07-28") }, now),
).toBe(false);
expect(isMappingDeactivated({ deactivatedAt: "2026-12-31" }, now)).toBe(
false,
);
});
});

describe("isDeactivationScheduledSoon", () => {
test("is false without a deactivation date", () => {
expect(isDeactivationScheduledSoon({}, now)).toBe(false);
expect(isDeactivationScheduledSoon({ deactivatedAt: null }, now)).toBe(
false,
);
});

test("is true inside the notice window", () => {
expect(
isDeactivationScheduledSoon({ deactivatedAt: "2026-07-28" }, now),
).toBe(true);
expect(
isDeactivationScheduledSoon(
{ deactivatedAt: new Date("2026-08-25T00:00:00.000Z") },
now,
),
).toBe(true);
});

test("is false beyond the notice window", () => {
expect(
isDeactivationScheduledSoon(
{ deactivatedAt: new Date("2026-08-27T00:00:00.001Z") },
now,
),
).toBe(false);
expect(
isDeactivationScheduledSoon({ deactivatedAt: "2028-01-11" }, now),
).toBe(false);
});

test("is false once the date has passed", () => {
expect(
isDeactivationScheduledSoon({ deactivatedAt: "2026-07-26" }, now),
).toBe(false);
});

test("honours a custom window", () => {
expect(
isDeactivationScheduledSoon({ deactivatedAt: "2026-08-10" }, now, 7),
).toBe(false);
expect(
isDeactivationScheduledSoon({ deactivatedAt: "2026-07-30" }, now, 7),
).toBe(true);
});
});
43 changes: 43 additions & 0 deletions packages/shared/src/deactivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* How close a scheduled deactivation has to be before it is worth surfacing.
* A date further out than this is catalogue bookkeeping (providers routinely
* publish retirement dates a year or more ahead) — the mapping routes normally
* and nothing about it should read as "deactivated".
*/
export const DEACTIVATION_NOTICE_DAYS = 30;

const DAY_MS = 24 * 60 * 60 * 1000;

/**
* A provider mapping only counts as deactivated once its deactivation date has
* passed — a future date is a scheduled deactivation and the mapping is still
* routable, so it stays visible in public model lists.
*/
export function isMappingDeactivated(
mapping: { deactivatedAt?: Date | string | null },
now: Date = new Date(),
): boolean {
return Boolean(
mapping.deactivatedAt && new Date(mapping.deactivatedAt) <= now,
);
}

/**
* True while the mapping is still live but its scheduled deactivation falls
* inside the notice window. Such a mapping is fully routable — only how its
* status is displayed changes, so callers must never use this to filter.
*/
export function isDeactivationScheduledSoon(
mapping: { deactivatedAt?: Date | string | null },
now: Date = new Date(),
withinDays: number = DEACTIVATION_NOTICE_DAYS,
): boolean {
if (!mapping.deactivatedAt) {
return false;
}
const deactivatedAt = new Date(mapping.deactivatedAt);
return (
deactivatedAt > now &&
deactivatedAt.getTime() - now.getTime() <= withinDays * DAY_MS
);
}
Loading