diff --git a/src/components/models/SchemaDisplay.astro b/src/components/models/SchemaDisplay.astro index 6dcb17aa8c6..db29b14d6c1 100644 --- a/src/components/models/SchemaDisplay.astro +++ b/src/components/models/SchemaDisplay.astro @@ -22,6 +22,7 @@ import { resolveInlineRef } from "@stoplight/json"; import SchemaTreeView from "./SchemaTree.tsx"; import SchemaVariantSelector from "./SchemaVariantSelector.tsx"; import type { SchemaRowData } from "./types"; +import { getTopLevelVariants } from "~/util/model-schema"; interface Props { schema: Record; @@ -356,32 +357,6 @@ function collectRows( return rows; } -// Check if schema has top-level oneOf/anyOf with titled variants -// If so, we'll render them as tabs instead of nested tree -type SchemaVariant = { - title: string; - schema: Record; -}; - -function getTopLevelVariants( - schemaObj: Record, -): SchemaVariant[] | null { - const variants = (schemaObj.oneOf || schemaObj.anyOf) as - | Record[] - | undefined; - if (!variants || variants.length < 2) return null; - - // Check if all variants have titles - const titled = variants.map((v, i) => ({ - title: (v.title as string) || `Option ${i + 1}`, - schema: v, - })); - - // Only use tabs if at least one variant has a real title - const hasRealTitle = variants.some((v) => v.title); - return hasRealTitle ? titled : null; -} - const topLevelVariants = getTopLevelVariants(schema); // Process variants or full schema diff --git a/src/util/model-schema.node.test.ts b/src/util/model-schema.node.test.ts new file mode 100644 index 00000000000..868be1127d8 --- /dev/null +++ b/src/util/model-schema.node.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "vitest"; +import { detectApiModes, getTopLevelVariants } from "./model-schema"; + +/** + * Fixture mirroring the exact shape the AI Gateway catalog generator emits for + * multi-format ("variable schema") models: a root-level `oneOf` on both input + * and output, where each branch is a plain object schema carrying a + * human-readable `title` (the request format). See + * `packages/model-catalog/src/generate-schema.ts` in ai-gateway-infra and the + * regenerated `models/openai/gpt-5/schema.json`. + * + * The branches are trimmed for readability but structurally identical to the + * real output: `type: "object"` + `properties` + `required` + `title`, with no + * `contentType`, no `format: "binary"`, and no `requests` property. + */ +function requestFormatOneOf(extraInputBranch?: Record) { + const input = { + $schema: "https://json-schema.org/draft/2020-12/schema", + oneOf: [ + { + type: "object", + properties: { messages: { type: "array" }, model: { type: "string" } }, + required: ["messages"], + title: "Chat Completions", + }, + { + type: "object", + properties: { input: { type: "string" }, model: { type: "string" } }, + required: ["input"], + title: "Responses", + }, + ...(extraInputBranch ? [extraInputBranch] : []), + ], + }; + const output = { + $schema: "https://json-schema.org/draft/2020-12/schema", + oneOf: [ + { + type: "object", + properties: { choices: { type: "array" } }, + required: ["choices"], + title: "Chat Completions", + }, + { + type: "object", + properties: { output: { type: "array" } }, + required: ["output"], + title: "Responses", + }, + ], + }; + return { input, output }; +} + +describe("detectApiModes", () => { + // The keystone case: this is precisely the shape the catalog generator + // (ai-gateway-infra MR !462) produces. It MUST NOT be hijacked into + // Synchronous/Batch/Streaming API modes — those would flatten the oneOf to + // a single branch per "mode" and drop the request-format distinction. + // Returning `undefined` makes ModelDetailPage fall through to + // SchemaDisplay, which renders the titled branches as a variant selector. + test("returns undefined for a titled request-format oneOf (no API-mode hijack)", () => { + const schema = requestFormatOneOf(); + expect(detectApiModes(schema)).toBeUndefined(); + }); + + test("does not hijack even though output branches are objects with properties", () => { + // Guard against the subtle trap: `jsonOutputIndex` matches an object + // branch, but with no streaming/batch counterpart only one mode is + // built, so the `modes.length > 1` gate still yields undefined. + const { input, output } = requestFormatOneOf(); + expect(detectApiModes({ input, output })).toBeUndefined(); + }); + + test("returns undefined for a flat (non-oneOf) schema", () => { + const schema = { + input: { type: "object", properties: { prompt: { type: "string" } } }, + output: { type: "object", properties: { response: { type: "string" } } }, + }; + expect(detectApiModes(schema)).toBeUndefined(); + }); + + // Boundary documentation: these are the real Workers AI shapes + // `detectApiModes` is designed to split. They confirm the request-format + // case above is excluded specifically because it lacks these signals. + test("still splits a genuine sync/batch schema (requests branch)", () => { + const schema = { + input: { + oneOf: [ + { type: "object", properties: { prompt: { type: "string" } } }, + { + type: "object", + properties: { requests: { type: "array" } }, + }, + ], + }, + output: { + oneOf: [{ type: "object", properties: { response: {} } }], + }, + }; + const modes = detectApiModes(schema); + expect(modes?.map((m) => m.id)).toEqual(["sync", "batch"]); + }); + + test("still splits a genuine sync/streaming schema (string output branch)", () => { + const schema = { + input: { + oneOf: [ + { type: "object", properties: { messages: { type: "array" } } }, + { type: "object", properties: { prompt: { type: "string" } } }, + ], + }, + output: { + oneOf: [ + { type: "object", properties: { choices: {} } }, + { type: "string" }, + ], + }, + }; + const modes = detectApiModes(schema); + expect(modes?.map((m) => m.id)).toEqual(["sync", "streaming"]); + }); +}); + +describe("getTopLevelVariants", () => { + // Because detectApiModes returns undefined for the request-format oneOf, + // ModelDetailPage falls through to SchemaDisplay, which calls + // getTopLevelVariants to render the labelled variant selector. This proves + // the second half of the render decision: the titled branches surface as a + // selector with the generator's titles, for both input and output. + test("surfaces the titled request-format branches with their labels", () => { + const { input, output } = requestFormatOneOf(); + + const inputVariants = getTopLevelVariants(input); + expect(inputVariants?.map((v) => v.title)).toEqual([ + "Chat Completions", + "Responses", + ]); + + const outputVariants = getTopLevelVariants(output); + expect(outputVariants?.map((v) => v.title)).toEqual([ + "Chat Completions", + "Responses", + ]); + }); + + test("returns the full branch schema for each variant", () => { + const { input } = requestFormatOneOf(); + const variants = getTopLevelVariants(input); + // The selector renders each branch's own object schema (properties), not + // the wrapping oneOf. + expect(variants?.[0].schema).toMatchObject({ + type: "object", + title: "Chat Completions", + properties: { messages: { type: "array" } }, + }); + }); + + test("returns null for a flat (non-oneOf) schema", () => { + expect( + getTopLevelVariants({ + type: "object", + properties: { prompt: { type: "string" } }, + }), + ).toBeNull(); + }); + + test("returns null when oneOf branches have no title (avoids a bare tab strip)", () => { + expect( + getTopLevelVariants({ + oneOf: [ + { type: "object", properties: { a: {} } }, + { type: "object", properties: { b: {} } }, + ], + }), + ).toBeNull(); + }); + + test("returns null for a single-branch oneOf (nothing to choose)", () => { + expect( + getTopLevelVariants({ + oneOf: [{ type: "object", properties: {}, title: "Only" }], + }), + ).toBeNull(); + }); +}); diff --git a/src/util/model-schema.ts b/src/util/model-schema.ts index 05bb520c845..45113a37644 100644 --- a/src/util/model-schema.ts +++ b/src/util/model-schema.ts @@ -113,3 +113,39 @@ export function detectApiModes(schema: { // Only return modes if we found meaningful splits return modes.length > 1 ? modes : undefined; } + +/** One labelled branch of a top-level `oneOf`/`anyOf` schema. */ +export type SchemaVariant = { + title: string; + schema: Record; +}; + +/** + * Detect a root-level `oneOf`/`anyOf` whose branches are meant to be shown as + * a labelled variant selector (e.g. a model's accepted request formats — + * "Chat Completions" vs "Responses"). Returns one entry per branch when there + * are at least two and at least one carries a real `title`; otherwise null, so + * the caller renders the schema as a single tree. + * + * This is the presentational counterpart to `detectApiModes`: that splits a + * schema into Workers AI API modes (sync/batch/streaming); this surfaces + * request-format branches the generator labels with a `title`. + */ +export function getTopLevelVariants( + schemaObj: Record, +): SchemaVariant[] | null { + const variants = (schemaObj.oneOf || schemaObj.anyOf) as + | Record[] + | undefined; + if (!variants || variants.length < 2) return null; + + // Check if all variants have titles + const titled = variants.map((v, i) => ({ + title: (v.title as string) || `Option ${i + 1}`, + schema: v, + })); + + // Only use tabs if at least one variant has a real title + const hasRealTitle = variants.some((v) => v.title); + return hasRealTitle ? titled : null; +}