From 3c58c723c0c0d9a09d3c0794d0eab4a8b5a416b2 Mon Sep 17 00:00:00 2001 From: Guillaume Gay Date: Thu, 20 Aug 2026 18:30:56 +0200 Subject: [PATCH 1/3] feat: add model lifecycle metadata --- CONTRIBUTING.md | 4 +++ README.md | 14 +++++++++ docs/model-parameters-schema.md | 21 ++++++++++++++ .../modelparams-python/scripts/codegen.ts | 10 ++++++- .../src/modelparams/__init__.py | 2 ++ .../src/modelparams/models.py | 4 +++ .../modelparams-python/tests/test_catalog.py | 1 + packages/modelparams/scripts/codegen.ts | 25 ++++++++++++++-- packages/modelparams/src/generated/data.ts | 18 ++++++++++-- packages/modelparams/src/index.ts | 2 +- packages/modelparams/test-d/types.test-d.ts | 2 ++ packages/modelparams/tests/runtime.test.ts | 1 + src/build/render-model.ts | 11 +++++++ src/client/webmcp.ts | 7 +++++ src/data/load.ts | 7 ++++- src/schema/model.ts | 27 +++++++++++++++-- src/views/model.ejs | 10 +++++++ src/views/partials/model_row.ejs | 5 ++++ tests/load.test.ts | 19 ++++++++++++ tests/render-meta.test.ts | 29 +++++++++++++++++++ tests/schema.test.ts | 23 +++++++++++++++ tests/webmcp.test.ts | 2 ++ 22 files changed, 234 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac687a86..cf509514 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`). Omitted `status` values are emitted as `active`. + 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..d08aee61 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`. Existing YAML entries may +omit it; generated API and package data emit `active`. `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. diff --git a/docs/model-parameters-schema.md b/docs/model-parameters-schema.md index 873a3fc8..d54a730b 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`. It is optional in source + YAML and defaults to `active` in generated catalog data. +- `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-python/scripts/codegen.ts b/packages/modelparams-python/scripts/codegen.ts index 1f870d0b..e6f426fb 100644 --- a/packages/modelparams-python/scripts/codegen.ts +++ b/packages/modelparams-python/scripts/codegen.ts @@ -156,6 +156,13 @@ function assertUniqueNames(models: Model[]): void { } } +function compactLifecycle(model: Model): Model { + if (model.status !== "active") return model; + const compact = { ...model }; + delete compact.status; + return compact; +} + async function removeStaleTypeModules(expected: Set): Promise { let entries: string[] = []; try { @@ -188,6 +195,7 @@ async function main(): Promise { providerModels.push(model); byProvider.set(model.provider, providerModels); } + const compactModels = models.map(compactLifecycle); const typeFiles = new Set(["__init__.py"]); for (const [provider, providerModels] of byProvider) { @@ -205,7 +213,7 @@ async function main(): Promise { await Promise.all([ fs.writeFile( path.join(GENERATED_DIR, "catalog.json"), - `${JSON.stringify(models, null, 2)}\n`, + `${JSON.stringify(compactModels, null, 2)}\n`, "utf8", ), fs.writeFile(path.join(GENERATED_DIR, "model_ids.py"), emitModelIds(models), "utf8"), 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..c1e62b73 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 = "active" + 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..8f76e7ae 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 == "active" 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..3e8be3d7 100644 --- a/packages/modelparams/scripts/codegen.ts +++ b/packages/modelparams/scripts/codegen.ts @@ -51,6 +51,13 @@ function emitDefaultsEntry(m: Model): string { : ` ${JSON.stringify(id)}: {},`; } +function compactLifecycle(model: Model): Model { + if (model.status !== "active") return model; + const compact = { ...model }; + delete compact.status; + return compact; +} + async function main(): Promise { const { models, issues } = await loadAllModels(); @@ -66,6 +73,7 @@ async function main(): Promise { const ids = models.map(modelId); const providers = [...new Set(models.map((m) => m.provider))].sort(); + const compactModels = models.map(compactLifecycle); // 1. model-ids.ts — ModelId union + Provider union await fs.writeFile( @@ -103,8 +111,21 @@ 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(compactModels, 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.map((model) => ({\n` + + ` status: "active",\n` + + ` ...model,\n` + + `}));\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..c3680682 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,21 @@ 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.map((model) => ({ + status: "active", + ...model, +})); 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..b026e22b 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..b434eb88 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).toBe("active"); expect(m.params.length).toBeGreaterThan(0); }); }); diff --git a/src/build/render-model.ts b/src/build/render-model.ts index f40e2d77..a2da828e 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 ?? "active"; + 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..31b04bfc 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/data/load.ts b/src/data/load.ts index fe0e1699..ddcd2278 100644 --- a/src/data/load.ts +++ b/src/data/load.ts @@ -63,7 +63,12 @@ function validateOne( if (!parsed.success) { return { issue: { file, message: formatZodIssue(parsed.error) } }; } - const model = parsed.data; + const { params, ...metadata } = parsed.data; + const model: ModelType = { + ...metadata, + status: metadata.status ?? "active", + params, + }; const expectedId = expectedIdFromPath(file, modelsDir); const derivedId = modelId(model); diff --git a/src/schema/model.ts b/src/schema/model.ts index bb1c2e4c..fae1fac1 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 source values are emitted as `active` by the catalog loader. */ + 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..94902a6f 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 === "active")).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"'); + }); }); diff --git a/tests/webmcp.test.ts b/tests/webmcp.test.ts index 82ca9647..1f815bc4 100644 --- a/tests/webmcp.test.ts +++ b/tests/webmcp.test.ts @@ -8,6 +8,7 @@ function model(provider: string, name: string, authType: AuthType, paths: string provider, model: name, authType, + status: "active" as const, params: paths.map((path) => ({ path, type: "number", @@ -97,6 +98,7 @@ describe("searchCatalog", () => { id: "anthropic/claude-opus-4-7-subscription", provider: "anthropic", authType: "subscription", + status: "active", parameterCount: 2, }); expect(sub?.parameters).toEqual(["temperature", "thinking.type"]); From 5600cfa3dbe0f82bdf82ef9bfda19d0149441740 Mon Sep 17 00:00:00 2001 From: Guillaume Gay Date: Thu, 20 Aug 2026 19:01:51 +0200 Subject: [PATCH 2/3] fix: leave lifecycle status unset --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- docs/model-parameters-schema.md | 4 ++-- packages/modelparams-python/scripts/codegen.ts | 11 +---------- .../src/modelparams/models.py | 2 +- .../modelparams-python/tests/test_catalog.py | 2 +- packages/modelparams/scripts/codegen.ts | 18 +++--------------- packages/modelparams/src/generated/data.ts | 7 ++----- packages/modelparams/test-d/types.test-d.ts | 2 +- packages/modelparams/tests/runtime.test.ts | 2 +- src/build/render-model.ts | 2 +- src/client/webmcp.ts | 2 +- src/data/load.ts | 7 +------ src/schema/model.ts | 2 +- tests/load.test.ts | 2 +- tests/webmcp.test.ts | 2 -- 16 files changed, 21 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf509514..bc9bf9c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ You don't need to know the schema to file one. A link to the official docs is th Optional lifecycle fields are `status` (`active`, `deprecated`, or `retired`), `replacement` (a provider-qualified model id), and `shutdownOn` - (ISO `YYYY-MM-DD`). Omitted `status` values are emitted as `active`. + (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`). diff --git a/README.md b/README.md index d08aee61..c46d4235 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ replacement: openai/gpt-5.6-sol shutdownOn: 2026-10-23 ``` -`status` is `active`, `deprecated`, or `retired`. Existing YAML entries may -omit it; generated API and package data emit `active`. `replacement` and -`shutdownOn` stay absent until the provider publishes them. +`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 diff --git a/docs/model-parameters-schema.md b/docs/model-parameters-schema.md index d54a730b..10ea3c7b 100644 --- a/docs/model-parameters-schema.md +++ b/docs/model-parameters-schema.md @@ -67,8 +67,8 @@ 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`. It is optional in source - YAML and defaults to `active` in generated catalog data. +- `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 diff --git a/packages/modelparams-python/scripts/codegen.ts b/packages/modelparams-python/scripts/codegen.ts index e6f426fb..847eb171 100644 --- a/packages/modelparams-python/scripts/codegen.ts +++ b/packages/modelparams-python/scripts/codegen.ts @@ -156,13 +156,6 @@ function assertUniqueNames(models: Model[]): void { } } -function compactLifecycle(model: Model): Model { - if (model.status !== "active") return model; - const compact = { ...model }; - delete compact.status; - return compact; -} - async function removeStaleTypeModules(expected: Set): Promise { let entries: string[] = []; try { @@ -195,8 +188,6 @@ async function main(): Promise { providerModels.push(model); byProvider.set(model.provider, providerModels); } - const compactModels = models.map(compactLifecycle); - const typeFiles = new Set(["__init__.py"]); for (const [provider, providerModels] of byProvider) { const filename = `${moduleName(provider)}.py`; @@ -213,7 +204,7 @@ async function main(): Promise { await Promise.all([ fs.writeFile( path.join(GENERATED_DIR, "catalog.json"), - `${JSON.stringify(compactModels, null, 2)}\n`, + `${JSON.stringify(models, null, 2)}\n`, "utf8", ), fs.writeFile(path.join(GENERATED_DIR, "model_ids.py"), emitModelIds(models), "utf8"), diff --git a/packages/modelparams-python/src/modelparams/models.py b/packages/modelparams-python/src/modelparams/models.py index c1e62b73..107381ba 100644 --- a/packages/modelparams-python/src/modelparams/models.py +++ b/packages/modelparams-python/src/modelparams/models.py @@ -63,7 +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 = "active" + 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 8f76e7ae..0f4187cf 100644 --- a/packages/modelparams-python/tests/test_catalog.py +++ b/packages/modelparams-python/tests/test_catalog.py @@ -45,7 +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 == "active" + 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 3e8be3d7..33d62301 100644 --- a/packages/modelparams/scripts/codegen.ts +++ b/packages/modelparams/scripts/codegen.ts @@ -51,13 +51,6 @@ function emitDefaultsEntry(m: Model): string { : ` ${JSON.stringify(id)}: {},`; } -function compactLifecycle(model: Model): Model { - if (model.status !== "active") return model; - const compact = { ...model }; - delete compact.status; - return compact; -} - async function main(): Promise { const { models, issues } = await loadAllModels(); @@ -73,8 +66,6 @@ async function main(): Promise { const ids = models.map(modelId); const providers = [...new Set(models.map((m) => m.provider))].sort(); - const compactModels = models.map(compactLifecycle); - // 1. model-ids.ts — ModelId union + Provider union await fs.writeFile( path.join(OUT_DIR, "model-ids.ts"), @@ -111,21 +102,18 @@ async function main(): Promise { path.join(OUT_DIR, "data.ts"), HEADER + `import type { ModelId } from "./model-ids.js";\n\n` + - `const GENERATED_CATALOG = ${JSON.stringify(compactModels, null, 2)} as const;\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 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.map((model) => ({\n` + - ` status: "active",\n` + - ` ...model,\n` + - `}));\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 c3680682..4cf20afd 100644 --- a/packages/modelparams/src/generated/data.ts +++ b/packages/modelparams/src/generated/data.ts @@ -29412,17 +29412,14 @@ type GeneratedCatalogEntry = (typeof GENERATED_CATALOG)[number]; export type LifecycleStatus = "active" | "deprecated" | "retired"; type WithLifecycle = T extends unknown ? Omit & { - readonly status: LifecycleStatus; + readonly status?: LifecycleStatus; readonly replacement?: string; readonly shutdownOn?: string; } : never; export type CatalogEntry = WithLifecycle; -export const CATALOG: readonly CatalogEntry[] = GENERATED_CATALOG.map((model) => ({ - status: "active", - ...model, -})); +export const CATALOG: readonly CatalogEntry[] = GENERATED_CATALOG; function authSuffix(authType: CatalogEntry["authType"]): "" | "-subscription" { return authType === "api_key" ? "" : "-subscription"; diff --git a/packages/modelparams/test-d/types.test-d.ts b/packages/modelparams/test-d/types.test-d.ts index b026e22b..d309b504 100644 --- a/packages/modelparams/test-d/types.test-d.ts +++ b/packages/modelparams/test-d/types.test-d.ts @@ -35,7 +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); +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 b434eb88..d139160a 100644 --- a/packages/modelparams/tests/runtime.test.ts +++ b/packages/modelparams/tests/runtime.test.ts @@ -57,7 +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).toBe("active"); + 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 a2da828e..a68da783 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -106,7 +106,7 @@ export function modelIntro(model: Model): string { } export function modelLifecycleSummary(model: Model): string | null { - const status = model.status ?? "active"; + 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."); diff --git a/src/client/webmcp.ts b/src/client/webmcp.ts index 31b04bfc..5f0ca5f7 100644 --- a/src/client/webmcp.ts +++ b/src/client/webmcp.ts @@ -19,7 +19,7 @@ interface CatalogModel { provider: string; authType: AuthType; model: string; - status: LifecycleStatus; + status?: LifecycleStatus; replacement?: string; shutdownOn?: string; params: CatalogParam[]; diff --git a/src/data/load.ts b/src/data/load.ts index ddcd2278..fe0e1699 100644 --- a/src/data/load.ts +++ b/src/data/load.ts @@ -63,12 +63,7 @@ function validateOne( if (!parsed.success) { return { issue: { file, message: formatZodIssue(parsed.error) } }; } - const { params, ...metadata } = parsed.data; - const model: ModelType = { - ...metadata, - status: metadata.status ?? "active", - params, - }; + const model = parsed.data; const expectedId = expectedIdFromPath(file, modelsDir); const derivedId = modelId(model); diff --git a/src/schema/model.ts b/src/schema/model.ts index fae1fac1..265494b8 100644 --- a/src/schema/model.ts +++ b/src/schema/model.ts @@ -194,7 +194,7 @@ export const Model = z * geography (`us`, `eu`, `global`, …) before sending. */ wireId: z.string().min(1).regex(/^\S+$/, "wireId must not contain whitespace").optional(), - /** Omitted source values are emitted as `active` by the catalog loader. */ + /** 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 diff --git a/tests/load.test.ts b/tests/load.test.ts index 94902a6f..5ed9fe7d 100644 --- a/tests/load.test.ts +++ b/tests/load.test.ts @@ -61,7 +61,7 @@ describe("loadAllModels", () => { "anthropic/claude-opus-4-7", "anthropic/claude-opus-4-7-subscription", ]); - expect(result.models.every((model) => model.status === "active")).toBe(true); + expect(result.models.every((model) => model.status === undefined)).toBe(true); }); it("preserves explicit lifecycle metadata", async () => { diff --git a/tests/webmcp.test.ts b/tests/webmcp.test.ts index 1f815bc4..82ca9647 100644 --- a/tests/webmcp.test.ts +++ b/tests/webmcp.test.ts @@ -8,7 +8,6 @@ function model(provider: string, name: string, authType: AuthType, paths: string provider, model: name, authType, - status: "active" as const, params: paths.map((path) => ({ path, type: "number", @@ -98,7 +97,6 @@ describe("searchCatalog", () => { id: "anthropic/claude-opus-4-7-subscription", provider: "anthropic", authType: "subscription", - status: "active", parameterCount: 2, }); expect(sub?.parameters).toEqual(["temperature", "thinking.type"]); From f8c0c610f103767adb453644933d3a8e5e71f0b0 Mon Sep 17 00:00:00 2001 From: Guillaume Gay Date: Thu, 20 Aug 2026 20:26:55 +0200 Subject: [PATCH 3/3] feat: expose lifecycle metadata through MCP --- README.md | 2 +- packages/modelparams-mcp/README.md | 2 +- packages/modelparams-mcp/src/server.ts | 5 +-- packages/modelparams-mcp/src/tools.ts | 3 ++ packages/modelparams-mcp/tests/server.test.ts | 35 +++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c46d4235..a818c9f3 100644 --- a/README.md +++ b/README.md @@ -125,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/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", () => {