diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac687a86..bc9bf9c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,10 @@ You don't need to know the schema to file one. A link to the official docs is th 3. **Required top-level fields:** `provider`, `authType` (`api_key` or `subscription`), `model`, `params`. + Optional lifecycle fields are `status` (`active`, `deprecated`, or + `retired`), `replacement` (a provider-qualified model id), and `shutdownOn` + (ISO `YYYY-MM-DD`). Omit `status` when lifecycle status has not been tracked. + 4. **Parameter shape:** each item in `params` has: - `path` (required): exact provider API request parameter path; supports dot notation for nested fields (`thinking.type`, `generationConfig.topK`). - `type` (required): one of `boolean`, `enum`, `integer`, `number`, `string`. diff --git a/README.md b/README.md index 19a22f44..a818c9f3 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ curl -s https://modelparams.dev/api/v1/validate \ } ``` +### Model lifecycle + +Full model records also expose lifecycle metadata: + +```yaml +status: deprecated +replacement: openai/gpt-5.6-sol +shutdownOn: 2026-10-23 +``` + +`status` is `active`, `deprecated`, or `retired`. When lifecycle status has not +been tracked, the field stays absent from YAML, the API, and package data. +`replacement` and `shutdownOn` stay absent until the provider publishes them. + ## Agents Give a coding agent the catalog, so it looks parameters up instead of recalling them. @@ -111,7 +125,7 @@ claude mcp add --transport http modelparams https://modelparams.dev/mcp codex mcp add modelparams --url https://modelparams.dev/mcp ``` -Four tools: `validate_model_params` to check a params object before you send it, `get_model_params` for one model's full surface, `list_models` and `find_models_supporting` to search the catalog. +Four tools: `validate_model_params` to check a params object before you send it, `get_model_params` for one model's parameter surface and lifecycle metadata, `list_models` and `find_models_supporting` to search the catalog. **Without MCP** — `npx skills add mnfst/modelparams.dev` installs the companion agent skill, and [llms.txt](https://modelparams.dev/llms.txt) points an agent at a URL. diff --git a/docs/model-parameters-schema.md b/docs/model-parameters-schema.md index 873a3fc8..10ea3c7b 100644 --- a/docs/model-parameters-schema.md +++ b/docs/model-parameters-schema.md @@ -25,6 +25,7 @@ parameters. "provider": "anthropic", "authType": "api_key", "model": "claude-haiku-4-5", + "status": "active", "params": [ { "path": "top_p", @@ -66,6 +67,12 @@ Conventions: go stale the next time the host adds a region. Availability per region is account state and is deliberately not recorded here. - `authType` is `api_key` or `subscription`. +- `status` is `active`, `deprecated`, or `retired`. Omit it when lifecycle + status has not been tracked; generated catalog data preserves the omission. +- `replacement` is an optional provider-qualified model id, such as + `anthropic/claude-opus-4-8`. +- `shutdownOn` is an optional provider-published shutdown date in ISO + `YYYY-MM-DD` format. - `params` is the non-empty list of parameters for that exact route. - `path` is the exact provider API request parameter path in dot notation. Use the provider's documented field casing, such as `top_p`, @@ -81,6 +88,20 @@ Conventions: - `group` is a semantic grouping for ordering and display. - `applicability` is optional. Omitted means always available. +## Lifecycle Metadata + +Lifecycle fields sit beside `params`; they are not request parameters. + +```yaml +status: deprecated +replacement: anthropic/claude-opus-4-8 +shutdownOn: 2026-10-01 +``` + +Omit `replacement` or `shutdownOn` when the provider has not published one. +Use the provider-qualified catalog id for `replacement` so consumers can look +up the target directly. + ## Parameter Scope MPS entries should describe only parameters the user or consumer can configure diff --git a/packages/modelparams-mcp/README.md b/packages/modelparams-mcp/README.md index ec661955..ab5fe802 100644 --- a/packages/modelparams-mcp/README.md +++ b/packages/modelparams-mcp/README.md @@ -77,7 +77,7 @@ Issue codes: `unknown_parameter` (no such knob on this model), `invalid_value` ( ### `get_model_params` -Every parameter for one model — type, range, enum values, default, and the conditional rules that gate it. +Every parameter for one model — type, range, enum values, default, and the conditional rules that gate it. The top-level response also includes `status`, `replacement`, and `shutdownOn` when that lifecycle metadata is tracked; these fields are not request parameters. ### `list_models` diff --git a/packages/modelparams-mcp/src/server.ts b/packages/modelparams-mcp/src/server.ts index 230c4ad9..9041a50d 100644 --- a/packages/modelparams-mcp/src/server.ts +++ b/packages/modelparams-mcp/src/server.ts @@ -79,8 +79,9 @@ export function createServer(version = "0.0.0"): McpServer { title: "Get model parameters", description: "List every parameter a model accepts, with its type, allowed range or enum values, " + - "default, and any conditional rules governing when it applies. Use before writing " + - "code that calls a model, or when building a model settings UI.", + "default, and any conditional rules governing when it applies. Also returns lifecycle " + + "status and migration guidance when tracked. Use before writing code that calls a " + + "model, or when building a model settings UI.", inputSchema: { model: z .string() diff --git a/packages/modelparams-mcp/src/tools.ts b/packages/modelparams-mcp/src/tools.ts index 14cf2a81..8b57ab80 100644 --- a/packages/modelparams-mcp/src/tools.ts +++ b/packages/modelparams-mcp/src/tools.ts @@ -140,6 +140,9 @@ export function getModelParams(input: { model: string; baseUrl?: string }): Tool provider: entry.provider, authType: entry.authType, ...("wireId" in entry ? { wireId: entry.wireId } : {}), + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.replacement !== undefined ? { replacement: entry.replacement } : {}), + ...(entry.shutdownOn !== undefined ? { shutdownOn: entry.shutdownOn } : {}), parameterCount: params.length, params: params.map(describeParam), defaults: getDefaults(resolved.id), diff --git a/packages/modelparams-mcp/tests/server.test.ts b/packages/modelparams-mcp/tests/server.test.ts index e0dbb163..5b794f91 100644 --- a/packages/modelparams-mcp/tests/server.test.ts +++ b/packages/modelparams-mcp/tests/server.test.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { getModel } from "modelparams"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createServer } from "../src/server.js"; @@ -25,6 +26,9 @@ interface ValidateResult { interface ModelParamsResult { model: string; + status?: "active" | "deprecated" | "retired"; + replacement?: string; + shutdownOn?: string; parameterCount: number; params: ParamInfo[]; } @@ -169,6 +173,37 @@ describe("get_model_params", () => { const display = out.params.find((p) => p.path === "thinking.display"); expect(display!.appliesOnlyWhen).toBeDefined(); }); + + it("returns tracked lifecycle metadata at the top level", async () => { + const entry = getModel("openai/gpt-5.5") as unknown as Record; + const previous = { + status: entry.status, + replacement: entry.replacement, + shutdownOn: entry.shutdownOn, + }; + + Object.assign(entry, { + status: "deprecated", + replacement: "openai/gpt-5.6-sol", + shutdownOn: "2026-10-23", + }); + + try { + const out = await call("get_model_params", { + model: "openai/gpt-5.5", + }); + expect(out).toMatchObject({ + status: "deprecated", + replacement: "openai/gpt-5.6-sol", + shutdownOn: "2026-10-23", + }); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete entry[key]; + else entry[key] = value; + } + } + }); }); describe("list_models", () => { diff --git a/packages/modelparams-python/scripts/codegen.ts b/packages/modelparams-python/scripts/codegen.ts index 1f870d0b..847eb171 100644 --- a/packages/modelparams-python/scripts/codegen.ts +++ b/packages/modelparams-python/scripts/codegen.ts @@ -188,7 +188,6 @@ async function main(): Promise { providerModels.push(model); byProvider.set(model.provider, providerModels); } - const typeFiles = new Set(["__init__.py"]); for (const [provider, providerModels] of byProvider) { const filename = `${moduleName(provider)}.py`; diff --git a/packages/modelparams-python/src/modelparams/__init__.py b/packages/modelparams-python/src/modelparams/__init__.py index 7c976688..bd530514 100644 --- a/packages/modelparams-python/src/modelparams/__init__.py +++ b/packages/modelparams-python/src/modelparams/__init__.py @@ -16,6 +16,7 @@ ApplicabilityCondition, CatalogEntry, JsonPrimitive, + LifecycleStatus, Parameter, ParamGroup, ParamRange, @@ -43,6 +44,7 @@ def _package_version() -> str: "ApplicabilityCondition", "CatalogEntry", "JsonPrimitive", + "LifecycleStatus", "ModelId", "Parameter", "ParamGroup", diff --git a/packages/modelparams-python/src/modelparams/models.py b/packages/modelparams-python/src/modelparams/models.py index be1dbe8a..107381ba 100644 --- a/packages/modelparams-python/src/modelparams/models.py +++ b/packages/modelparams-python/src/modelparams/models.py @@ -7,6 +7,7 @@ JsonPrimitive: TypeAlias = str | int | float | bool | None JsonNumber: TypeAlias = int | float AuthType: TypeAlias = Literal["api_key", "subscription"] +LifecycleStatus: TypeAlias = Literal["active", "deprecated", "retired"] ParamType: TypeAlias = Literal["boolean", "enum", "integer", "number", "string"] ParamGroup: TypeAlias = Literal[ "generation_length", @@ -62,4 +63,7 @@ class CatalogEntry(FrozenModel): # Exact wire string when the host's native id differs from the catalog # slug (pathed ids: accounts/fireworks/models/kimi-k3, openai/gpt-oss-20b). wire_id: str | None = Field(alias="wireId", default=None) + status: LifecycleStatus | None = None + replacement: str | None = None + shutdown_on: str | None = Field(alias="shutdownOn", default=None) params: tuple[Parameter, ...] diff --git a/packages/modelparams-python/tests/test_catalog.py b/packages/modelparams-python/tests/test_catalog.py index e80ff31a..0f4187cf 100644 --- a/packages/modelparams-python/tests/test_catalog.py +++ b/packages/modelparams-python/tests/test_catalog.py @@ -45,6 +45,7 @@ def test_get_model_returns_frozen_pythonic_metadata() -> None: assert model.provider == "anthropic" assert model.auth_type == "api_key" assert model.model == "claude-haiku-4-5-20251001" + assert model.status is None assert model.params with pytest.raises(ValidationError): model.model = "changed" diff --git a/packages/modelparams/scripts/codegen.ts b/packages/modelparams/scripts/codegen.ts index 5cff916c..33d62301 100644 --- a/packages/modelparams/scripts/codegen.ts +++ b/packages/modelparams/scripts/codegen.ts @@ -66,7 +66,6 @@ async function main(): Promise { const ids = models.map(modelId); const providers = [...new Set(models.map((m) => m.provider))].sort(); - // 1. model-ids.ts — ModelId union + Provider union await fs.writeFile( path.join(OUT_DIR, "model-ids.ts"), @@ -103,8 +102,18 @@ async function main(): Promise { path.join(OUT_DIR, "data.ts"), HEADER + `import type { ModelId } from "./model-ids.js";\n\n` + - `export const CATALOG = ${JSON.stringify(models, null, 2)} as const;\n\n` + - `export type CatalogEntry = (typeof CATALOG)[number];\n\n` + + `const GENERATED_CATALOG = ${JSON.stringify(models, null, 2)} as const;\n\n` + + `type GeneratedCatalogEntry = (typeof GENERATED_CATALOG)[number];\n` + + `export type LifecycleStatus = "active" | "deprecated" | "retired";\n` + + `type WithLifecycle = T extends unknown\n` + + ` ? Omit & {\n` + + ` readonly status?: LifecycleStatus;\n` + + ` readonly replacement?: string;\n` + + ` readonly shutdownOn?: string;\n` + + ` }\n` + + ` : never;\n` + + `export type CatalogEntry = WithLifecycle;\n\n` + + `export const CATALOG: readonly CatalogEntry[] = GENERATED_CATALOG;\n\n` + `function authSuffix(authType: CatalogEntry["authType"]): "" | "-subscription" {\n` + ` return authType === "api_key" ? "" : "-subscription";\n` + `}\n\n` + diff --git a/packages/modelparams/src/generated/data.ts b/packages/modelparams/src/generated/data.ts index 9f5e6290..4cf20afd 100644 --- a/packages/modelparams/src/generated/data.ts +++ b/packages/modelparams/src/generated/data.ts @@ -3,7 +3,7 @@ import type { ModelId } from "./model-ids.js"; -export const CATALOG = [ +const GENERATED_CATALOG = [ { "provider": "alibaba", "authType": "api_key", @@ -29408,7 +29408,18 @@ export const CATALOG = [ } ] as const; -export type CatalogEntry = (typeof CATALOG)[number]; +type GeneratedCatalogEntry = (typeof GENERATED_CATALOG)[number]; +export type LifecycleStatus = "active" | "deprecated" | "retired"; +type WithLifecycle = T extends unknown + ? Omit & { + readonly status?: LifecycleStatus; + readonly replacement?: string; + readonly shutdownOn?: string; + } + : never; +export type CatalogEntry = WithLifecycle; + +export const CATALOG: readonly CatalogEntry[] = GENERATED_CATALOG; function authSuffix(authType: CatalogEntry["authType"]): "" | "-subscription" { return authType === "api_key" ? "" : "-subscription"; diff --git a/packages/modelparams/src/index.ts b/packages/modelparams/src/index.ts index 341f7466..ce16bc39 100644 --- a/packages/modelparams/src/index.ts +++ b/packages/modelparams/src/index.ts @@ -14,7 +14,7 @@ export type { export type { ApplicabilityIssue, DropCode, DroppedParam, DropResult } from "./applicability.js"; export type { ModelId, Provider } from "./generated/model-ids.js"; export type { ParamsById } from "./generated/params-by-id.js"; -export type { CatalogEntry } from "./generated/data.js"; +export type { CatalogEntry, LifecycleStatus } from "./generated/data.js"; export type { ParamIssue, ParseParamsResult } from "./parse.js"; export type { StandardSchemaV1, diff --git a/packages/modelparams/test-d/types.test-d.ts b/packages/modelparams/test-d/types.test-d.ts index ad6d6b0c..d309b504 100644 --- a/packages/modelparams/test-d/types.test-d.ts +++ b/packages/modelparams/test-d/types.test-d.ts @@ -2,6 +2,7 @@ import { expectAssignable, expectError, expectType } from "tsd"; import { getModel, parseParams, paramsSchema } from "../dist/index.js"; import type { JsonPrimitive, + LifecycleStatus, Param, ParamsOf, ParseParamsResult, @@ -34,6 +35,7 @@ expectType(empty); // The precise catalog params assign to the loose `Param` type with no cast. expectAssignable(getModel("openai/gpt-4.1").params); +expectType(getModel("openai/gpt-4.1").status); // parseParams returns the discriminated result and rejects unknown model ids. expectType(parseParams("openai/gpt-4.1", {})); diff --git a/packages/modelparams/tests/runtime.test.ts b/packages/modelparams/tests/runtime.test.ts index 2bf4f09f..d139160a 100644 --- a/packages/modelparams/tests/runtime.test.ts +++ b/packages/modelparams/tests/runtime.test.ts @@ -57,6 +57,7 @@ describe("getModel", () => { expect(m.provider).toBe("anthropic"); expect(m.authType).toBe("api_key"); expect(m.model).toBe("claude-haiku-4-5-20251001"); + expect(m.status).toBeUndefined(); expect(m.params.length).toBeGreaterThan(0); }); }); diff --git a/src/build/render-model.ts b/src/build/render-model.ts index f40e2d77..a68da783 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -105,6 +105,16 @@ export function modelIntro(model: Model): string { return `These are the API parameters ${SITE_NAME} tracks for ${who}${access} — the settings you send in a request. Each row gives the type, default, valid range or values, and the conditions that gate it. It's the same data the JSON API serves.`; } +export function modelLifecycleSummary(model: Model): string | null { + const status = model.status; + const parts: string[] = []; + if (status === "deprecated") parts.push("This model is deprecated."); + if (status === "retired") parts.push("This model is retired."); + if (model.shutdownOn) parts.push(`Shutdown date: ${model.shutdownOn}.`); + if (model.replacement) parts.push(`Suggested replacement: ${model.replacement}.`); + return parts.length > 0 ? parts.join(" ") : null; +} + export async function renderModelPage(model: Model, allModels: Model[]): Promise { const siblings = allModels .filter((other) => other.provider === model.provider && modelId(other) !== modelId(model)) @@ -117,6 +127,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise siblings, faqs, intro: modelIntro(model), + lifecycleSummary: modelLifecycleSummary(model), providerName: providerLabel(model.provider), modelName: modelLabel(model), fullName: modelFullLabel(model), diff --git a/src/client/webmcp.ts b/src/client/webmcp.ts index e553d4ab..5f0ca5f7 100644 --- a/src/client/webmcp.ts +++ b/src/client/webmcp.ts @@ -4,6 +4,7 @@ // server-side modules (and no zod) into the client bundle. type AuthType = "api_key" | "subscription"; +type LifecycleStatus = "active" | "deprecated" | "retired"; interface CatalogParam { path: string; @@ -18,6 +19,9 @@ interface CatalogModel { provider: string; authType: AuthType; model: string; + status?: LifecycleStatus; + replacement?: string; + shutdownOn?: string; params: CatalogParam[]; } @@ -144,6 +148,9 @@ export function searchCatalog(catalog: Catalog, params: Record) provider: model.provider, model: model.model, authType: model.authType, + status: model.status, + replacement: model.replacement, + shutdownOn: model.shutdownOn, parameterCount: model.params.length, parameters: model.params.map((p) => p.path), })), diff --git a/src/schema/model.ts b/src/schema/model.ts index bb1c2e4c..265494b8 100644 --- a/src/schema/model.ts +++ b/src/schema/model.ts @@ -3,6 +3,9 @@ import { z } from "zod"; export const AuthType = z.enum(["api_key", "subscription"]); export type AuthType = z.infer; +export const LifecycleStatus = z.enum(["active", "deprecated", "retired"]); +export type LifecycleStatus = z.infer; + export const ParameterType = z.enum(["boolean", "enum", "integer", "number", "string"]); export type ParameterType = z.infer; @@ -19,9 +22,22 @@ export type ParameterGroup = z.infer; const PROVIDER_SLUG = /^[a-z0-9][a-z0-9-]*$/; const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const MODEL_REFERENCE = /^[a-z0-9][a-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; const PARAM_PATH = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/; const BLOCKED_PARAM_PATHS = new Set(["stream"]); +const IsoDate = z + .string() + .regex(ISO_DATE, "date must use ISO YYYY-MM-DD") + .refine( + (value) => { + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value; + }, + { message: "date must be a real calendar date" }, + ); + export const Range = z .object({ min: z.number().optional(), @@ -177,11 +193,16 @@ export const Model = z * May contain `{scope}`, a placeholder the caller replaces with a routing * geography (`us`, `eu`, `global`, …) before sending. */ - wireId: z + wireId: z.string().min(1).regex(/^\S+$/, "wireId must not contain whitespace").optional(), + /** Omitted when lifecycle status has not been tracked for this model. */ + status: LifecycleStatus.optional(), + /** Provider-qualified model id suggested as the migration target. */ + replacement: z .string() - .min(1) - .regex(/^\S+$/, "wireId must not contain whitespace") + .regex(MODEL_REFERENCE, "replacement must be a provider-qualified model id") .optional(), + /** Provider-published shutdown date. */ + shutdownOn: IsoDate.optional(), params: z.array(Parameter), }) .strict(); diff --git a/src/views/model.ejs b/src/views/model.ejs index f38317ec..d9ccdbe0 100644 --- a/src/views/model.ejs +++ b/src/views/model.ejs @@ -18,6 +18,11 @@ <% if (isSubscription) { %> <%= helpers.authLabel(model.authType) %> <% } %> + <% if (model.status === "deprecated") { %> + Deprecated + <% } else if (model.status === "retired") { %> + Retired + <% } %> <%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %>

