Skip to content
Open
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
27 changes: 1 addition & 26 deletions src/components/models/SchemaDisplay.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>;
};

function getTopLevelVariants(
schemaObj: Record<string, unknown>,
): SchemaVariant[] | null {
const variants = (schemaObj.oneOf || schemaObj.anyOf) as
| Record<string, unknown>[]
| 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
Expand Down
186 changes: 186 additions & 0 deletions src/util/model-schema.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
36 changes: 36 additions & 0 deletions src/util/model-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
};

/**
* 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<string, unknown>,
): SchemaVariant[] | null {
const variants = (schemaObj.oneOf || schemaObj.anyOf) as
| Record<string, unknown>[]
| 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;
}