tools/openapi-postprocess.mjs
Rama main · réplica de lectura del repositorio original.
import fs from "node:fs";
const file = process.argv[2] || "docs/openapi.json";
const spec = JSON.parse(fs.readFileSync(file, "utf8"));
// parseServers emits a single templated OpenAPI 3 server entry whose URL is a
// free-text variable. Mintlify renders this variable as an editable input at
// the top of every API Reference page, so visitors can point the docs at
// their own GoModel deployment without leaving the page. The first URL in
// DOCS_API_SERVERS is used as the default; any additional URLs become
// description hints so common deployments stay discoverable.
function parseServers(value) {
const urls = (value || "")
.split(",")
.map((url) => url.trim())
.filter(Boolean);
if (urls.length === 0) {
throw new Error("DOCS_API_SERVERS must include at least one URL");
}
const [defaultURL, ...alternatives] = urls;
const description = alternatives.length === 0
? "Your GoModel deployment URL"
: `Your GoModel deployment URL (e.g. ${alternatives.join(", ")})`;
return [
{
url: "{base_url}",
description: "Edit the base URL to point at your GoModel deployment.",
variables: {
base_url: {
default: defaultURL,
description,
},
},
},
];
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function schema(name) {
const result = spec.components?.schemas?.[name];
if (!result) {
throw new Error(`missing OpenAPI schema: ${name}`);
}
return result;
}
function applyResponseInputOneOf(name) {
const properties = schema(name).properties;
if (!properties?.input) {
throw new Error(`missing input property on schema: ${name}`);
}
const input = {};
if (properties.input.description) {
input.description = properties.input.description;
}
input.oneOf = clone([
{ type: "string" },
{
type: "array",
items: { $ref: "#/components/schemas/core.ResponsesInputElement" },
},
]);
properties.input = input;
}
function applyResponseConversationOneOf(name) {
const properties = schema(name).properties;
if (!properties?.conversation) {
throw new Error(`missing conversation property on schema: ${name}`);
}
const conversation = {};
if (properties.conversation.description) {
conversation.description = properties.conversation.description;
}
conversation.oneOf = clone([
{ type: "string" },
{ $ref: "#/components/schemas/core.ResponsesConversationRef" },
]);
properties.conversation = conversation;
}
function ensureResponsesInputElementSchema() {
const schemas = spec.components?.schemas;
if (!schemas) {
throw new Error("missing OpenAPI components.schemas");
}
if (schemas["core.ResponsesInputElement"]) {
return;
}
schemas["core.ResponsesInputElement"] = {
type: "object",
properties: {
arguments: { type: "string" },
call_id: {
description: 'Function call fields (type="function_call")',
type: "string",
},
extra_content: freeFormObjectSchema(),
content: {
description: "Can be string or []ContentPart",
oneOf: [
{ type: "string" },
{
type: "array",
items: { $ref: "#/components/schemas/core.ContentPart" },
},
],
},
name: { type: "string" },
output: {
description: 'Function call output fields (type="function_call_output") - CallID shared above',
type: "string",
},
role: {
description: 'Message fields (type="" or "message")',
type: "string",
},
status: { type: "string" },
type: {
description: '"message", "function_call", "function_call_output"',
type: "string",
},
},
};
}
function anthropicContentSchema() {
return {
oneOf: [
{ type: "string" },
{
type: "array",
items: { $ref: "#/components/schemas/anthropicapi.ContentBlock" },
},
],
};
}
function freeFormObjectSchema() {
return {
type: "object",
additionalProperties: true,
};
}
function stringOrFreeFormObjectSchema() {
return {
oneOf: [
{ type: "string" },
freeFormObjectSchema(),
],
};
}
function ensureAnthropicContentBlockSchema() {
const schemas = spec.components?.schemas;
if (!schemas) {
throw new Error("missing OpenAPI components.schemas");
}
schemas["anthropicapi.ContentBlock"] = {
type: "object",
properties: {
content: anthropicContentSchema(),
extra_content: freeFormObjectSchema(),
id: { type: "string" },
input: freeFormObjectSchema(),
is_error: { type: "boolean" },
name: { type: "string" },
source: stringOrFreeFormObjectSchema(),
text: { type: "string" },
thinking: { type: "string" },
// thinking blocks are replayed with the signature Anthropic issued;
// redacted ones carry their opaque payload instead.
signature: { type: "string" },
data: { type: "string" },
tool_use_id: { type: "string" },
type: { type: "string" },
},
};
}
function applyResponsesReplayStateSchema() {
schema("core.ResponsesOutputItem").properties.extra_content = freeFormObjectSchema();
}
function applyAnthropicMessageSchemas() {
ensureAnthropicContentBlockSchema();
schema("anthropicapi.Message").properties.content = anthropicContentSchema();
schema("anthropicapi.MessagesRequest").properties.system = anthropicContentSchema();
schema("anthropicapi.ResponseContentBlock").properties.input = freeFormObjectSchema();
schema("anthropicapi.Tool").properties.input_schema = freeFormObjectSchema();
}
function constStringSchema(value) {
return {
type: "string",
enum: [value],
};
}
function ensureAnthropicSSEEventSchemas() {
const schemas = spec.components?.schemas;
if (!schemas) {
throw new Error("missing OpenAPI components.schemas");
}
schemas["anthropicapi.SSEMessageStartEvent"] = {
type: "object",
required: ["message", "type"],
properties: {
type: constStringSchema("message_start"),
message: { $ref: "#/components/schemas/anthropicapi.MessagesResponse" },
},
};
schemas["anthropicapi.SSEContentBlockStartEvent"] = {
type: "object",
required: ["content_block", "index", "type"],
properties: {
type: constStringSchema("content_block_start"),
index: { type: "integer" },
content_block: { $ref: "#/components/schemas/anthropicapi.ResponseContentBlock" },
},
};
schemas["anthropicapi.SSEContentBlockDeltaEvent"] = {
type: "object",
required: ["delta", "index", "type"],
properties: {
type: constStringSchema("content_block_delta"),
index: { type: "integer" },
delta: freeFormObjectSchema(),
},
};
schemas["anthropicapi.SSEContentBlockStopEvent"] = {
type: "object",
required: ["index", "type"],
properties: {
type: constStringSchema("content_block_stop"),
index: { type: "integer" },
},
};
schemas["anthropicapi.SSEMessageDeltaEvent"] = {
type: "object",
required: ["delta", "type"],
properties: {
type: constStringSchema("message_delta"),
delta: freeFormObjectSchema(),
usage: { $ref: "#/components/schemas/anthropicapi.Usage" },
},
};
schemas["anthropicapi.SSEMessageStopEvent"] = {
type: "object",
required: ["type"],
properties: {
type: constStringSchema("message_stop"),
},
};
schemas["anthropicapi.SSEPingEvent"] = {
type: "object",
required: ["type"],
properties: {
type: constStringSchema("ping"),
},
};
schemas["anthropicapi.SSEErrorEvent"] = {
type: "object",
required: ["error", "type"],
properties: {
type: constStringSchema("error"),
error: { $ref: "#/components/schemas/anthropicapi.ErrorObject" },
},
};
schemas["anthropicapi.SSEUnknownEvent"] = {
description: "Fallback for future Anthropic streaming event payloads.",
type: "object",
required: ["type"],
properties: {
type: { type: "string" },
},
additionalProperties: true,
};
schemas["anthropicapi.SSEEventFrame"] = {
description: "One server-sent event frame emitted by streaming /v1/messages. On the wire each frame is sent as event: <name> and data: <JSON payload>. Unknown event payloads are accepted for forward compatibility.",
anyOf: [
{ $ref: "#/components/schemas/anthropicapi.SSEMessageStartEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEContentBlockStartEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEContentBlockDeltaEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEContentBlockStopEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEMessageDeltaEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEMessageStopEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEPingEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEErrorEvent" },
{ $ref: "#/components/schemas/anthropicapi.SSEUnknownEvent" },
],
};
}
function applyAnthropicMessagesStreamSchema() {
ensureAnthropicSSEEventSchemas();
const streamResponse = spec.paths?.["/v1/messages"]?.post?.responses?.["200"]?.content?.["text/event-stream"];
if (!streamResponse) {
throw new Error("missing OpenAPI text/event-stream response: POST /v1/messages 200");
}
streamResponse.schema = {
$ref: "#/components/schemas/anthropicapi.SSEEventFrame",
};
}
// applyRetrieveModelDialects documents the second wire dialect of
// GET /v1/models/{model}: a request carrying anthropic-version is answered with
// Anthropic's model envelope, and its failures with Anthropic's error envelope.
// swag models one schema per status code, so the alternatives are spelled here.
function applyRetrieveModelDialects() {
const schemas = spec.components?.schemas;
if (!schemas) {
throw new Error("missing OpenAPI components.schemas");
}
schemas["anthropicapi.ModelInfo"] = {
description: "One model in the Anthropic dialect, returned when the request carries anthropic-version.",
type: "object",
required: ["created_at", "display_name", "id", "type"],
properties: {
type: constStringSchema("model"),
id: { type: "string" },
display_name: { type: "string" },
created_at: { type: "string", format: "date-time" },
},
};
// A 401 is raised by the auth middleware before the handler runs, so it
// stays in the OpenAI envelope whatever the header says.
const dialects = {
200: ["core.Model", "anthropicapi.ModelInfo"],
404: ["core.OpenAIErrorEnvelope", "anthropicapi.ErrorResponse"],
502: ["core.OpenAIErrorEnvelope", "anthropicapi.ErrorResponse"],
};
const responses = spec.paths?.["/v1/models/{model}"]?.get?.responses;
if (!responses) {
throw new Error("missing OpenAPI operation: GET /v1/models/{model}");
}
for (const [status, names] of Object.entries(dialects)) {
const body = responses[status]?.content?.["application/json"];
if (!body) {
throw new Error(`missing JSON response ${status} on GET /v1/models/{model}`);
}
body.schema = {
description: "OpenAI envelope by default; the Anthropic envelope when the request carries anthropic-version.",
oneOf: names.map((name) => ({ $ref: `#/components/schemas/${name}` })),
};
}
}
function applyAudioTranscriptionTextSchema() {
// swag emits one schema across every produced content type, so the text/plain
// transcription body is generated as a JSON object. For text/srt/vtt the
// endpoint returns a plain string, so model it as such.
const textResponse = spec.paths?.["/v1/audio/transcriptions"]?.post?.responses?.["200"]?.content?.["text/plain"];
if (!textResponse) {
throw new Error("missing OpenAPI text/plain response: POST /v1/audio/transcriptions 200");
}
textResponse.schema = { type: "string" };
}
function applyUsageInvoiceCSVSchema() {
// swag emits one schema across every produced content type, so the CSV
// invoice is generated as the JSON Invoice object. The CSV body is text rows.
// Errors are always JSON, though swag lists every produced type on them.
const responses = spec.paths?.["/admin/usage/invoice"]?.get?.responses ?? {};
const csvResponse = responses["200"]?.content?.["text/csv"];
if (!csvResponse) {
throw new Error("missing OpenAPI text/csv response: GET /admin/usage/invoice 200");
}
csvResponse.schema = { type: "string" };
for (const [status, response] of Object.entries(responses)) {
if (status !== "200") {
delete response.content?.["text/csv"];
}
}
}
function applyImageEditMultiImageSchema() {
// swag models every formData file as a single binary. The repeatable
// image[] field carries several source images, and a request may use it
// instead of image, so make image[] an array, drop image from required,
// and express the server's actual rule — at least one source image field —
// as an anyOf constraint.
const body = spec.paths?.["/v1/images/edits"]?.post?.requestBody
?.content?.["multipart/form-data"]?.schema;
if (!body?.properties?.["image[]"] || !body?.properties?.image) {
throw new Error("missing OpenAPI multipart schema: POST /v1/images/edits");
}
body.properties["image[]"] = {
description: body.properties["image[]"].description,
type: "array",
maxItems: 16,
items: { type: "string", format: "binary" },
};
body.required = (body.required || []).filter((name) => name !== "image");
body.anyOf = [{ required: ["image"] }, { required: ["image[]"] }];
}
function ensureBearerAuthSecurityScheme() {
const securitySchemes = spec.components?.securitySchemes;
if (!securitySchemes?.BearerAuth) {
throw new Error("missing OpenAPI security scheme: BearerAuth");
}
securitySchemes.BearerAuth = {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
};
}
function ensureRequiredProperty(schemaName, propertyName) {
const target = schema(schemaName);
if (!target.properties?.[propertyName]) {
throw new Error(`missing ${propertyName} property on schema: ${schemaName}`);
}
const required = new Set(target.required || []);
required.add(propertyName);
target.required = Array.from(required).sort();
}
function applyArrayMaxItems(operationPath, method, statusCode, maxItems) {
const op = spec.paths?.[operationPath]?.[method];
if (!op) {
throw new Error(`missing OpenAPI operation: ${method.toUpperCase()} ${operationPath}`);
}
const response = op.responses?.[statusCode];
if (!response) {
throw new Error(`missing response ${statusCode} on ${method.toUpperCase()} ${operationPath}`);
}
const schemaRef = response.content?.["application/json"]?.schema || response.schema;
if (!schemaRef || schemaRef.type !== "array") {
throw new Error(`expected array schema on ${method.toUpperCase()} ${operationPath} ${statusCode}`);
}
schemaRef.maxItems = maxItems;
if (!schemaRef.description) {
schemaRef.description = `Bounded by maxItems=${maxItems}.`;
}
}
function applyStringEnum(schemaName, values, varnames) {
const target = schema(schemaName);
target.type = "string";
target.enum = values;
if (varnames) {
target["x-enum-varnames"] = varnames;
}
}
function applyStringArrayPropertyBounds(schemaName, propertyName, maxItems, itemMaxLength) {
const target = schema(schemaName);
const property = target.properties?.[propertyName];
if (!property || property.type !== "array") {
throw new Error(`expected array property ${propertyName} on schema: ${schemaName}`);
}
property.maxItems = maxItems;
property.items = property.items || {};
property.items.maxLength = itemMaxLength;
}
function applySlowdownSchemaConstraints() {
const oneOf = [
{ type: "number", enum: [0] },
{ type: "number", minimum: 0.1, maximum: 10 },
];
for (const name of [
"admin.upsertVirtualModelRequest",
"virtualmodels.View",
"virtualmodels.VirtualModel",
]) {
const properties = schema(name).properties;
if (!properties?.slowdown) {
throw new Error(`missing slowdown property on schema: ${name}`);
}
properties.slowdown = {
description: properties.slowdown.description,
oneOf: clone(oneOf),
};
}
}
// applyPathSidebarTitles sets the API Reference sidebar entry of every
// operation to its path so endpoints are listed by URL rather than by
// natural-language summary. Mintlify renders the HTTP method as a colored
// pill from the spec, so the title itself omits the method to avoid
// duplicating it.
function applyPathSidebarTitles() {
const httpMethods = new Set([
"get",
"post",
"put",
"patch",
"delete",
"head",
"options",
"trace",
]);
for (const [path, pathItem] of Object.entries(spec.paths || {})) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const [method, operation] of Object.entries(pathItem)) {
if (!httpMethods.has(method)) continue;
if (!operation || typeof operation !== "object") continue;
operation["x-mint"] ??= {};
operation["x-mint"].metadata ??= {};
operation["x-mint"].metadata.sidebarTitle = path;
}
}
}
// applyMintlifyOperationMetadata gives every generated API page a concise,
// unique search title and description without replacing the full operation
// description rendered in the reference body. Several routes intentionally
// share a summary (for example GET, POST, and DELETE on an MCP endpoint), so
// the HTTP method is included in the page title only when disambiguation is
// needed.
function applyMintlifyOperationMetadata() {
const httpMethods = new Set([
"get",
"post",
"put",
"patch",
"delete",
"head",
"options",
"trace",
]);
const operations = [];
const summaryCounts = new Map();
for (const [path, pathItem] of Object.entries(spec.paths || {})) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const [method, operation] of Object.entries(pathItem)) {
if (!httpMethods.has(method)) continue;
if (!operation || typeof operation !== "object") continue;
const summary = String(operation.summary || `${method.toUpperCase()} ${path}`).trim();
operations.push({ path, method, operation, summary });
summaryCounts.set(summary, (summaryCounts.get(summary) || 0) + 1);
}
}
for (const { path, method, operation, summary } of operations) {
const endpoint = `${method.toUpperCase()} ${path}`;
const description = `GoModel API reference for ${endpoint}: ${summary.replace(/\.$/, "")}.`;
operation["x-mint"] ??= {};
operation["x-mint"].metadata ??= {};
operation["x-mint"].metadata.title = summaryCounts.get(summary) > 1
? `${method.toUpperCase()} ${summary}`
: summary;
operation["x-mint"].metadata.description = description.length <= 160
? description
: `${description.slice(0, 157).replace(/\s+\S*$/, "").replace(/[,:;\s]+$/, "")}...`;
}
}
function applyPricingSchemaConstraints() {
schema("pricingoverrides.Pricing").minProperties = 1;
for (const name of ["core.ModelPricingTier", "pricingoverrides.PricingTier"]) {
const upToTokens = schema(name).properties?.up_to_tokens;
if (!upToTokens) {
throw new Error(`missing up_to_tokens property on schema: ${name}`);
}
upToTokens.type = "integer";
upToTokens.minimum = 1;
}
}
function applyBudgetKeySchemaConstraints() {
const target = schema("admin.budgetKeyRequest");
if (!target.properties?.period || !target.properties?.period_seconds) {
throw new Error("missing budget key period properties");
}
target.oneOf = [{ required: ["period"] }, { required: ["period_seconds"] }];
}
spec.servers = parseServers(process.env.DOCS_API_SERVERS);
ensureResponsesInputElementSchema();
applyAnthropicMessageSchemas();
applyResponsesReplayStateSchema();
applyAnthropicMessagesStreamSchema();
applyAudioTranscriptionTextSchema();
applyUsageInvoiceCSVSchema();
applyRetrieveModelDialects();
applyImageEditMultiImageSchema();
ensureBearerAuthSecurityScheme();
ensureRequiredProperty("core.AudioSpeechRequest", "model");
ensureRequiredProperty("core.AudioSpeechRequest", "input");
ensureRequiredProperty("core.AudioSpeechRequest", "voice");
ensureRequiredProperty("admin.recalculatePricingRequest", "confirmation");
ensureRequiredProperty("admin.upsertBudgetRequest", "amount");
ensureRequiredProperty("admin.upsertBudgetRequest", "budget_key");
ensureRequiredProperty("admin.deleteBudgetRequest", "budget_key");
ensureRequiredProperty("admin.upsertVirtualModelRequest", "source");
ensureRequiredProperty("admin.deleteVirtualModelRequest", "source");
ensureRequiredProperty("admin.upsertModelPricingOverrideRequest", "selector");
ensureRequiredProperty("admin.upsertModelPricingOverrideRequest", "pricing");
ensureRequiredProperty("admin.deleteModelPricingOverrideRequest", "selector");
ensureRequiredProperty("core.ResponsesConversationRef", "id");
applyBudgetKeySchemaConstraints();
applyStringArrayPropertyBounds("admin.upsertVirtualModelRequest", "user_paths", 100, 1024);
applySlowdownSchemaConstraints();
applyPricingSchemaConstraints();
applyPathSidebarTitles();
applyMintlifyOperationMetadata();
// Bound the registry-backed admin model listing so OpenAPI consumers (and
// security scanners like CKV_OPENAPI_21) see an explicit upper limit. The
// runtime registry is bounded by configured providers and the backing
// model list; 10000 leaves substantial headroom for that worst case.
applyArrayMaxItems("/admin/models", "get", "200", 10000);
applyArrayMaxItems("/admin/virtual-models", "get", "200", 10000);
applyArrayMaxItems("/admin/model-pricing-overrides", "get", "200", 10000);
applyStringEnum(
"pricingoverrides.ScopeKind",
["global", "model", "provider", "provider_model"],
["PricingScopeGlobal", "PricingScopeModel", "PricingScopeProvider", "PricingScopeProviderModel"],
);
for (const name of [
"core.ResponsesRequest",
"core.ResponseInputTokensRequest",
"core.ResponseCompactRequest",
]) {
applyResponseInputOneOf(name);
applyResponseConversationOneOf(name);
}
const inputItemList = schema("core.ResponseInputItemListResponse");
if (!inputItemList.properties?.data) {
throw new Error("missing data property on schema: core.ResponseInputItemListResponse");
}
inputItemList.properties.data = {
type: "array",
items: { $ref: "#/components/schemas/core.ResponsesInputElement" },
};
fs.writeFileSync(file, `${JSON.stringify(spec, null, 2)}\n`);