Skip to main content

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";
ExportKindDescription
completefunctionOne-shot request, resolves with the folded result
streamfunctionStreaming request, returns an AiStream
toolfunctionDeclares a JSON-Schema tool with a local handler
runToolfunctionRuns a tool_call event against its declared handler
AiErrorclassEverything 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_sequence reason — 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: user and assistant, 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:

FieldTypeDescription
modelstringRoutes the request; defaults to claude-opus-5
messagesarrayThe conversation; roles user and assistant only
systemstringSystem prompt (also accepts an array of {type: "text", text} blocks)
max_tokensnumberOutput ceiling; defaults to 16000
temperature, top_p, top_knumberSampling knobs; ignored by models that no longer take them
stop_sequencesstring[]Generation stops before emitting any of these; the sequence itself is never in the text
toolsarrayTool declarations, from tool() or plain {name, description, parameters}
tool_choicestring | object"auto" | "none" | "required" | {name} to force one tool
thinkingbool | objectOpt-in extended thinking: true, {effort: "low" | "medium" | "high"}, or {budget_tokens}
providerstringEscape 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.

note

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​

MemberDescription
for await (const event of s)The normalized event stream
s.resultPromise of the aggregated result — the same shape complete resolves with
s.cancel()Ask the platform to stop producing; the stream still ends normally
s.ticketPromise of the subscription ticket (rarely needed directly)

Events​

EventShape
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:

PropertyDescription
messageHuman-readable summary
payloadThe 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_tokens can end a turn at max_tokens with 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; treat usage as best-effort there.
  • tool_choice: "none" can yield an empty answer. Some models say nothing when the tool they wanted is off the table; check text before printing.