Every Rust shape here is compiled and run by tests/every_spelling.rs.
If one of them stops being true, that test fails rather than this page quietly going stale.
A task is declared one of two ways — its field names in a string, or a struct — and asked by one
of two modules. Both declarations produce the same type and are asked the same way, which is what
dspy gives by having one Predict class and one Signature base.
let qa = predict!("question -> answer"); // declare
let out = call!(qa, question = "capital of France?").await?; // ask
out.get("answer").unwrap() // readThree things are worth knowing before the table.
Building is free; asking is the network call. predict! reaches no provider, and neither does
copying the module. The model is reached in call!, once per call, which is why that line and only
that line carries .await?.
A bad signature fails the build. predict!("a -> b -> c") is a compile error carrying dspy's
own message, pointed at the literal. dspy raises the equivalent when the program runs.
A module is a value. That is what lets an optimizer rewrite it, and the reason a callable was not the right shape:
let mut program = predict!("question -> answer");
BootstrapFewShot::new(exact_match).compile(&mut program, &trainset).await?;
let out = call!(program, question = "…").await?; // the same line, now compiledqa = dspy.Predict("question -> answer")
out = qa(question="capital of France?")
out.answer// the whole way
let signature: Signature = "question -> answer".parse()?;
let qa = Predict::from_signature(signature);
let out = qa.forward(input! { question: "capital of France?" }).await?;
out.get("answer").unwrap()
// the short way
let qa = predict!("question -> answer");
let out = call!(qa, question = "capital of France?").await?;qa = dspy.Predict("subject, tone -> haiku, mood")
out = qa(subject="computer science", tone="wry")let qa = predict!("subject, tone -> haiku, mood");
let out = call!(qa, subject = "computer science", tone = "wry").await?;
out.get("haiku").unwrap()Types are allowed, and a comma inside brackets belongs to the type rather than separating a field:
let m = predict!("question: str, context: list[str] -> answer: int, scores: dict[str, float]");class QA(dspy.Signature):
"""Answer the question."""
question: str = dspy.InputField()
answer: str = dspy.OutputField()#[derive(Signature)]
/// Answer the question.
struct QA {
#[input]
question: String,
#[output]
answer: String,
}qa = dspy.Predict(QA)
out = qa(question="capital of France?")
out.answer// the whole way — the task's own inputs struct in, its own outputs struct back
let qa = Predict::<QA>::new();
let out = qa.call_inputs(&QAInputs { question: "capital of France?".into() }).await?;
out.answer
// the short way — the same spelling a string signature is asked with
let qa = predict!(QA);
let out = call!(qa, question = "capital of France?").await?;
out.answerout.answer rather than a lookup: a derived task knows its outputs, so the field is checked when
this compiles. That is the reason the two declarations answer with different types even though
they are built and asked alike.
poet = dspy.Predict(Haiku)
out = poet(subject="quantum computing", tone="wry")let poet = predict!(Haiku);
let out = call!(poet, subject = "quantum computing", tone = "wry").await?;
out.haikuOne invocation can name the task and fill it, evaluating to the call itself. The inputs literal is exhaustive, so a forgotten field is a compile error:
let out = predict!(Haiku {
subject: "quantum computing",
tone: "wry"
})
.await?;The model is asked for a leading reasoning field, which is kept out of the answer. Everything
else reads the same.
c = dspy.ChainOfThought("question -> answer")
out = c(question="a calm colour?")
c = dspy.ChainOfThought(Haiku)
out = c(subject="machine learning", tone="patient")let picked = chain_of_thought!("question -> answer");
let out = call!(picked, question = "a calm colour?").await?;
let out = chain_of_thought!(Haiku {
subject: "machine learning",
tone: "patient"
})
.await?;
out.haikuComposing steps into one program is the point of the Module seam, and it is what an optimizer
walks. Python subclasses; Rust implements a trait.
class Outline(dspy.Module):
def __init__(self):
super().__init__()
self.plan = dspy.Predict("subject -> angle")
self.write = dspy.Predict("angle -> haiku")
def forward(self, subject):
angle = self.plan(subject=subject).angle
return self.write(angle=angle)#[derive(Module)]
struct Outline {
plan: Predict,
write: Predict,
}
impl Forward for Outline {
async fn forward(&self, inputs: Example) -> Result<Prediction> {
let angle = self.plan.forward(inputs).await?;
let handed = input! { angle: angle.get("angle").cloned().unwrap_or_default() };
self.write.forward(handed).await
}
}let mut mine = Outline::new();
let out = call!(mine, subject = "winter mornings").await?;You write how it runs; the derive writes what Python inherits.
Every named field is a step, so named_predictors — the seam an optimizer works through — comes
from the field list, and each child's predictors are renamed after the field holding them, so a
demo says which step earned it. A field that is not a step carries #[not_a_step]. The derive
also makes the module callable through call!.
Forward exists so an author is not writing Pin<Box<dyn Future>> by hand. Module keeps that
shape because it must be object-safe for a composed program to hold Box<dyn Module>; the derive
does the boxing in between.
A model is an LM, named provider/model-id. The prefix picks a wire format, not a brand:
openai/… is the OpenAI /v1/chat/completions shape, which OpenAI, Groq, Together, Fireworks,
DeepSeek, vLLM and LM Studio all speak — you reach any of them by pointing the base URL, not by a
new prefix. anthropic/… and ollama/… are their own shapes.
// OpenAI itself, from OPENAI_API_KEY in the environment.
let lm = LM::new("openai/gpt-4o-mini")?;
// Groq, on the same wire, a different host and key.
let lm = LM::new("openai/llama-3.3-70b")?
.with_openai_base_url("https://api.groq.com/openai/v1")
.with_openai_key(std::env::var("GROQ_API_KEY")?);
dsrust::lm::configure(lm); // the process-wide default every module reachesThis is where DsRust and dspy part on purpose. dspy routes every provider through litellm, whose
prefix is the brand (groq/…, bedrock/…) with the host known for you; DsRust is litellm-free, so
the prefix is the wire format and a non-OpenAI host is named by its URL. The four built-in shapes
are a closed match, because something must map a model string to a wire — dspy has the same map
in litellm, just hidden.
A provider of your own is the ChatModel trait, the one seam every built-in already implements:
struct MyProvider {
key: String,
}
impl ChatModel for MyProvider {
fn forward<'a>(
&'a self,
http: &'a reqwest::Client,
request: &'a dsrust::lm::api::LmRequest,
) -> impl Future<Output = Result<dsrust::lm::api::LmResponse>> + Send + 'a {
async move {
// request.wire_messages(), request.config.temperature, request.output_schema() —
// translate the typed request to your API, and its reply back to an LmResponse.
todo!()
}
}
}This is dspy 3.3's forward_contract = "typed_lm" shape exactly: forward(LmRequest) -> LmResponse.
A model built this way is indistinguishable from the built-ins — it nests behind Cached, reaches
every module, and (with forward_stream, optional) streams.
Rust has no class inheritance, so "extend OpenAI and change one thing" is composition: the
built-in OpenAI provider is Endpoint, parameterised by base URL, key, JSON envelope and
token-cap rule, so a variant that differs only in configuration is a different Endpoint, not a
subclass. A provider that shares the OpenAI wire but changes a header or the reply parsing wraps
the OpenAI request/reply pieces in its own ChatModel — it holds what it reuses rather than
inheriting it.
dspy's modules split into two families, and the split decides how you build one.
A module that takes a signature is declared like Predict — a field-name string or a task
type — and each has a macro of the same name.
A wrapper takes another module. There is no signature to hand it; the signature lives in
whatever it wraps, so it is built with ::new and has no macro.
| module | takes | dspy | DsRust |
|---|---|---|---|
Predict |
a signature | dspy.Predict("q -> a") |
predict!("q -> a") |
ChainOfThought |
a signature | dspy.ChainOfThought("q -> a") |
chain_of_thought!("q -> a") |
ReAct |
a signature + tools | dspy.ReAct("q -> a", tools=[…]) |
ReAct::new(signature!("q -> a"), tools) |
MultiChainComparison |
a signature | dspy.MultiChainComparison("q -> a") |
MultiChainComparison::new(…) |
BestOfN |
a module | dspy.BestOfN(module=qa, N=3, reward_fn=f, threshold=1.0) |
BestOfN::new(qa, 3, f, 1.0) |
Parallel |
branches per call | dspy.Parallel(num_threads=8) |
Parallel::new(8) |
Refine, ProgramOfThought, CodeAct and RLM are not built yet. The first is a wrapper like
BestOfN; the other three take a signature and will get macros.
# dspy
qa = dspy.ChainOfThought("question -> answer")
out = qa(question="capital of France?")
out.answer// DsRust
let qa = chain_of_thought!("question -> answer");
let out = call!(qa, question = "capital of France?").await?;
out.get("answer").unwrap()BestOfN runs a module up to n times, each at a fresh rollout and temperature = 1.0, and
keeps the highest-scoring attempt — stopping early at the first to reach threshold.
# dspy
def one_word(inputs, out):
return 1.0 if len(out.answer.split()) == 1 else 0.0
qa = dspy.ChainOfThought("question -> answer")
best = dspy.BestOfN(module=qa, N=3, reward_fn=one_word, threshold=1.0)
out = best(question="capital of Belgium?")// DsRust
fn one_word(_inputs: &Example, out: &Prediction) -> f64 {
match out.get("answer").and_then(|answer| answer.as_str()) {
Some(answer) if answer.split_whitespace().count() == 1 => 1.0,
_ => 0.0,
}
}
let qa = chain_of_thought!("question -> answer");
let best = best_of_n!(qa, n = 3, reward = one_word, threshold = 1.0);
let out = call!(best, question = "capital of Belgium?").await?;The reward is a named function in both, which is what dspy's own example does. A Rust closure
works too — |inputs: &Example, out: &Prediction| … — but writing one inline inside the
constructor buries the three arguments around it.
best_of_n! names the arguments because dspy passes all four by keyword and Rust has no named
arguments. BestOfN::new(qa, 3, one_word, 1.0) compiles and says nothing about which number is
n and which is threshold. This is the same reason call! and input! exist: the macros
supply what the language does not. fail_count is optional in the macro as it is upstream.
All four arguments are upstream's. Two of its details are easy to read as bugs and are not:
threshold is required, because BestOfN.forward compares against it with no guard; and
fail_count = 0 means n, not none allowed, because dspy reads fail_count or N and Python
treats zero as unset.
A wrapper is still a module — it nests, call! reaches it, and an optimizer's walk goes straight
through to the predictors inside it.
| dspy | DsRust | |
|---|---|---|
| Constructor | dspy.Predict(x) |
predict!(x) — a string or a task |
| Call | m(field=…) |
call!(m, field = …) |
| Reading a result | out.answer |
out.answer typed, out.get("answer") from a string signature |
| One module type | ✅ | ✅ Predict<S = Dynamic> |
| Optimizable either way | ✅ | ✅ |
| A malformed signature | raises when run | fails the build |
Three differences are the language rather than the design:
call!(m, …)puts the module inside the parentheses. Postfix macros are not Rust syntax, som.call!(…)cannot parse.m.forward(input! { … })keeps the module as the subject if that reads better..await?is on the call. It is the network request and its failure, and it appears only where the model is actually reached.- The typed long form is
call_inputs, notcall. Rust cannot resolve two methods of one name acrossPredict<Dynamic>andPredict<Task>, so the two constructors and the two typed calls carry distinct names.call!is unaffected and works on both.
Rust has no mapping literal and no named arguments, so { subject: "…" } and f(subject = "…")
are both syntax errors. input! and call! supply what the language does not, the way vec!
supplies a list literal. predict! additionally checks a signature string while the crate
compiles, which is something dspy cannot do at all.