Ctrl K
ComunidadGuías…

tools/openapi-postprocess.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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`);