yeet:ai
yeet:ai is provider-agnostic AI chat. A script speaks one neutral shape — messages, JSON-Schema tools, sampling knobs — and the platform serves it with whichever provider owns the requested model. Swapping models is a one-string change: no SDKs, no API keys, no per-provider code paths.
import { complete, stream } from "yeet:ai";
const reply = await complete({
model: "claude-opus-5",
max_tokens: 256,
messages: [{ role: "user", content: "One sentence on why the sea is salty." }],
});
console.log(reply.text, reply.stop_reason);
const s = stream({
model: "gpt-5",
messages: [{ role: "user", content: "Count to ten." }],
});
for await (const event of s) {
if (event.type === "text") {
tty.write(event.delta);
}
}
Importing
import { AiError, complete, runTool, stream, tool } from "yeet:ai";
| Export | Kind | Description |
|---|---|---|
complete | function | One-shot request, resolves with the folded result |
stream | function | Streaming request, returns an AiStream |
tool | function | Declares a JSON-Schema tool with a local handler |
runTool | function | Runs a tool_call event against its declared handler |
AiError | class | Everything the module throws or rejects with |
Concepts
Models
model names what serves the request — claude-opus-5, gpt-5, gemini-2.5-pro, and so on. The platform recognizes the model and takes it from there; there is nothing to configure script-side, and nothing else in the request changes when the model does. Omitting model falls back to the platform default (claude-opus-5); a model the platform doesn't serve rejects with AiError.
One contract, every model
The neutral shape means the same thing everywhere; differences between the models' native APIs are absorbed by the platform rather than leaked into scripts:
- Every field either works or is ignored. A sampling knob some model generation no longer takes is dropped, not bounced back as that model's API error.
- Stop sequences stop generation everywhere, and always report the true
stop_sequencereason — including on models whose native API has no stop parameter or misreports the ending; the platform scans and truncates the stream itself where it must. - Roles are validated identically everywhere:
userandassistant, nothing else. - A turn always ends with a
stop_reason, even where a model's native stream would end without saying why.
Casing
The contract is snake_case end to end — max_tokens, stop_sequences, tool_choice in requests; stop_reason, tool_calls, usage.input_tokens in results. CamelCase spellings are still accepted on requests as a fallback, but snake_case is canonical.
The request
Both complete and stream take the same request object:
| Field | Type | Description |
|---|---|---|
model | string | Routes the request; defaults to claude-opus-5 |
messages | array | The conversation; roles user and assistant only |
system | string | System prompt (also accepts an array of {type: "text", text} blocks) |
max_tokens | number | Output ceiling; defaults to 16000 |
temperature, top_p, top_k | number | Sampling knobs; ignored by models that no longer take them |
stop_sequences | string[] | Generation stops before emitting any of these; the sequence itself is never in the text |
tools | array | Tool declarations, from tool() or plain {name, description, parameters} |
tool_choice | string | object | "auto" | "none" | "required" | {name} to force one tool |
thinking | bool | object | Opt-in extended thinking: true, {effort: "low" | "medium" | "high"}, or {budget_tokens} |
provider | string | Escape hatch for model names the platform can't recognize on its own |
A message's content is a string, or an array of blocks for tool traffic — see Tools.
Thinking needs headroom: the budget must fit under max_tokens with ~1k to spare, and requests below that reject. When thinking is set, the sampling knobs are ignored in its favor.
Thinking
const s = stream({
model: "claude-opus-5",
max_tokens: 16000,
thinking: { effort: "medium" }, // or { budget_tokens: 8192 }, or just `true`
messages: [{ role: "user", content: "How many trailing zeros does 100! have?" }],
});
for await (const event of s) {
if (event.type === "thinking") {
tty.write(`\x1b[2m${event.delta}\x1b[0m`); // reasoning, dimmed
}
if (event.type === "text") {
tty.write(event.delta); // the answer
}
}
complete
One-shot call; resolves once the provider finishes.
const reply = await complete({ model, max_tokens: 512, messages });
Resolves with:
{
text: "…", // concatenated text
tool_calls: [ // every tool call the model made
{ id, name, arguments },
],
usage: { input_tokens, output_tokens, … }, // see the usage event
stop_reason: "end_turn", // see stop reasons
}
Rejects with AiError on any failure — bad request, unknown model, provider error.
stream
const s = stream({ model, messages });
Returns an AiStream immediately; the request is dispatched in the background.
AiStream
| Member | Description |
|---|---|
for await (const event of s) | The normalized event stream |
s.result | Promise of the aggregated result — the same shape complete resolves with |
s.cancel() | Ask the platform to stop producing; the stream still ends normally |
s.ticket | Promise of the subscription ticket (rarely needed directly) |
Events
| Event | Shape |
|---|---|
text | { type: "text", delta } |
thinking | { type: "thinking", delta } |
tool_call | { type: "tool_call", id, name, arguments } |
usage | { type: "usage", model, provider, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens } |
stop | { type: "stop", reason } |
Unknown event types pass through untouched, so new platform events never break old scripts.
Stop reasons
end_turn, max_tokens, tool_use, stop_sequence, refusal, cancelled, error.
Cancelling
let seen = 0;
for await (const event of s) {
if (event.type === "text" && ++seen === 3) {
await s.cancel();
}
}
const partial = await s.result; // stop_reason: "cancelled", partial text and usage
A cancel is an ending, not a failure: the provider stops within milliseconds, the tokens spent so far are still reported, and stop_reason says cancelled. Cancelling twice, or after the stream already ended, is harmless. Abandoning a stream without cancelling also stops the upstream — the platform cancels on your behalf when the local side goes away.
Failure semantics
Setup failures (bad request, unknown model, missing provider key) and mid-stream failures both surface as AiError, from both caller-facing paths: the for await loop throws it, and s.result rejects with it.
Tools
tool
Declares a tool the model may call. parameters is JSON Schema — the one format every provider consumes. The handler stays local; only name, description, and parameters go to the platform.
const weather = tool({
name: "get_weather",
description: "Get the current temperature in celsius for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
handler: ({ city }) => ({ city, celsius: 21 }),
});
runTool
Runs one tool_call event (or aggregated call) against its declared handler and returns the tool_result block content the next request expects:
const outcome = await runTool([weather], call);
// { tool_call_id, name, result }
Throws AiError when no declared tool matches the call, or the match has no handler.
The tool loop
A tool call comes back through the stream (and in tool_calls on the result); its answer goes back as blocks in the next request — a tool_call block on an assistant message, a tool_result block on a user message:
const tools = [weather];
const messages = [{ role: "user", content: "How warm is Lisbon right now?" }];
const s = stream({ model, tools, messages });
for await (const _ of s) { /* drain; the aggregate carries the calls */ }
const turn = await s.result;
if (turn.tool_calls.length > 0) {
const outcomes = [];
for (const call of turn.tool_calls) {
outcomes.push(await runTool(tools, call));
}
const reply = await complete({
model,
tools,
messages: [
...messages,
{
role: "assistant",
content: turn.tool_calls.map(({ id, name, arguments: args }) => ({
type: "tool_call", id, name, arguments: args,
})),
},
{
role: "user",
content: outcomes.map((outcome) => ({ type: "tool_result", ...outcome })),
},
],
});
console.log(reply.text);
}
tool_choice steers the loop: {name: "get_weather"} forces that tool, "none" forbids calls for a turn (useful for the closing summary), "required" demands some call.
Errors
Every failure is an AiError:
| Property | Description |
|---|---|
message | Human-readable summary |
payload | The platform's raw rejection value, for programmatic inspection |
complete() rejects with it; a stream throws it from the iterator and rejects result with it (pre-attach a .catch or use try/catch around the loop).
import { AiError, complete } from "yeet:ai";
try {
await complete({ model: "not-a-model", messages });
} catch (error) {
if (error instanceof AiError) {
console.log(error.message); // what went wrong
console.log(error.payload); // the platform's raw rejection, for inspection
}
}
Putting it together
A complete script: streamed chat over yeet run, with a tool the model may call, looping until the turn needs no more tool work.
import { complete, runTool, stream, tool } from "yeet:ai";
const dice = tool({
name: "roll_dice",
description: "Roll N six-sided dice and return each result",
parameters: {
type: "object",
properties: { count: { type: "integer", minimum: 1, maximum: 10 } },
required: ["count"],
},
handler: ({ count }) => ({
rolls: Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6)),
}),
});
const model = yeet.args.model ?? "claude-opus-5";
const tools = [dice];
const messages = [
{ role: "user", content: yeet.args._.join(" ") || "Roll 3 dice and call the outcome." },
];
while (true) {
const s = stream({ model, max_tokens: 2048, tools, messages });
for await (const event of s) {
if (event.type === "text") {
tty.write(event.delta);
}
}
const turn = await s.result;
if (turn.tool_calls.length === 0) {
tty.write("\r\n");
break;
}
const outcomes = [];
for (const call of turn.tool_calls) {
outcomes.push(await runTool(tools, call));
}
messages.push(
{
role: "assistant",
content: turn.tool_calls.map(({ id, name, arguments: args }) => ({
type: "tool_call", id, name, arguments: args,
})),
},
{ role: "user", content: outcomes.map((outcome) => ({ type: "tool_result", ...outcome })) },
);
}
yeet run chat.js -- --model gemini-2.5-pro "roll five dice"
Practical notes
- Reasoning models spend before they speak. Reasoning-first models burn output budget before the first text token or tool call; a tight
max_tokenscan end a turn atmax_tokenswith empty text even though nothing went wrong. Give tool-calling and short-answer turns 2048+. - Cancelled and truncated streams report partial usage. A stream ended by
cancel()or a stop sequence may miss the vendor's final usage frame; treatusageas best-effort there. tool_choice: "none"can yield an empty answer. Some models say nothing when the tool they wanted is off the table; checktextbefore printing.