A test without a model
Hand Signal a client of your own and it answers in place of the provider. Your test says what the model writes, and Signal does the rest as it always does.
pnpm add @niscorp/signal zodWhat the model will write
Two replies, in the order they are asked for. The first has the seats as a word.
import { z } from 'zod';
import { createSignal } from '@niscorp/signal';
const replies = ['{ "show": "The Tempest", "seats": "two" }', '{ "show": "The Tempest", "seats": 2 }'];A client that answers with them
Signal calls a provider through chat.completions.create, and reads the answer as a stream of chunks. This one hands back the next reply as one chunk. (step reads one whole completion instead of chunks.)
Signal is given it as client. No package is loaded and no request leaves the machine. A key is still asked for: any string will do.
const client = {
chat: {
completions: {
create: async () =>
(async function* () {
yield { choices: [{ index: 0, delta: { content: replies.shift() ?? '' }, finish_reason: null }] };
yield { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
})(),
},
},
};
const signal = createSignal('groq', { client, apiKey: 'test' });The call under test
From here it is the code you would ship. Signal checks the first reply against the schema, retries with what was wrong, and takes the second.
const Booking = z.object({ show: z.string(), seats: z.number() });
const { response, meta } = await signal
.schema(Booking)
.onRetry((error) => console.log('failed:', error.message))
.complete('Two for The Tempest on Saturday, please.');
console.log(response, '· retries:', meta.retries);Run it.
No model was asked. The first line is what Signal said of the first reply, and the second is the booking it took from the next one.
npx tsx test.mtsfailed: Response failed schema validation: seats: Invalid input: expected number, received string
{ show: 'The Tempest', seats: 2 } · retries: 1