diff --git a/.changeset/clean-tools-schema.md b/.changeset/clean-tools-schema.md new file mode 100644 index 0000000000..30c7ecce6e --- /dev/null +++ b/.changeset/clean-tools-schema.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Ignore OpenAPI `default` and `example` annotations whose values conflict with the declared schema type, so models are not prompted to submit invalid tool input. diff --git a/packages/eve/src/runtime/connections/openapi-client.test.ts b/packages/eve/src/runtime/connections/openapi-client.test.ts index 9e9150a0a3..0499cf10a8 100644 --- a/packages/eve/src/runtime/connections/openapi-client.test.ts +++ b/packages/eve/src/runtime/connections/openapi-client.test.ts @@ -703,6 +703,66 @@ describe("OpenApiConnectionClient", () => { expect(props.mix?.type).toEqual(["string", "null"]); }); + it("drops default and example values that contradict their schema type", async () => { + const spec: Record = { + openapi: "3.0.3", + info: { title: "T", version: "1" }, + paths: { + "/things": { + post: { + operationId: "createThing", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + hasAttachments: { + type: "boolean", + default: "false", + example: "false", + }, + sendCopy: { type: "boolean", default: true, example: false }, + note: { type: "string", nullable: true, default: null, example: null }, + metadata: { + type: "object", + default: { type: "invoice", nullable: true }, + example: { type: "credit-note", nullable: false }, + }, + untyped: { default: "false", example: "false" }, + }, + }, + }, + }, + }, + responses: { "200": { description: "ok" } }, + }, + }, + }, + }; + const client = new OpenApiConnectionClient(makeConnection({ spec })); + const createThing = (await client.getToolMetadata()).find((m) => m.name === "createThing"); + const properties = ( + createThing!.inputSchema as { + properties: { body: { properties: Record> } }; + } + ).properties.body.properties; + + expect(properties.hasAttachments).toEqual({ type: "boolean" }); + expect(properties.sendCopy).toEqual({ type: "boolean", default: true, example: false }); + expect(properties.note).toEqual({ + type: ["string", "null"], + default: null, + example: null, + }); + expect(properties.metadata).toEqual({ + type: "object", + default: { type: "invoice", nullable: true }, + example: { type: "credit-note", nullable: false }, + }); + expect(properties.untyped).toEqual({ default: "false", example: "false" }); + }); + it("keeps operations whose input schemas cannot be locally validated", async () => { const spec: Record = { openapi: "3.0.3", diff --git a/packages/eve/src/runtime/connections/openapi-schema.ts b/packages/eve/src/runtime/connections/openapi-schema.ts index 97395cd97f..60e1d2fb4a 100644 --- a/packages/eve/src/runtime/connections/openapi-schema.ts +++ b/packages/eve/src/runtime/connections/openapi-schema.ts @@ -104,10 +104,14 @@ export function derefSchema( } const result: Record = {}; for (const [key, value] of Object.entries(node)) { - result[key] = derefSchema(document, value, depth + 1, seen); + result[key] = + key === "default" || key === "example" + ? value + : derefSchema(document, value, depth + 1, seen); } normalizeSchemaType(result); normalizeNullable(result); + normalizeSchemaAnnotations(result); return result; } @@ -177,6 +181,44 @@ function normalizeNullable(schema: Record): void { } } +/** Drops model-facing annotations that contradict the node's effective type. */ +function normalizeSchemaAnnotations(schema: Record): void { + const type = schema.type; + if (typeof type !== "string" && !isArray(type)) { + return; + } + + for (const keyword of ["default", "example"] as const) { + if (keyword in schema && !matchesSchemaType(schema[keyword], type)) { + delete schema[keyword]; + } + } +} + +function matchesSchemaType(value: unknown, type: string | readonly unknown[]): boolean { + const types = typeof type === "string" ? [type] : type; + return types.some((entry) => { + switch (entry) { + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "object": + return isObject(value); + case "array": + return isArray(value); + case "null": + return value === null; + default: + return false; + } + }); +} + /** Resolves a local JSON pointer ref (`#/components/...`) against the document. */ function resolveRef(document: Record, ref: string): unknown { if (!ref.startsWith("#/")) {