llm_wire/testing
Scripted LLM replies for tests, offline through HTTP Gun’s scripted client.
Describe a reply with text, tool_calls, refusal, output_limited,
content_filtered or prompt_blocked, pair it with a prepared call with
exchange, and give the exchanges to an http_gun/testing client. The
call then runs the ordinary llm_wire.run path:
import http_gun/config as http_config
import http_gun/testing as http_testing
import llm_wire
import llm_wire/testing
let request = llm_wire.request("m", [llm_wire.user("Hi")])
let assert Ok(prepared) = llm_wire.prepare(testing.config(), request)
let script =
http_testing.script([testing.exchange(prepared, testing.text("Hello"))])
let assert Ok(client) = http_testing.playback(script, http_config.default())
let assert Ok(llm_wire.Answer(text: "Hello", ..)) =
llm_wire.run(client, prepared)
config() selects a provider-neutral scripted wire. To test code that
talks to a built-in provider, keep its configuration: exchange lowers
the reply into the prepared provider’s wire, so no test writes the
provider’s server-sent events by hand. events(chunks) sends chunks
exactly as given, for a custom provider’s wire.
A provider’s failures are scripted the same way. rate_limited,
overloaded and http_status write the error status and body of a
built-in provider, interrupted cuts a reply off, invalid_output is a
final text no schema accepts, and with_retry_after adds the
Retry-After header to an exchange:
let limited =
testing.exchange(prepared, testing.rate_limited(message.OpenAI))
|> testing.with_retry_after(duration.seconds(2))
A fake HTTP server serves a reply without unwrapping it:
http_response(message.OpenAI, reply) is a gleam/http response. A
server that sends one chunk per event reads status, chunks and
is_interrupted of events_for(provider, reply) instead. To feed code
that takes a llm_wire.Failure, build one with failure instead of
running a call.
Types
A finite classification reply. Builders keep future fixture fields private.
pub opaque type ClassificationReply
One scripted HTTP reply: a stream of server-sent events, or an error
status. Build it with text, tool_calls, refusal, output_limited,
content_filtered, prompt_blocked, the failure builders or events;
serve it with exchange or http_response.
A reply is in the provider-neutral scripted wire until events_for
lowers it into a built-in provider’s wire. A lowered reply, and one
given as is with events, stays in its wire: events_for leaves it
unchanged.
pub opaque type Reply
A tool call the scripted provider returns. Build it with tool_call.
pub opaque type ScriptedCall
Values
pub fn chunks(reply: Reply) -> List(String)
The body chunks a server sends for reply, in order: one per event for
a stream (lower it first with events_for for a built-in provider), the
whole body for an error status.
pub fn classification_exchange(
prepared: classify.Prepared(a),
reply: ClassificationReply,
) -> testing.Exchange
Uses HTTP Gun’s credential-redacting exchange builder and cassette schema.
pub fn classification_response(
body: String,
) -> ClassificationReply
A raw provider response for classification protocol and malformed-wire tests.
pub fn config() -> llm_wire.Config
The configuration of the provider-neutral scripted wire.
pub fn content_filtered(partial_text: String) -> Reply
The provider’s content filter stops the output after partial_text.
The call fails with error.ContentFiltered(error.InOutput, reason) and
sent: Completed, where reason is the wire’s own value: OpenAI’s
response.incomplete with incomplete_details.reason
"content_filter", Anthropic’s stop_reason "refusal", Gemini’s
finishReason "SAFETY", and "content_filter" on the scripted wire.
pub fn events(chunks: List(String)) -> Reply
A successful stream of exactly these chunks, one per read, then the end
of the stream. Chunks may split events anywhere. Use it for a wire the
builders do not write, such as a custom provider’s or a provider event
with fields the builders leave out; events_for leaves it unchanged.
pub fn events_for(
provider: message.Provider,
reply: Reply,
) -> Reply
Lower a scripted reply into the server-sent events of a built-in
provider’s wire, for a fake server that serves a provider’s real
configuration (exchange lowers by itself). Each event becomes one
chunk. A reply already in a wire (lowered, or from events), a Status
reply and a Custom provider leave it unchanged.
The provider’s reducer shapes some values: Gemini re-encodes call
arguments, which must be a JSON object (other text is sent as
{"unparsed_arguments": text}); Anthropic always reports usage.
refusal panics on Anthropic and Google and prompt_blocked on OpenAI
and Anthropic, whose wires do not carry them.
pub fn exchange(
prepared: llm_wire.Prepared(o),
reply: Reply,
) -> testing.Exchange
One finite HTTP Gun exchange answering prepared with reply, lowered
into the wire of prepared’s provider as events_for lowers it.
Recorded requests drop HTTP Gun’s credential headers.
pub fn failure(
provider: message.Provider,
error: error.Error,
) -> llm_wire.Failure
A llm_wire.Failure for error from provider, as a failed call would
return it, without running a call. sent follows the error: NotSent
for an HTTP Gun failure that sent nothing, Completed for a status, a
provider error or invalid output, MaybeSent otherwise. There is no
partial output and no usage; change a field with a record update:
llm_wire.Failure(..testing.failure(p, e), partial_output: True).
pub fn http_response(
provider: message.Provider,
reply: Reply,
) -> response.Response(String)
reply as provider would send it, for a fake HTTP server: a
gleam/http response with the whole body in one string. A stream gets
status 200 and text/event-stream; an error status keeps its status,
and its body is application/json when it starts with {, else
text/plain. A server that must cut an interrupted reply off
(is_interrupted) does so itself; here it is the chunks so far.
pub fn http_status(
provider: message.Provider,
status: Int,
message: String,
) -> Reply
An error status whose body carries message, shaped as provider
shapes its errors (a Custom provider gets the bare message, so
http_status(message.Custom("scripted"), 503, "busy") sends busy as
is). The call fails with error.Status(status, ..).
pub fn interrupted(reply: Reply) -> Reply
The connection drops after the reply’s content, before its end, so the
call fails with an error.Http transport failure and sent: MaybeSent.
Apply it before events_for, which then also drops the provider’s
terminal events. A reply from events keeps every chunk; a Status
reply is unchanged.
pub fn invalid_output() -> Reply
A final text that is not valid JSON. A plain call answers with it; a
structured call fails with error.InvalidOutput carrying this text.
pub fn is_interrupted(reply: Reply) -> Bool
Whether a server must drop the connection after chunks(reply) instead
of ending the response: true after interrupted.
pub fn output_limited(partial_text: String) -> Reply
Output cut off at the token limit after partial_text.
pub fn overloaded(provider: message.Provider) -> Reply
provider’s overload error: HTTP 529 on Anthropic and 503 on every other
wire.
pub fn prompt_blocked() -> Reply
The provider’s content filter blocks the prompt before generation. The
call fails with error.ContentFiltered(error.InPrompt, reason): Gemini’s
promptFeedback.blockReason "SAFETY", and "content_filter" on the
scripted wire. OpenAI and Anthropic reject such a prompt with an error
status instead (http_status), so events_for panics for them.
pub fn rate_limited(provider: message.Provider) -> Reply
HTTP 429 with provider’s rate-limit error body. The call fails with
error.Status(429, ..). Add the header with with_retry_after.
pub fn refusal(reason: String) -> Reply
The model declines in its own words; the call answers
llm_wire.Refused(reason:, ..). Only OpenAI’s wire and the scripted wire
carry a model’s refusal: events_for panics for Anthropic and Google,
whose only refusal on the wire is a safety stop (content_filtered).
pub fn response_failed(
reply: Reply,
code: String,
message: String,
) -> Reply
OpenAI’s response.failed event after the reply’s content: the stream
ends with the response object’s status: "failed" and its error object
carrying code and message. The call fails like
stream_error(message.OpenAI, ..), with error.Provider(Some(code), message). Like stream_error, the result is already in the OpenAI wire.
pub fn status(reply: Reply) -> Int
The HTTP status a server sends for reply: 200 for a stream.
pub fn stream_error(
provider: message.Provider,
reply: Reply,
code: String,
message: String,
) -> Reply
The provider’s in-band error event arrives after the reply’s content: the
stream starts, reply streams, then the wire’s own error event carries
code (OpenAI’s error.code, Anthropic’s error.type, Google’s
error.status) and message. The call fails with
error.Provider(Some(code), message), sent: Completed and the
partial output. The result is already in provider’s wire, like
events_for’s. Pass the reply before lowering; a lowered reply, one
from events and a Status reply are unchanged.
pub fn text(text: String) -> Reply
A final text answer. Non-empty text streams as one TextDelta.
pub fn tool_call(
id id: String,
name name: String,
arguments_json arguments_json: String,
) -> ScriptedCall
A tool call for tool_calls. Its fields are wire values: the runtime
admits them exactly as it admits a real provider’s call.
pub fn tool_calls(
text: String,
calls: List(ScriptedCall),
) -> Reply
Tool calls, optionally after assistant text, awaiting results.
pub fn with_classification_header(
reply: ClassificationReply,
name: String,
value: String,
) -> ClassificationReply
pub fn with_classification_status(
reply: ClassificationReply,
status: Int,
) -> ClassificationReply
pub fn with_retry_after(
exchange: testing.Exchange,
delay: duration.Duration,
) -> testing.Exchange
Answer with Retry-After: seconds, rounding a fraction up. Use it on an
exchange of an error reply. An exchange that fails before a response is
unchanged.
pub fn with_retry_after_header(
http: response.Response(String),
delay: duration.Duration,
) -> response.Response(String)
Add Retry-After: seconds to a fake server’s http_response, rounding a
fraction up, so advise answers ProviderDelay(delay). For a scripted
exchange, use with_retry_after.
pub fn with_usage(reply: Reply, usage: message.Usage) -> Reply
Report usage with the reply. Apply it before events_for; a lowered
reply, one from events and a Status reply are unchanged.