Files
oh-my-pi/packages/ai/test/schema-wire.test.ts
T
oldschoolaandcan1357 29d5392d3e feat(ai): add Fireworks Fire Pass support + AI provider review fixes
Fire Pass support:
- New provider with login command 'omp /login firepass'
- Hand-seeded kimi-k2.6-turbo model with Fire Pass router wire id
- pi-ai CLI --help lists firepass

AI provider fixes surfaced during the Fire Pass review:
- service_tier whitelist restored for openai/openai-codex only (was leaking to
  Fireworks, Firepass, OpenRouter, Azure OpenAI Responses)
- Anthropic tool schema normalizer collapses {} -> true for additionalProperties
- anthropic.prepareParams fires onPayload after drop helpers so callers see the
  real wire body
- isServiceTier type guard narrowed to ResolvedServiceTier
- transformMessages stops dropping orphan tool_result when all pending tool
  calls have already resolved
- zodToWireSchema preserves null for non-scalar nullable() inner schemas
- isEmptyObject / isJsonObjectEmpty use Object.keys().length === 0 instead of
  prototype-walking for...in
- Telemetry records resolved service_tier (priority) instead of scoped
  placeholder (openai-only/claude-only)
- Robomp dirty-state reminder no longer asserts a formatter-failure premise
2026-05-26 20:36:15 +02:00

