Documentation
Everything the
library does.
Install it, declare a signature, pick a module, choose a provider. The rest of this page is the detail behind those four steps.
01
Installation
Install core plus the provider you intend to use. Core carries no vendor SDK.
npm install @ts-dspy/core @ts-dspy/anthropic
npm install @ts-dspy/core @ts-dspy/openai
npm install @ts-dspy/core @ts-dspy/gemini
Node.js 22 or newer. Packages ship ESM and CommonJS builds
with type declarations for both, so import and
require both resolve correctly.
Signatures use decorators, so your tsconfig.json needs
experimentalDecorators. Everything else works without it —
string signatures are decorator-free.
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
useDefineForClassFields must be false. With an
ES2022 target it defaults to true, which makes
class fields overwrite what the decorators recorded and leaves your
signature with no fields at all.
02
Quick start
Declare what you want back, then ask for it.
import { Signature, InputField, OutputField, Predict, configure } from '@ts-dspy/core'
import { AnthropicLM } from '@ts-dspy/anthropic'
configure({ lm: new AnthropicLM({ apiKey: process.env.ANTHROPIC_API_KEY }) })
class TriageTicket extends Signature {
static description = 'Classify a support ticket and rate its urgency.'
@InputField({ description: 'the ticket body' })
ticket!: string
@OutputField({ description: 'billing | bug | feature | other' })
category!: string
@OutputField({ description: 'urgency from 0 to 1', type: 'number' })
urgency!: number
}
const triage = new Predict(TriageTicket)
const { category, urgency } = await triage.forward({ ticket })
// urgency is a number here, or forward() threw.
if (urgency > 0.8) escalate(category)
03
Signatures
A signature declares the inputs a task takes and the outputs it must return. There are two forms.
Class form
Decorated fields, with a description per field and an optional type. The description is sent to the model; the type is what the reply is checked against.
class Summarize extends Signature {
static description = 'Summarize an article for a busy reader.'
@InputField({ description: 'full article text' })
article!: string
@OutputField({ description: 'three sentences at most' })
summary!: string
@OutputField({ description: 'key topics', type: 'string[]' })
topics!: string[]
@OutputField({ description: 'optional caveat', required: false })
caveat?: string
}
String form
For short tasks, or when you would rather not use decorators. Types follow a colon.
const rate = new Predict('question -> answer, confidence: number')
const out = await rate.forward({ question: 'When did Apollo 11 land?' })
description is what the model reads — write it as an
instruction, not a label. type drives validation and defaults
to string. required defaults to true;
set it false and the field becomes optional in both the schema
and the inferred TypeScript type. prefix overrides the label
used in the prompt.
04
Field types
Every output field is checked against its declared type. Coercion is lenient, because models emit text — what is not lenient is failure.
| Type | Accepts | Rejects |
|---|---|---|
| string | any text (the default) | — |
| number float | 42, "42", "3.5", "1,200", "87%" | "about forty" |
| int integer | 7, "7" | "7.5", "seven" |
| boolean bool | true, "true", "yes", "1" | "maybe" |
| string[] array list | JSON arrays, or "a, b, c" | unparseable text |
| number[] | [1,2], "1, 2, 3" | non-numeric members |
| object json | JSON objects, fenced or bare | invalid JSON |
| enum | any declared member, trimmed and case-insensitive | anything outside the set |
Commas and a trailing percent sign are stripped before a number is checked,
because models write 1,200 and 87% when asked for a
figure. That is a deliberate accommodation, not an accident — and it
stops well short of guessing at prose.
Enums
A string field asked for a sentiment will accept
euphoric as readily as positive. An
enum field will not: it names the closed set, validates against
it, and — on providers with structured output — puts that set in
the schema, so the constraint reaches decoding rather than only the check
afterwards. Members are declared on values:
class Classify extends Signature {
@InputField({ description: 'the review text' })
review!: string
@OutputField({
description: 'overall sentiment',
type: 'enum',
values: ['positive', 'negative', 'neutral'],
})
sentiment!: string
}
String signatures say the same thing inline. The members are separated by
| rather than ,, because commas already separate
fields:
const p = new Predict('review -> sentiment: enum(positive|negative|neutral)')
Matching is lenient in the same spirit as the other types —
Positive is the same answer as
positive, and the declared spelling is what you get back. A
value outside the set is a ValidationError, not a shrug. An
enum declared with no members throws immediately, since it would
constrain nothing, and so does a malformed inline declaration such as
enum(a|b — quietly falling back to an open string is the
one thing this type must never do.
Strict mode and open-ended types
OpenAI’s strict structured output is stricter than JSON
Schema itself: every object in the document, nested ones included, must carry
additionalProperties: false, and items must be a
real schema rather than the empty {} that means “any
element”. Both open-ended types are emitted accordingly — an
array declares items: { type: 'string' }, and an
object declares an empty, closed object.
One schema is built per signature and handed to whichever provider is
configured, so the constraint lands everywhere, not only on OpenAI. Strict
mode cannot express a free-form object at all, which means a bare
object or json output field is pinned to
{} on every provider with structured output. Declare
the keys you actually want as their own signature fields. Only the text
path — a model reporting
supportsStructuredOutput: false — still accepts an
arbitrary object.
The array case has a milder version of the same edge. A bare
array or list now tells the provider its elements
are strings, so a list of figures comes back as ['1', '2'] rather
than [1, 2]. Declare number[] or
string[] whenever the elements have a type worth naming;
validation accepts either way, but only the declared form constrains what the
model may emit.
Optional fields are emitted as type: [base, 'null'] rather than
being dropped from required. OpenAI documents that form, Anthropic
takes plain JSON Schema, and Gemini’s responseJsonSchema
documents it too. Gemini’s older responseSchema keyword
models nullability with a nullable boolean instead, so a provider
pinned to that older shape would have to translate.
05
Modules
A module is the strategy used to fill a signature. All of them accept an
optional ILanguageModel as the second constructor argument;
without one they use whatever configure() holds.
Predict
One prompt, one completion, one validation. The baseline.
const p = new Predict(Summarize)
const out = await p.forward({ article })
ChainOfThought
Reasons in free text first, then answers with that reasoning in context.
Two model calls. The result carries every signature field plus
reasoning.
const cot = new ChainOfThought(SolveProblem)
const out = await cot.forward({ problem })
console.log(out.reasoning) // how it got there
console.log(out.answer) // validated as declared
RespAct
A reason–act–observe loop over tools you provide. Adds
steps to the result. See Tools below.
06
Tools and RespAct
In their simplest form tools are plain functions taking a string and returning a string, or a promise of one. Give each a description — the model picks from those descriptions, so write them as instructions.
import { RespAct } from '@ts-dspy/core'
const agent = new RespAct(
'question -> answer',
{
tools: {
lookupOrder: {
description: 'Look up an order by its ID. Input: the order ID.',
function: async (id) => JSON.stringify(await db.orders.find(id)),
},
today: {
description: 'Return today’s date. Input: ignored.',
function: () => new Date().toISOString().slice(0, 10),
},
},
maxSteps: 8,
onEvent: (e) => logger.debug(e),
}
)
const out = await agent.forward({ question: 'Has order A-4182 shipped?' })
console.log(out.answer, out.steps)
Typed tool arguments
One string is a thin pipe for a tool that really wants three fields. Add a
parameters schema — a JSON Schema object, or a Zod schema
— and the tool receives named arguments instead. Zod schemas are
validated before the tool runs, and a failure comes back to the model as an
observation it can correct rather than an exception you have to catch.
import { z } from 'zod'
const agent = new RespAct(
'question -> answer',
{
tools: {
findFlights: {
description: 'Find flights between two airports on a date.',
parameters: z.object({
from: z.string().describe('departure IATA code'),
to: z.string().describe('arrival IATA code'),
date: z.string().describe('ISO date, e.g. 2026-03-14'),
}),
function: ({ from, to, date }) => flights.search(from, to, date),
},
},
}
)
Bare functions and { description, function } keep working as
they always did; a tool without parameters is still called with
a single string.
Native vs. text-mode tool calling
RespAct chooses its path from the model, not from configuration.
When the language model reports
supportsFunctionCalling: true — every provider in this
repo does — the tool schemas travel in the request itself and the
model's calls come back as structured data. When it does not, the loop falls
back to prompting for Action: / Action Input: lines
and parsing them out of the completion, which works on any model that can
produce text.
| Native | Text mode | |
|---|---|---|
| Requires | supportsFunctionCalling | any model that emits text |
| Tool schemas | sent with the request | described in the prompt |
| Arguments | a parsed object from the provider | JSON on the Action Input line |
| Calls per turn | several, executed in order | exactly one |
| Typical failure | arguments fail your schema | the model mis-formats and burns a step |
Both paths run the same tools, honour the same repeat guard, and emit the
same events, so a program written against one works against the other.
That is the point of keeping the text loop: a local model behind an
OpenAI-compatible endpoint still runs your agent. Pass
forceTextMode: true to pin a tool-capable model to the text
loop — useful for comparing the two, or when a particular model's tool
mode misbehaves.
maxSteps defaults to 6. A repeated tool call
is not executed twice — the loop returns an observation telling the
model it already has that result. A final answer that fails validation is
fed back with the failing field names so the model can correct it, as long
as a step remains. Exhausting the steps throws rather than returning a
half-formed answer.
onEvent receives each thought, tool call, observation, and
validation failure. Route it to your logger for a full trace of a run.
07
Providers
Each vendor provider wraps its vendor's official SDK and reports
supportsStructuredOutput: true, so Predict uses the
native JSON-schema mode rather than scraping labelled text.
OpenAICompatibleLM is the exception — it declares its
capabilities from config, because the endpoint behind it might be anything.
Anthropic
import { AnthropicLM } from '@ts-dspy/anthropic'
new AnthropicLM({
apiKey: process.env.ANTHROPIC_API_KEY,
model: 'claude-opus-5', // default
maxTokens: 16000,
timeout: 60_000,
maxRetries: 2,
})
A refusal is surfaced as a typed error rather than empty text: the API
returns HTTP 200 with stop_reason: "refusal", which is easy to
mistake for a successful empty reply.
OpenAI
import { OpenAILM } from '@ts-dspy/openai'
new OpenAILM({
apiKey: process.env.OPENAI_API_KEY,
model: 'gpt-5.2', // default
baseURL: 'https://...', // any OpenAI-compatible endpoint
})
Sampling parameters are sent only when you set them, so reasoning models
that reject a non-default temperature work untouched.
OpenAI-compatible endpoints
Ollama, LM Studio, vLLM, Groq, Together, and OpenRouter all speak the
OpenAI chat-completions API, so @ts-dspy/openai already talks to
them — but the OpenAI defaults are wrong once you leave
api.openai.com. OpenAICompatibleLM corrects them:
baseURL and model are required, the API key
defaults to a placeholder for local servers that want the header but ignore
its value, and capabilities are declared rather than assumed.
import { OpenAICompatibleLM } from '@ts-dspy/openai'
new OpenAICompatibleLM({
baseURL: 'http://localhost:11434/v1', // required
model: 'llama3.2', // required
// apiKey defaults to a placeholder Ollama and LM Studio ignore
supportsStructuredOutput: false, // default
maxContextLength: 8192, // default
})
supportsStructuredOutput is the flag that matters.
Predict branches on it: left at OpenAI's true, every
call ships a response_format: { type: 'json_schema', strict: true }
that most compatible servers reject outright, and the provider looks broken
rather than merely unsupported. At false, structured output goes
through the prompt-based fallback and the reply is validated against the
signature exactly as before.
| Endpoint | baseURL | Key | Capabilities to set |
|---|---|---|---|
| Ollama | http://localhost:11434/v1 | none | Defaults. Raise maxContextLength to match the model. |
| LM Studio | http://localhost:1234/v1 | none | Defaults. |
| vLLM | http://localhost:8000/v1 | none | supportsStructuredOutput: true when guided decoding is enabled. |
| Groq | https://api.groq.com/openai/v1 | GROQ_API_KEY | supportsFunctionCalling: true on the tool-capable models. |
| Together | https://api.together.xyz/v1 | TOGETHER_API_KEY | supportsStructuredOutput: true on models listing JSON mode. |
| OpenRouter | https://openrouter.ai/api/v1 | OPENROUTER_API_KEY | Per routed model — the safe default is to set nothing. |
Those URLs are also exported as OPENAI_COMPATIBLE_BASE_URLS, keyed
ollama, lmstudio, vllm,
groq, together, and openrouter. The
runnable version is examples/ollama-local.ts:
ollama serve # leave running
ollama pull llama3.2
npm run example:ollama
Note that OPENAI_API_KEY is deliberately not read from
the environment here, unlike OpenAILM. baseURL is by
definition somewhere other than api.openai.com, so forwarding an
OpenAI credential to it would be a leak rather than a convenience — pass
the endpoint's own key as apiKey.
Gemini
import { GeminiLM } from '@ts-dspy/gemini'
new GeminiLM({
apiKey: process.env.GEMINI_API_KEY,
model: 'gemini-3.5-flash', // default
vertexai: false,
})
Writing your own
Extend BaseLM and implement chat() and
getCapabilities(). Usage accounting,
generate() delegation, and a prompt-based
generateStructured() fallback come with the base class.
import { BaseLM, type ChatMessage, type ModelCapabilities } from '@ts-dspy/core'
class LocalLM extends BaseLM {
async chat(messages: ChatMessage[]): Promise<string> {
const res = await fetch('http://localhost:11434/api/chat', { /* ... */ })
return (await res.json()).message.content
}
getCapabilities(): ModelCapabilities {
return { supportsStructuredOutput: false, supportsStreaming: false,
supportsFunctionCalling: false, supportsVision: false,
maxContextLength: 8192, supportedFormats: ['text'] }
}
}
08
Validation and errors
Every reply is checked before it reaches you. A field that cannot satisfy
its declared type throws ValidationError naming the field,
rather than handing back a string where the type says number.
import { ValidationError, LMError } from '@ts-dspy/core'
try {
const out = await triage.forward({ ticket })
} catch (err) {
if (err instanceof ValidationError) {
err.issues // [{ field, expected, received, message }]
err.rawOutput // exactly what the model said
metrics.increment('llm.validation_failed', { field: err.issues[0].field })
} else if (err instanceof LMError) {
err.cause // the provider SDK's own error
}
}
| Error | Means | Carries |
|---|---|---|
| ValidationError | The reply did not match the signature. | issues[], rawOutput |
| LMError | The provider call itself failed. | cause, provider, status |
| TsDspyError | Base class for both. | — |
A validation failure is usually a prompt problem, not a bug. The field
description is the lever: 'urgency from 0 to 1' gets a number far
more reliably than 'urgency'.
The error taxonomy
A failed provider call throws something more specific than
LMError whenever the SDK gives us enough to tell — so
retry logic is a catch on a class, not a sniff at an HTTP
status number. Every one of these still extends LMError, so
existing handlers keep working unchanged.
| Error | Thrown when | What each provider gives us |
|---|---|---|
| RateLimitError | A rate or quota limit was hit. Retry after a backoff. | OpenAI 429; Anthropic rate_limit_error; Gemini 429. |
| AuthError | The key is missing, wrong, or not entitled to the model. Retrying will not help. | OpenAI 401/403; Anthropic authentication_error or permission_error; Gemini 401/403. |
| ContextLengthError | The prompt did not fit the context window. Shorten it or move to a larger model. | OpenAI code: 'context_length_exceeded' on a 400 — the only dependable signal. Anthropic has no such type, and Gemini has no code at all, so both fall back to matching the message on a 400. |
| ContentFilterError | Safety classifiers declined the request or the reply. Carries category where one is reported. |
Not an SDK error at all — see below. |
| TimeoutError | The request timed out before a reply arrived. | OpenAI APIConnectionTimeoutError, which carries no status; Anthropic timeout_error; any 408. |
| LMError | Anything else — a 500, a dropped connection, an unrecognised 400. | The status, when the SDK reported one, plus cause. |
import { RateLimitError, ContextLengthError, LMError } from '@ts-dspy/core'
try {
return await triage.forward({ ticket })
} catch (err) {
if (err instanceof RateLimitError) return queue.retryLater(ticket)
if (err instanceof ContextLengthError) return triage.forward({ ticket: truncate(ticket) })
if (err instanceof LMError) log.error(err.provider, err.status, err.cause)
throw err
}
Content filtering is a 200
None of the three providers throws when a reply is filtered — all
three answer 200 OK and leave a marker in the body. Anthropic
sets stop_reason: 'refusal', Gemini sets
promptFeedback.blockReason or
finishReason: 'SAFETY', and OpenAI sets
finish_reason: 'content_filter'. Each provider inspects the
response and raises ContentFilterError, so a declined request
never comes back to you as a silently empty string.
AnthropicRefusalError was the older, Anthropic-only name for
this. It is now a deprecated alias of ContentFilterError
— an alias of that class, not a subclass of it, so an
instanceof check under the old name now matches an OpenAI or
Gemini filter too. Test err.provider === 'anthropic' if you
need to tell them apart, and prefer the new name.
09
Streaming
There are two levels. A module streams fields, so a value fills in as its tokens arrive and is still validated at the end; a provider streams tokens, which is raw text and validates nothing.
Streaming a module
stream() runs the same prediction as forward().
Every yield is a snapshot of the output fields parsed so far, so the object
you render grows rather than appearing all at once.
const qa = new Predict(AnswerQuestion)
for await (const partial of qa.stream({ question })) {
render(partial.answer) // 'Par' → 'Paris' → 'Paris, France'
}
The last yield is the complete output, validated against
the signature exactly as forward() validates it — a stream
that ends in something the signature rejects still throws a
ValidationError, from the final pull. Streaming is not an
opt-out from the runtime checks. The generator's return value
is the Prediction wrapper, which for await discards;
reach it by driving the iterator yourself.
Only that last snapshot is guaranteed to match the declared types. Coercion
belongs to validation, so a field declared number may still be
the raw '0.' the model is part-way through writing — which
is why a snapshot is typed as PartialOutput<T>, every field
optional and possibly still a string.
const stream = qa.stream({ question }, { signal: controller.signal })
let step = await stream.next()
while (!step.done) {
render(step.value.answer)
step = await stream.next()
}
step.value.confidence // the validated Prediction
| Situation | Behaviour |
|---|---|
| Native structured output | Streams JSON, read by an incremental parser. |
| No structured output | Streams labelled text, read by the usual heuristics. |
| supportsStreaming: false or no chatStream | One ordinary call, yielded once. It does not throw. |
| break | Closes the provider's stream; nothing is left open. |
| signal | Cancels the stream, which rejects with the signal's reason. |
ChainOfThought streams too. The reasoning step has to finish
before the answer step can start, so nothing is yielded until it has; from
then on every snapshot carries the finished reasoning alongside
the fields filling in.
Reading truncated JSON
The parser behind the structured path is exported, dependency-free and
pure. Give it a JSON document cut off anywhere — mid-string, mid-key,
after a trailing comma, halfway through an escape — and it returns the
fields that are unambiguously there, without throwing. A number or keyword
cut off at the end is dropped, because 1 may still become
12.
import { parsePartialJson } from '@ts-dspy/core'
parsePartialJson('{"answer": "Par') // { answer: 'Par' }
parsePartialJson('{"a": 1, "b": 2,') // { a: 1, b: 2 }
parsePartialJson('{"a": "he said \\"hi') // { a: 'he said "hi' }
parsePartialJson('{"score": 0.9') // {} — 0.9 may become 0.95
Streaming a provider
One level down, all three providers yield raw chunks. This path bypasses signature validation by design — you are consuming tokens before there is a complete reply to check — so use it for display, and use a module when you need the parsed object.
const lm = new AnthropicLM({ apiKey })
for await (const chunk of lm.generateStream('Explain B-trees.')) {
process.stdout.write(chunk.content)
if (chunk.done) console.log(chunk.usage)
}
10
Timeouts and retries
LLMCallOptions flows from a module through to the provider SDK,
which owns retries and honours retry-after. There is no second
retry layer stacked on top, so you never get two loops fighting each other
— with one documented exception on Gemini, below.
const out = await triage.forward(
{ ticket },
{ timeout: 30_000, retries: 3, maxTokens: 2000 }
)
| Option | Effect |
|---|---|
| timeout | Deadline in milliseconds. It bounds each attempt, so a retried call gets a fresh one. |
| retries | Passed to the SDK's own retry policy. Defaults to 2 on all three providers. |
| signal | An AbortSignal that cancels the call in flight. |
| maxTokens | Output cap for the call. |
| temperature topP | Sent only when set, so reasoning models are unaffected. |
| model | Override the provider's model for one call. |
Cancellation
A timeout ends a call that is taking too long. signal ends one
whose answer nobody is waiting for any more — a React component that
unmounted, a server request whose client hung up. Pass any
AbortSignal and the call rejects the moment it aborts, instead of
running on to a reply that gets thrown away.
const controller = new AbortController()
// React: return () => controller.abort() from the effect.
const out = await triage.forward(
{ ticket },
{ signal: controller.signal, timeout: 30_000 }
)
signal and timeout compose: pass both and whichever
fires first ends the call. OpenAI and Anthropic hand each to the SDK
separately; Gemini exposes a single abortSignal slot, so ts-dspy
folds the two into one with AbortSignal.any(), freshly per
request.
The signal is read when the request goes out, and aborting a controller
aborts every call still attached to it. That is exactly what you want for
a screen full of parallel calls, and exactly what you do not want when
one retry should outlive an earlier cancellation — make a fresh
AbortController for each piece of work you might cancel
independently.
Provider defaults
The same two knobs are available at construction on all three providers, and
per-call options override them. GeminiLM accepted neither before
v0.6, and ignored retries on the call options as well.
new OpenAILM({ apiKey, timeout: 20_000, maxRetries: 2 })
new AnthropicLM({ apiKey, timeout: 20_000, maxRetries: 2 })
new GeminiLM({ apiKey, timeout: 20_000, maxRetries: 2 })
@google/genai reads its retry policy from client-level options
only, so a per-call retries cannot be expressed through it —
and its retry wrapper replaces API errors with generic ones, losing the
status code, and keeps retrying after an abort. The Gemini provider
therefore runs the loop itself: exponential backoff on 408/409/429/5xx and
network failures, never on an abort or a client error. It is the one place
ts-dspy does not simply hand retries to the SDK, and it exists so
retries: 2 means the same thing on all three providers.
Usage accounting
Every provider tracks tokens across calls.
lm.getUsage() // { promptTokens, completionTokens, totalTokens, requestCount }
lm.resetUsage()
There is no cost estimate. Prices change faster than a library can track them, and a stale multiplier is worse than no number — earlier versions reported figures roughly twenty times off.
11
Testing
Modules take a language model, so a test needs no network and no API key.
The doubles ts-dspy tests itself with ship alongside it, under the
@ts-dspy/core/testing subpath — a separate entry point, so
nothing in it is pulled into your production bundle.
import { MockLM, CassetteLM } from '@ts-dspy/core/testing'
Scripted replies
MockLM answers with the strings you hand it, in order, and
records every call it received — including the prompt the module built,
which is usually the part worth asserting on.
import { MockLM } from '@ts-dspy/core/testing'
it('asks about the ticket it was handed', async () => {
const lm = new MockLM({ responses: ['category: bug\nurgency: 3'] })
const result = await new Predict(TriageTicket, lm).forward({ ticket: 'login broken' })
expect(result.urgency).toBe(3)
expect(lm.lastPrompt()).toContain('login broken')
})
Pass structuredResponses alongside
capabilities: { supportsStructuredOutput: true } to exercise the
native structured-output path instead, and read lm.calls or
lm.structuredCalls for the options each call carried.
chatStream and generateStream replay the same
scripted text in chunks.
Cassettes
A cassette records real provider replies once and replays them ever after. Capture them with a key on your own machine, commit the file, and CI runs the same test with no key, no network, and no flake.
// Once, against the real provider:
const recorder = CassetteLM.record('cassettes/triage.json', new OpenAILM({ apiKey }))
await new Predict(TriageTicket, recorder).forward({ ticket: 'login broken' })
// Everywhere else, ever after:
const lm = CassetteLM.replay('cassettes/triage.json')
What it writes is a plain array of entries, each keyed by a hash of its request, so a changed prompt reviews as a readable diff rather than an opaque blob.
[
{
"key": "6f1c3b0a9e7d5241",
"request": {
"kind": "chat",
"model": "gpt-4o-mini",
"messages": [{ "role": "user", "content": "Ticket: login broken\n..." }],
"options": { "temperature": 0 }
},
"response": "category: bug\nurgency: 3"
}
]
The key covers the messages, the model name, and the sampling options that
change a reply. timeout and retries are left out, so
re-running a test with a longer deadline still hits the recording. Identical
requests replay in the order they were recorded, and an unrecorded one fails
loudly rather than calling out.
| Mode | Behaviour |
|---|---|
| replay | The default, and what CI runs. Never calls a live model; a miss throws LMError. |
| record | Calls the wrapped model for everything and rewrites the file from scratch. |
| auto | Replays what the file holds, calls the wrapped model for the rest and appends it. |
Rolling your own
Anything implementing ILanguageModel will do; extend
BaseLM to inherit the usage accounting and the structured-output
fallback.
import { BaseLM, Predict } from '@ts-dspy/core'
class StubLM extends BaseLM {
constructor(private reply: string) { super('stub', 'stub-model') }
async chat() { return this.reply }
getCapabilities() {
return { supportsStructuredOutput: false, supportsStreaming: false,
supportsFunctionCalling: false, supportsVision: false,
maxContextLength: 4096, supportedFormats: ['text'] }
}
}
it('rejects a non-numeric urgency', async () => {
const lm = new StubLM('category: bug\nurgency: very high')
await expect(new Predict(TriageTicket, lm).forward({ ticket: 'x' }))
.rejects.toThrow(ValidationError)
})
Testing the failure path matters more than the happy path. A model that returns the right shape is not the interesting case.
12
API reference
Exports from @ts-dspy/core
| Export | Kind | Purpose |
|---|---|---|
| Signature | class | Base class for declared signatures. |
| InputField | decorator | Marks an input field. |
| OutputField | decorator | Marks an output field and its type. |
| Module | class | Base class for modules. |
| Predict | class | One-shot prediction. |
| ChainOfThought | class | Reason, then answer. |
| RespAct | class | Tool loop. |
| BaseLM | class | Base for providers. |
| Prediction | class | Result wrapper with direct field access. |
| Example | class | Input/output pair for few-shot use. |
| configure | function | Set the default LM and flags. |
| getDefaultLM | function | Read the configured LM. |
| ValidationError | class | Reply did not match the signature. |
| LMError | class | Provider call failed. |
| TsDspyError | class | Base error. |
| buildPrompt | function | Render a prompt from a signature. |
| parseOutput | function | Parse and validate a raw reply. |
| buildOutputSchema | function | Signature → zod schema. |
| buildOutputJsonSchema | function | Signature → JSON Schema. |
| fieldConfigToZod | function | One field → zod schema. |
configure()
configure({
lm: new AnthropicLM({ apiKey }),
cache: false,
tracing: false,
})
13
Migrating to 0.5
0.5.0 makes the type guarantee real, which means code that relied on the old silent behaviour now throws.
| Change | What to do |
|---|---|
Parsing throws ValidationError instead of returning null or an uncoerced string. |
Catch it, or mark the field required: false if the model legitimately may omit it. |
| Node 22+ required. | Node 20 reached end of life in April 2026. |
| Packages are ESM-first with an exports map. | Nothing, unless you were reaching into dist/ directly. |
Module.save/load/compiled removed. |
They threw or were never set. Remove the call sites. |
| Cost estimation removed. | Compute from getUsage() with prices you control. |
| Provider defaults moved to current models. | Pin model explicitly if you need the old one. |
Gemini moved to @google/genai. |
Nothing, unless you constructed the old SDK yourself. |
Upgrade core and every provider together. Providers declare
@ts-dspy/core@^0.5.0, and a mismatched pair is exactly the bug
0.5.0 fixed — 0.4.2 shipped providers that resolved a stale core.
14
Tracing and history
When a signature misbehaves the first question is always the same: what prompt did it actually send? Turn tracing on and every module invocation records one — the prompt, the raw reply, what was parsed out of it, the tokens it cost, and how long it took.
import { configure, inspectHistory, Predict } from '@ts-dspy/core'
configure({ lm, tracing: true })
const out = await new Predict(TriageTicket).forward({ ticket })
out.trace?.rawLMInput // the prompt as sent
out.trace?.rawLMOutput // the reply, before parsing
out.trace?.usage // tokens for this call, not the model's lifetime
The same entries land in a ring buffer, so you can read them after the
fact — including from a catch block, where the prediction
you would have inspected does not exist.
try {
await triage.forward({ ticket })
} catch (error) {
const [failed] = inspectHistory(1)
console.error(failed.rawLMInput, '\n---\n', failed.rawLMOutput)
}
inspectHistory(n) returns the last n entries,
oldest first; omit n for everything still retained. The buffer
holds 100 entries by default — raise or lower it with
traceHistorySize — and clearHistory() empties
it, which is what you want between tests.
What an entry holds
| Field | Contents |
|---|---|
| moduleId | Which instance ran, e.g. ChainOfThought#2. |
| input output | Arguments in, parsed fields out. output is empty on failure. |
| rawLMInput rawLMOutput | The last call — the one that produced output. |
| calls | Every round trip, in order, each with its own prompt, reply, duration, and usage. |
| usage | Tokens for this invocation, differenced from the model's running totals. Exact for sequential calls; approximate when several run concurrently against one model. |
| duration timestamp | Milliseconds elapsed, and when it started. |
| error | Set when the invocation threw instead of returning. |
ChainOfThought makes two calls and RespAct makes
one per step, so calls is where a multi-step module becomes
readable. Failures are recorded too — a ValidationError is
precisely when the prompt is worth reading.
Forwarding entries
onTrace receives each entry as it is recorded. Send them to
Langfuse, an OpenTelemetry span, or your own logger; the library itself
never prints.
configure({
lm,
tracing: true,
onTrace: (entry) => span.addEvent('lm.call', {
module: entry.moduleId,
tokens: entry.usage.totalTokens,
ms: entry.duration,
}),
})
Tracing costs one boolean check while it is off — nothing is timed, copied, or retained. A handler that throws is swallowed, so a broken exporter cannot fail the run it is instrumenting.
15
Caching
Repeated prompts are the common case — a test suite re-running the same
example, an optimiser sweeping a signature, a retry loop that re-asks a
question it already asked. configure({ cache: true }) replays the
previous answer instead of paying for it again.
import { configure, MemoryCache } from '@ts-dspy/core'
// Process-wide LRU, 1000 entries.
configure({ cache: true })
// Or size it yourself.
configure({ cache: new MemoryCache({ maxSize: 10_000 }) })
Caching is opt-in. A cache that replays an old answer for a repeated prompt changes what a program does — sampling stops varying, an agent loop stops exploring — so it is never switched on behind your back.
What goes into a key
generate, chat, and generateStructured are
all cached, for every provider, because the wrapping lives in
BaseLM. The key is a SHA-256 hash of everything that can change
the answer.
| In the key | Not in the key |
|---|---|
Provider and model, including a per-call model override |
timeout and retries — transport, not content |
| The prompt, or every message with its role | metadata, which the provider does not see |
temperature, topP, maxTokens, stopSequences, frequencyPenalty, presencePenalty |
— |
| The JSON schema for a structured call, with keys sorted so property order does not split an entry | — |
Whatever the provider returns from cacheScope() |
— |
Prompts and schemas run to tens of kilobytes, so the canonical form is hashed rather than stored. Errors are never cached: a transient 429 must not pin a failure to a prompt for the life of the process.
Provider and model alone do not identify a request. A client built with a
default maxTokens, non-default safety settings, or a
baseURL pointing at a proxy answers the same prompt
differently, so a custom provider extending BaseLM should
return those options from cacheScope() and keep its entries
apart from a differently configured sibling.
Usage accounting
A cache hit costs nothing, and getUsage() says so. Hits land in a
new cacheHits counter and are kept out of requestCount
and the token totals, so a figure you multiply by a published price still
reflects real provider traffic.
configure({ cache: true })
await lm.generate('Capital of France?')
await lm.generate('Capital of France?')
lm.getUsage()
// { requestCount: 1, cacheHits: 1, promptTokens: 10, completionTokens: 5, ... }
Bring your own store
cache accepts an implementation as well as a boolean. Both methods
may be async, so Redis, SQLite, or a directory of files fits without a
wrapper — useful when the cache should outlive the process or be shared
across workers.
import { configure, type Cache } from '@ts-dspy/core'
const redisCache: Cache = {
async get(key) {
const hit = await redis.get(key)
return hit === null ? undefined : JSON.parse(hit)
},
async set(key, value) {
await redis.set(key, JSON.stringify(value), { EX: 86_400 })
},
}
configure({ cache: redisCache })
get resolves to undefined for a key that was never
written, and null counts as a miss too, since that is what a
Redis client returns for an absent key. Cached values are model
responses, which are never either, so a miss cannot be mistaken for a
hit. An optional clear() backs clearCache()
— handy between test cases, since the built-in cache is
process-wide.
16
Self-repair
A model that fails validation has usually understood the task and merely
fumbled the shape — a number written as "very high", a
required field left off the end. Set repairAttempts and
ts-dspy spends that many extra round-trips telling the model exactly what
went wrong before it gives up.
const qa = new Predict(AnswerQuestion, lm)
// One extra round-trip if the first response fails validation.
const result = await qa.forward(
{ question: 'Capital of France?' },
{ repairAttempts: 1 }
)
The default is 0, so behaviour is unchanged unless you ask for
repair. Once the attempts are spent the last ValidationError is
rethrown, carrying the usual issues and rawOutput.
What the model is told
The follow-up prompt names every failing field with its declared type and
the value that actually arrived, so the correction is specific rather than a
bare “try again”. For a confidence field declared
number that came back as prose, the model is sent:
// Your previous response failed validation for: confidence.
// - confidence: expected number, received "very high"
//
// Previous response:
// answer: Paris
// confidence: very high
//
// Reply again with every required field on its own "field: value" line,
// using the declared type for each.
Each repair is built from the original prompt rather than the previous repair prompt, so a multi-attempt loop does not accumulate every earlier correction and drift off task.
Where it applies
Both of Predict's paths are covered: the provider's native
structured-output mode and the labelled-text fallback. The structured path
asks for a corrected object instead of labelled lines, but the field detail
is identical.
ChainOfThought inherits it. Repair applies to the answering step
only — the reasoning is already settled, so it is reused rather than
regenerated, and a repair costs one call rather than two.
const cot = new ChainOfThought(Scored, lm)
// Reason once, then answer - retrying only the answer.
await cot.forward({ question: q }, { repairAttempts: 2 })
RespAct has always repaired a malformed Final Answer
inside its own reasoning loop, bounded by maxSteps rather than by
repairAttempts. All three modules now build that corrective
prompt from the same helper, so the wording never diverges.
Every repair is a real completion. repairAttempts: 1 is
usually enough — a model that gets the shape wrong twice is more
likely to be missing something in the signature's field descriptions than
to be one nudge from correct. Two guards keep the bill bounded: the value
is capped at 10, and the loop stops as soon as an attempt
reproduces the previous failure exactly, since the next prompt would be
identical and a deterministic model would answer it identically.
17
Batch & concurrency
Every module inherits batch(), so Predict,
ChainOfThought and RespAct all take a list of inputs
and run them through a bounded worker pool. Eight calls in flight by default.
const rows = tickets.map((ticket) => ({ ticket }))
const results = await triage.batch(rows, { concurrency: 16 })
const done = results.filter((r) => r.status === 'fulfilled')
console.log(`${done.length} of ${results.length} classified`)
Results come back in input order, whatever order the calls
actually finished in. This is the detail a hand-written loop gets wrong:
Promise.all over fixed-size slices keeps the order but stalls
each slice on its slowest call, and a queue that pushes results as they
settle quietly loses the correspondence between row and answer.
One bad row does not kill the job
Each input settles independently, in the shape of
Promise.allSettled. A model that returns nonsense for row 4 812
of 10 000 costs you row 4 812, not the run.
for (const [i, result] of results.entries()) {
if (result.status === 'fulfilled') {
save(rows[i], result.value.category)
} else {
// result.reason is the ValidationError or LMError that was thrown
quarantine(rows[i], result.reason)
}
}
Pass stopOnError: true when a single failure means the whole run
is worthless — the batch then rejects and starts no further inputs.
Calls already in flight are still awaited, so nothing is left running behind
your back, and the rejection carries the lowest-indexed failure rather than
whichever one happened to land first.
Options
| Option | Effect |
|---|---|
| concurrency | Maximum calls in flight. Defaults to 8. A value that is not a positive integer throws RangeError rather than hanging. |
| onProgress | (done, total), fired as each input settles — failures included. Advisory: if it throws, it is dropped and the batch carries on. |
| stopOnError | Reject the batch on the first failure instead of capturing it. Defaults to false. |
| signal | An AbortSignal. Once aborted, no further inputs start and the batch rejects with the abort reason. |
Everything else — temperature, timeout,
retries, model — is passed through to every
underlying call unchanged.
const controller = new AbortController()
setTimeout(() => controller.abort(), 60_000)
await triage.batch(rows, {
concurrency: 16,
signal: controller.signal,
onProgress: (done, total) => bar.update(done / total),
temperature: 0,
})
An aborted batch rejects rather than returning the inputs that already
finished, in line with the rest of the platform’s
AbortSignal behaviour — so wrap a cancellable batch in a
try. If you need to keep completed work across a cancellation,
run the inputs in chunks and abort between them.
The pool on its own
The primitive underneath is exported, so anything else with a rate limit can use it — embedding calls, database writes, an image API.
import { mapWithConcurrency } from '@ts-dspy/core'
const embeddings = await mapWithConcurrency(
documents,
(doc) => embed(doc),
{ concurrency: 4, onSettled: (done, total) => log(done, total) }
)
Same guarantees: input order, per-item settlement, a hard ceiling on in-flight work, and no dependency to install — it is roughly eighty lines of core.
18
Zod signatures
A third signature form, built from zod schemas. Decorators record their
fields at runtime, so TypeScript never learns them and results come back
typed loosely unless you write the output shape out by hand. A zod schema
carries that shape in the type system instead — so the result type is
inferred exactly, with no type argument and no
experimentalDecorators.
import { z } from 'zod'
import { signature, Predict } from '@ts-dspy/core'
const AnalyzeReview = signature({
description: 'Analyze a product review.',
input: z.object({ review: z.string() }),
output: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
rating: z.number().int().min(1).max(5),
themes: z.array(z.string()),
followUp: z.string().optional(),
}),
})
const r = await new Predict(AnalyzeReview).forward({ review })
r.sentiment // 'positive' | 'negative' | 'neutral'
r.themes.join(', ') // string[]
r.followUp?.trim() // string | undefined
Input keys are typed from the input schema too, so a misspelt input is a
compile error rather than a field the model never sees. Everything else
behaves as it always has: Predict,
ChainOfThought and RespAct all accept this form,
and decorated classes and string signatures keep working untouched.
What you can express
The zod schema is itself the validator, so every constraint you write is enforced against the model's reply — including the kinds the flat field-type list has no spelling for.
| Written as | Enforced as |
|---|---|
z.enum(['p0', 'p1', 'p2']) |
A union of literals in TypeScript; the options are listed in the prompt and in the provider schema. |
z.number().int().min(1).max(5) |
Bounds checked after coercion; "9" fails rather than passing through as a number. |
z.object({ … }) |
A nested object, parsed from JSON on the text path and typed all the way down. |
z.union([z.number(), z.literal('unknown')]) |
Either branch, as an anyOf in the provider schema. |
.optional() |
Optional in the inferred type, and nullable in the provider schema. |
.refine(…) |
Object-level checks, run after every field has been coerced. |
Coercion still applies
Models emit text, so the text path coerces before your schema sees the
value — "42" satisfies a number field and
"a, b" satisfies a string[], exactly as with the
other two forms. Coercion happens field by field on the way in; your schema
is used verbatim, so defaults, optionality and refinements all survive.
const out = parseOutput(AnalyzeReview, 'sentiment: positive\nrating: 5\nthemes: price, build')
// { sentiment: 'positive', rating: 5, themes: ['price', 'build'] }
The schema sent to the provider
When the provider supports native structured output, the zod schema is
converted with z.toJSONSchema() and then rewritten for
OpenAI's strict mode: every property listed in required,
additionalProperties: false on every object including nested
ones, and optional fields expressed as [base, 'null'] rather
than left out of required.
{
"type": "object",
"properties": {
"sentiment": { "type": "string", "enum": ["positive", "negative", "neutral"] },
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
"themes": { "type": "array", "items": { "type": "string" } },
"followUp": { "type": ["string", "null"] }
},
"required": ["sentiment", "rating", "themes", "followUp"],
"additionalProperties": false
}
Reach for a zod signature when you want the output type without writing
it twice, when a field is an enum or a nested object, or when your
project cannot enable experimentalDecorators. The class form
still reads best when per-field prompt descriptions are the point, and
the string form is still the fastest way to sketch something.
zod 4 is already a dependency of
@ts-dspy/core, but import z from your own
zod so the schema classes match — two copies of zod in
one tree will fail instanceof checks.
A runnable version of everything above lives in
examples/zod-signature.ts:
npm run example:zod
19
Images and multimodal
All three providers read images, and until now there was no way to send
one. ChatMessage.content was a string and every
converter passed it straight through, so supportsVision: true
was a claim the library could not honour. Content is now
string | ContentPart[] — a plain string still means
exactly what it always did, and an array carries text and images in order.
import { imagePart, textPart, type ChatMessage } from '@ts-dspy/core'
const messages: ChatMessage[] = [
{ role: 'user', content: [
textPart('What does this sign say?'),
imagePart('data:image/png;base64,iVBORw0KGgo…'),
] },
]
await lm.chat(messages)
imagePart() accepts an https:// URL, a
data: URI, or an explicit source such as
{ kind: 'base64', data, mediaType }. A bare base64 blob is
refused: nothing in it says whether the bytes are a PNG or a JPEG, and
every provider insists on being told.
Image input fields
A signature can declare an input as an image — with
@ImageField, or the image type in a string
signature. buildPromptContent() then renders the prompt as
content parts, with the image sitting where its label falls.
import { Signature, ImageField, OutputField, buildPromptContent } from '@ts-dspy/core'
class ReadSign extends Signature {
static description = 'Read the sign in the photo.'
@ImageField({ description: 'photo of the sign' })
photo!: string
@OutputField({ description: 'the words on the sign' })
words!: string
}
const content = buildPromptContent(ReadSign, { photo: dataUri })
await lm.chat([{ role: 'user', content }])
// string signatures take a type too
buildPromptContent('photo: image, question -> answer', { photo, question })
buildPromptContent() returns a plain string
when every input is text, byte for byte what buildPrompt()
produces; it returns content parts only once a field declared
image is supplied. Predict and
ChainOfThought follow the same rule, so a text-only program
sends exactly what it always did.
The provider methods that constrain decoding to a schema take a string prompt, so a prompt carrying an image asks for the schema in the prompt instead — the same fallback used for providers with no JSON-schema mode. Validation is identical either way; only the guarantee that the model cannot emit the wrong shape is lost.
What each provider sends
| Provider | Inline bytes | Remote URL |
|---|---|---|
| OpenAI | image_url with a data: URI | image_url.url, plus detail when set |
| Anthropic | image block, source.type: "base64" | image block, source.type: "url" |
| Gemini | inlineData with mimeType | fileData.fileUri, Files API or Cloud Storage only |
A data: URI handed to Anthropic or Gemini as a URL is rewritten
to inline bytes, because neither can dereference one. Gemini will not fetch
an arbitrary web URL at all — fileData resolves only a
Files API or Cloud Storage URI — so anything else is refused with an
LMError rather than sent on to earn a 400. detail
is an OpenAI-only fidelity hint; the others ignore it.
Roles and alternation
Only a user turn may carry an image. OpenAI's message type is a
discriminated union in which system and assistant
accept text alone, and Anthropic's system and Gemini's
systemInstruction are top-level strings — so an image
addressed to any of those is flattened to its placeholder rather than
dropped without trace.
Anthropic additionally requires strict user/assistant alternation, so consecutive same-role turns are merged. That merge now concatenates block arrays: it used to run only when both turns were strings, which meant a text turn followed by an image turn was sent as two adjacent user messages and rejected outright. Merged content is therefore a block array, with two adjoining text blocks folded into one so the blank line between the turns survives.
supportsVision is now reported per model rather than
hardcoded to true: false for
gpt-3.5, o1-mini and o3-mini, for
claude-3-5-haiku and older Claude models, and for Gemini
embedding models. Check it before sending pixels.
20
Evaluation
A prompt change either helped or it did not, and the only way to know is to
measure. evaluate runs a program over a dataset, grades every
prediction with a metric you choose, and hands back one number plus the
working behind it.
import { evaluate, exactMatch, Example, Predict } from '@ts-dspy/core'
const dataset = [
new Example({ question: 'Capital of France?', answer: 'Paris' }).withInputs('question'),
new Example({ question: 'Capital of Japan?', answer: 'Tokyo' }).withInputs('question'),
]
const report = await evaluate(new Predict(AnswerQuestion), dataset, exactMatch, {
concurrency: 8,
})
report.score // 0.5 — the mean across every example
report.results // per example: inputs, expected, prediction, score, error?
report.usage // tokens and latency for this run only
Datasets
A dataset is an array of Example.
withInputs() names the input fields; everything else is what
the program is expected to produce, which is exactly the split an
evaluation needs. Pass inputKeys instead if the whole dataset
shares one shape.
const dataset = rows.map((row) => new Example(row))
await evaluate(program, dataset, exactMatch, { inputKeys: ['question'] })
Metrics
A metric is (example, prediction) => number | boolean, awaited
if it returns a promise. That is the whole contract, and it is deliberately
small: the same function works unchanged as an optimiser's objective.
| Metric | Grades |
|---|---|
| exactMatch | Every expected field, compared as text, character for character. |
| normalizedMatch | The same, ignoring case and runs of whitespace. |
| numericMatch(tolerance) | Numeric fields within an absolute tolerance. |
| fieldAccuracy | Fraction of fields that match, ignoring case and whitespace — partial credit for multi-output signatures. |
| tokenF1 | Token-level F1, for free text where exact match is too blunt. |
| matchMetric fieldAccuracyMetric tokenF1Metric | Factories behind the above, taking fields, normalize, and tolerance. |
Anything else is a function you write.
const mentionsSource: Metric = (example, prediction) =>
String(prediction.get('answer')).includes(example.get('citation'))
const report = await evaluate(program, dataset, mentionsSource)
Read the prediction through get() or toObject().
Prediction defines its fields with
Object.defineProperty, so spreading it is not the same thing.
The report
| Field | Meaning |
|---|---|
| score | Mean score across every example, failures included. |
| totalScore count | The sum and the denominator behind that mean. |
| errorCount | How many examples threw. |
| results | One entry per example, in dataset order. |
| usage | Tokens, request count, mean latency, wall-clock duration. |
Usage comes from diffing the model's own counters around the run, so it counts this evaluation and nothing that came before it. There is no cost figure, for the reason given under usage accounting.
Failures are results
An example whose program or metric throws is recorded as a zero with the error attached, and the run carries on. An evaluation that dies on row 40 of 500 tells you nothing at all.
const failures = report.results.filter((result) => result.error)
failures[0].error.message
failures[0].inputs // what was fed in, to reproduce it
Printing
The library never writes to a console. formatReport returns a
string, and where it goes — a terminal, a log line, a CI annotation
— is your decision.
import { formatReport } from '@ts-dspy/core'
console.log(formatReport(report, { maxRows: 20, includePassing: false }))
// score 0.800 (4.00 / 5) errors 1
// tokens 3120 (2480 prompt + 640 completion) requests 5 ...
//
// # score detail
// 4 0.000 Error: 429 rate limit exceeded
Four examples run at once by default. Raise concurrency when
your rate limit allows it — a 500-row set at one call a second is
eight minutes you did not need to spend — and lower it to one when
you want the run to be reproducible against a scripted model.
21
Few-shot and optimizers
Everything above writes prompts by hand. An optimizer writes them from data instead: give it labelled examples and a metric, and it works out which worked examples belong in the prompt. This is the idea TS-DSPy takes from DSPy — a program that improves itself, rather than a prompt you keep editing.
Demos
A demo is an Example rendered into the prompt before the real
input, showing the task performed correctly. Demos render in whichever
shape the reply is expected to take — labelled
field: value for a provider answering in text, JSON for one
whose decoding is constrained to a schema — so they teach the output
format as well as the task.
import { Example, Predict } from '@ts-dspy/core'
const demos = [
new Example({ ticket: 'My card was charged twice.', team: 'billing' }).withInputs('ticket'),
new Example({ ticket: 'Export does nothing on Safari.', team: 'bug' }).withInputs('ticket'),
]
const router = new Predict(RouteTicket, { demos })
withInputs() marks which fields are the question; the rest is
the answer. Omit it and the signature decides the split instead. The
original two-argument form, new Predict(Sig, lm), still works
— pass { lm, demos } when you want both.
withDemos() returns a configured copy, leaving the
module you called it on alone.
LabeledFewShot
Selects k of your own labelled examples. No model calls, so compiling is instant and free — try this before bootstrapping.
import { LabeledFewShot } from '@ts-dspy/core'
const compiled = new LabeledFewShot({ k: 3, seed: 42 })
.compile(new Predict(RouteTicket), { trainset })
BootstrapFewShot
Runs the module over the trainset, scores every attempt with your metric, and promotes the runs that passed into demos. The program learns from its own successes.
import { BootstrapFewShot } from '@ts-dspy/core'
const optimizer = new BootstrapFewShot({
metric: (example, prediction) => example.get('team') === prediction.get('team'),
maxBootstrappedDemos: 4,
teacher: strongLM, // generates the demos, at compile time only
concurrency: 4,
seed: 42,
onProgress: (event) => log(`${event.index + 1}/${event.total} ${event.status}`),
})
const compiled = await optimizer.compile(new Predict(RouteTicket, cheapLM), { trainset })
With a teacher, a stronger model does the trainset runs and the
cheaper student ends up imitating its work. You pay for the strong model
once, when compiling — never at run time. Without one, the student
bootstraps from itself.
The run stops as soon as it has maxBootstrappedDemos, so a
trainset of five hundred rows does not cost five hundred calls to keep
four demos. Setting maxBootstrappedDemos: 0 alongside
maxLabeledDemos compiles from labels alone and makes no model
calls whatsoever.
Metrics
A metric is (example, prediction) => number | boolean, and may
be async. Booleans are taken at face value; a number counts as a pass when
it reaches threshold, which defaults to 0.5. The
metric is the entire definition of “good” — everything the
optimizer does follows from it.
Options
| Option | Default | Meaning |
|---|---|---|
| metric | required | Judges each attempt. |
| maxBootstrappedDemos | 4 | Demos to promote. The run stops once it has this many. |
| maxLabeledDemos | 0 | Plain labels shown alongside the bootstrapped ones. |
| concurrency | 4 | Trainset examples attempted at once. |
| seed | 0 | Seeds the trainset shuffle. |
| threshold | 0.5 | Score at which a numeric metric passes. |
| teacher | — | Stronger model used to generate demos. |
| inputKeys | — | Input fields, when examples declare none. |
| onProgress | — | Per-example outcome callback. |
| callOptions | — | Call options for the trainset runs. |
Both optimizers are seeded, so the same seed and the same trainset compile to the same demos — a result you can reproduce, diff, and write a stable test against. Progress events arrive in completion order, which with concurrency above 1 is not trainset order; the demos are deterministic regardless.
A trainset example whose attempt throws — a provider error, a reply
that fails validation — is reported as an error event
and skipped. One bad row must not throw away every demo already paid for.
A full run, including a baseline to compare against, is in
examples/optimizer.ts:
export OPENAI_API_KEY="sk-..."
npm run example:optimizer