Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/clean-tools-schema.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions packages/eve/src/runtime/connections/openapi-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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<string, Record<string, unknown>> } };
}
).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<string, unknown> = {
openapi: "3.0.3",
Expand Down
44 changes: 43 additions & 1 deletion packages/eve/src/runtime/connections/openapi-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,14 @@ export function derefSchema(
}
const result: Record<string, unknown> = {};
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;
}

Expand Down Expand Up @@ -177,6 +181,44 @@ function normalizeNullable(schema: Record<string, unknown>): void {
}
}

/** Drops model-facing annotations that contradict the node's effective type. */
function normalizeSchemaAnnotations(schema: Record<string, unknown>): 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<string, unknown>, ref: string): unknown {
if (!ref.startsWith("#/")) {
Expand Down
Loading