216 lines
9.1 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { normalizeAnthropicToolSchema } from "@oh-my-pi/pi-ai/providers/anthropic";
import type { Tool } from "@oh-my-pi/pi-ai/types";
import {
decontaminateZodInstance,
isZodSchema,
normalizeEmptySchemas,
normalizeSchemaForCCA,
normalizeSchemaForGoogle,
toolWireSchema,
zodToWireSchema,
} from "@oh-my-pi/pi-ai/utils/schema";
import { z } from "zod/v4";
describe("isZodSchema", () => {
it("accepts a live Zod instance", () => {
expect(isZodSchema(z.object({ a: z.string() }))).toBe(true);
expect(isZodSchema(z.string())).toBe(true);
expect(isZodSchema(z.enum({ a: "a", b: "b" }))).toBe(true);
});
// Regression: issue #1101. Before tightening, `isZodSchema` returned true
// for `JSON.parse(JSON.stringify(zodSchema))` because the `_zod` property
// (and its object value) survived the round-trip — even though every Zod
// method had been stripped along with the prototype. The relaxed predicate
// fed garbage into `z.toJSONSchema` and (when callers bypassed conversion)
// shipped the raw Zod internals to Anthropic's strict validator.
it("rejects a JSON-roundtripped Zod schema (prototype lost)", () => {
const impostor = JSON.parse(JSON.stringify(z.object({ a: z.string() })));
expect(isZodSchema(impostor)).toBe(false);
});
it("rejects the raw gitnexus_impact.direction payload from issue #1101", () => {
const impostor = {
def: { type: "enum", entries: { upstream: "upstream", downstream: "downstream" } },
type: "enum",
enum: { upstream: "upstream", downstream: "downstream" },
options: ["upstream", "downstream"],
};
expect(isZodSchema(impostor)).toBe(false);
});
it("rejects plain JSON Schema objects", () => {
expect(isZodSchema({ type: "object", properties: {} })).toBe(false);
expect(isZodSchema({ type: "string" })).toBe(false);
});
it("rejects non-objects", () => {
expect(isZodSchema(null)).toBe(false);
expect(isZodSchema(undefined)).toBe(false);
expect(isZodSchema("string")).toBe(false);
expect(isZodSchema(42)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// zodToWireSchema — empty-schema normalization (issue #1179)
// ---------------------------------------------------------------------------
describe("zodToWireSchema — empty-schema normalization", () => {
it("converts z.unknown() additionalProperties from {} to true (z.record case)", () => {
// Grammar-constrained samplers treat {} as "emit empty object" rather than
// "any JSON value". Normalizing to `true` lets models emit strings.
const schema = z.object({ extra: z.record(z.string(), z.unknown()) });
const wire = zodToWireSchema(schema);
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra.additionalProperties).toBe(true);
});
it("converts z.unknown() items from {} to true (z.array case)", () => {
const schema = z.object({ items: z.array(z.unknown()) });
const wire = zodToWireSchema(schema);
const items = (wire.properties as Record<string, unknown>).items as Record<string, unknown>;
expect(items.items).toBe(true);
});
it("converts z.unknown() property schemas from {} to true", () => {
const schema = z.object({ meta: z.unknown() });
const wire = zodToWireSchema(schema);
const meta = (wire.properties as Record<string, unknown>).meta;
expect(meta).toBe(true);
});
it("does not touch non-empty schemas or boolean values", () => {
const schema = z.object({ name: z.string() });
const wire = zodToWireSchema(schema);
const name = (wire.properties as Record<string, unknown>).name as Record<string, unknown>;
expect(name.type).toBe("string");
expect(name.additionalProperties).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// normalizeEmptySchemas — provider-agnostic post-pipeline normalization
// ---------------------------------------------------------------------------
describe("normalizeEmptySchemas", () => {
it("normalizes {} in additionalProperties / items / property values / combiner branches", () => {
const schema: Record<string, unknown> = {
type: "object",
properties: { meta: {}, items: { type: "array", items: {} } },
additionalProperties: {},
anyOf: [{}, { type: "string" }],
};
normalizeEmptySchemas(schema);
expect(schema).toEqual({
type: "object",
properties: { meta: true, items: { type: "array", items: true } },
additionalProperties: true,
anyOf: [true, { type: "string" }],
});
});
it("leaves non-empty schemas and boolean values alone", () => {
const schema: Record<string, unknown> = {
type: "object",
additionalProperties: { type: "string" },
unevaluatedProperties: false,
};
normalizeEmptySchemas(schema);
expect(schema).toEqual({
type: "object",
additionalProperties: { type: "string" },
unevaluatedProperties: false,
});
});
});
// ---------------------------------------------------------------------------
// toolWireSchema — covers both Zod and TypeBox paths (issue #1179)
// ---------------------------------------------------------------------------
describe("toolWireSchema — empty-schema normalization across both paths", () => {
function zodTool(parameters: z.ZodType): Tool {
return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool;
}
function jsonTool(parameters: Record<string, unknown>): Tool {
return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool;
}
it("normalizes {} → true for Zod tools (z.record(z.string(), z.unknown()))", () => {
const wire = toolWireSchema(zodTool(z.object({ extra: z.record(z.string(), z.unknown()) })));
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra.additionalProperties).toBe(true);
});
it("normalizes {} → true for TypeBox / raw JSON Schema tools", () => {
const wire = toolWireSchema(
jsonTool({
type: "object",
properties: { extra: { type: "object", additionalProperties: {} } },
required: [],
}),
);
const extra = (wire.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra.additionalProperties).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Provider downstream behavior with normalized `additionalProperties: true`
// (issue #1179 — verify Google and Anthropic don't break)
// ---------------------------------------------------------------------------
describe("provider normalizers on normalized open-record schemas", () => {
const wire = zodToWireSchema(
z.object({
action: z.enum(["apply", "discard"]),
extra: z.record(z.string(), z.unknown()).optional(),
}),
);
it("Anthropic preserves additionalProperties: true so strict-mode opt-out still fires", () => {
// `normalizeAnthropicStrictSchemaNode` rejects nodes where additionalProperties !== false.
// With normalization, the value is `true` (was `{}`); still !== false, so strict opts out.
const out = normalizeAnthropicToolSchema(wire) as Record<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra.additionalProperties).toBe(true);
});
it("Google strips additionalProperties entirely (UNSUPPORTED_SCHEMA_FIELDS)", () => {
// Pre-existing behavior — Google never sees the open-record marker either way.
// `additionalProperties: true` is removed just like `additionalProperties: {}` was.
const out = normalizeSchemaForGoogle(wire) as Record<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra).not.toHaveProperty("additionalProperties");
});
it("CCA (Claude on Cloud Code Assist) strips additionalProperties entirely", () => {
const out = normalizeSchemaForCCA(wire) as Record<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra).not.toHaveProperty("additionalProperties");
});
});
// ---------------------------------------------------------------------------
// decontaminateZodInstance — nullable wrapping of non-scalar inner schemas
// ---------------------------------------------------------------------------
describe("decontaminateZodInstance — nullable union", () => {
it("z.union([z.string(), z.number()]).nullable() produces a null-tolerant schema", () => {
// Round-trip strips Zod methods; decontaminateZodInstance must then inject null.
const roundTripped = JSON.parse(JSON.stringify(z.union([z.string(), z.number()]).nullable()));
const out = decontaminateZodInstance(roundTripped) as Record<string, unknown>;
// The union inner schema surfaces as an anyOf shape (no scalar `type`), so
// nullable wrapping must produce { anyOf: [..., { type: "null" }] }.
const toleratesNull =
(Array.isArray(out.type) && (out.type as string[]).includes("null")) ||
(Array.isArray(out.anyOf) &&
(out.anyOf as unknown[]).some(
b => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "null",
));
expect(toleratesNull).toBe(true);
});
});