@@ -26,6 +31,11 @@

<%= intro %>

+ <% if (lifecycleSummary) { %> +

+ <%= lifecycleSummary %> +

+ <% } %>

After the parameter count instead — how many weights <%= modelName %> has? That's a different number, and we don't track it. diff --git a/src/views/partials/model_row.ejs b/src/views/partials/model_row.ejs index cb9faf48..790753c8 100644 --- a/src/views/partials/model_row.ejs +++ b/src/views/partials/model_row.ejs @@ -39,6 +39,11 @@ ><%= helpers.authLabel(model.authType) %> <% } %> + <% if (model.status === "deprecated") { %> + Deprecated + <% } else if (model.status === "retired") { %> + Retired + <% } %> <%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %> diff --git a/tests/load.test.ts b/tests/load.test.ts index 565cf3cf..5ed9fe7d 100644 --- a/tests/load.test.ts +++ b/tests/load.test.ts @@ -61,6 +61,25 @@ describe("loadAllModels", () => { "anthropic/claude-opus-4-7", "anthropic/claude-opus-4-7-subscription", ]); + expect(result.models.every((model) => model.status === undefined)).toBe(true); + }); + + it("preserves explicit lifecycle metadata", async () => { + await writeModel( + "anthropic/claude-opus-4-7.yaml", + VALID_OPUS.replace( + "params:", + "status: deprecated\nreplacement: anthropic/claude-opus-4-8\nshutdownOn: 2026-10-01\nparams:", + ), + ); + + const result = await loadAllModels(tmpRoot); + expect(result.issues).toEqual([]); + expect(result.models[0]).toMatchObject({ + status: "deprecated", + replacement: "anthropic/claude-opus-4-8", + shutdownOn: "2026-10-01", + }); }); it("flags provider/path mismatch", async () => { diff --git a/tests/render-meta.test.ts b/tests/render-meta.test.ts index 980dda3a..1c8506c1 100644 --- a/tests/render-meta.test.ts +++ b/tests/render-meta.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from "vitest"; import { homeDescription, homeTitle } from "../src/build/render.js"; import { + modelLifecycleSummary, modelPageDescription, modelPageTitle, modelParamProse, + renderModelPage, } from "../src/build/render-model.js"; import { providerPageDescription, providerPageTitle } from "../src/build/render-provider.js"; import { parameterPageDescription, parameterPageTitle } from "../src/build/render-parameter.js"; @@ -112,6 +114,33 @@ describe("model page meta", () => { expect(desc).toContain("Anthropic Claude Opus 4.7"); expect(desc).toContain("temperature"); }); + + it("summarizes lifecycle metadata when present", () => { + expect( + modelLifecycleSummary( + model({ + status: "deprecated", + replacement: "anthropic/claude-opus-4-8", + shutdownOn: "2026-10-01", + }), + ), + ).toBe( + "This model is deprecated. Shutdown date: 2026-10-01. Suggested replacement: anthropic/claude-opus-4-8.", + ); + expect(modelLifecycleSummary(model())).toBeNull(); + }); + + it("renders lifecycle status and migration guidance", async () => { + const entry = model({ + status: "deprecated", + replacement: "anthropic/claude-opus-4-8", + shutdownOn: "2026-10-01", + }); + const html = await renderModelPage(entry, [entry]); + expect(html).toContain(">Deprecated"); + expect(html).toContain("Shutdown date: 2026-10-01."); + expect(html).toContain("Suggested replacement: anthropic/claude-opus-4-8."); + }); }); describe("home page meta", () => { diff --git a/tests/schema.test.ts b/tests/schema.test.ts index 778fedca..aa9450a1 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -72,6 +72,22 @@ describe("Model schema", () => { expect(result.success).toBe(false); }); + it("accepts lifecycle metadata", () => { + const result = Model.safeParse({ + ...VALID_MODEL, + status: "deprecated", + replacement: "anthropic/claude-opus-4-8", + shutdownOn: "2026-10-01", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid lifecycle metadata", () => { + expect(Model.safeParse({ ...VALID_MODEL, status: "legacy" }).success).toBe(false); + expect(Model.safeParse({ ...VALID_MODEL, replacement: "claude-opus-4-8" }).success).toBe(false); + expect(Model.safeParse({ ...VALID_MODEL, shutdownOn: "2026-02-30" }).success).toBe(false); + }); + it("rejects unknown top-level fields", () => { const result = Model.safeParse({ ...VALID_MODEL, metadata: { source: "docs" } }); expect(result.success).toBe(false); @@ -349,4 +365,11 @@ describe("JSON Schema generator", () => { expect(schema.$id).toBe("https://modelparams.dev/api/v1/schema.json"); expect(typeof schema.title).toBe("string"); }); + + it("includes lifecycle metadata", () => { + const schema = JSON.stringify(buildModelJsonSchema()); + expect(schema).toContain('"status"'); + expect(schema).toContain('"replacement"'); + expect(schema).toContain('"shutdownOn"'); + }); });