Skip to main content

submilli_engine/stdlib/llm/
declaration.rs

1//! Type surface of `submilli:llm`.
2//!
3//! `call` and `batch` are declared **generic**, and that generic is only sound
4//! because the typechecker intercepts it. The typed forms are not a second
5//! signature: they are a rewrite. A generic `call<T>` reaching the ordinary
6//! generic-call lowering would set `return_cast`, which codegen emits as a Wasm
7//! representation cast rather than a structural check, so a response that
8//! ignored the schema would arrive statically typed and never validated. So the
9//! typechecker matches the symbol by mangled name and rewrites the call into a
10//! checked `Cast` instead — the contract `submilli:session`'s `get` carries.
11//! The declaration and that interception are a matched pair, and a generic
12//! declaration without the interception is the unsound half on its own, which
13//! is why the generics landed together with
14//! [`checked_llm_call`](crate::typechecker) rather than ahead of it.
15//! [`is_checked_call`] is the hook that interception matches on.
16//!
17//! `T` defaults to `Completion` (or `Completion[]` for `batch`) rather than to
18//! `unknown`: the untyped call keeps its envelope, carrying `ok`/`text`/
19//! `reason`, while the typed form trades that envelope for the checked value —
20//! the same trade `session.get<T>` makes. That is why `T` appears only in the
21//! return type here and is never inferable from an argument.
22//!
23//! The trailing `schema` parameter is likewise not surface a program writes. It
24//! defaults to `undefined`, and the typed lowering fills it with the JSON Schema
25//! emitted from `T` at compile time — the way `McpCall` passes its `server` and
26//! `tool` as constants. Passing it by hand changes only what the provider is
27//! asked for; the structural check lives in the synthetic `Cast`, not in the
28//! host fn, so a hand-passed schema buys no verification.
29
30use std::collections::BTreeMap;
31
32use crate::{
33    Dispatch, PackageDeclaration, Param, PropertySig, Span, Type, TypeKind, TypeSymbol, ValueKind,
34    ValueSymbol,
35};
36
37use super::MODULE_NAME;
38
39/// The export names of the checked reads. Shared with the typechecker's
40/// interception so the declaration and the check that keeps it sound cannot
41/// drift apart.
42const LLM_CALL: &str = "call";
43const LLM_BATCH: &str = "batch";
44
45/// The type parameter of [`LLM_CALL`] and [`LLM_BATCH`].
46const CALL_TYPE_PARAM: &str = "T";
47
48/// The type parameter as a type, for the generic return positions.
49fn call_type_var() -> Type {
50    Type::TypeVar(CALL_TYPE_PARAM.to_string())
51}
52
53/// Whether `mangled` is the single-prompt checked read, as opposed to the
54/// batch. The two lower the same way but differ in what a bare call returns —
55/// `Completion` against `Completion[]` — so the interception needs to tell
56/// them apart to pick the untyped default.
57pub fn is_checked_batch(mangled: &crate::MangledName) -> bool {
58    *mangled == crate::mangle::package_symbol(MODULE_NAME, LLM_BATCH)
59}
60
61/// What `T` binds to when the program wrote no type argument, which keeps the
62/// untyped call's pre-generic meaning: the `Completion` envelope for `call`,
63/// and one per prompt for `batch`. Unlike `session.get`, the default is *not*
64/// `unknown` — an untyped `llm.call` is not an unchecked read, it is a
65/// different and fully-typed result.
66pub fn untyped_result_type(mangled: &crate::MangledName) -> Type {
67    if is_checked_batch(mangled) {
68        Type::Array(Box::new(completion_type()))
69    } else {
70        completion_type()
71    }
72}
73
74/// Whether `mangled` is one of this package's checked reads. Matching the
75/// package-export mangled name rather than a receiver name is what makes the
76/// typechecker's rewrite independent of how the symbol was imported —
77/// `llm.call`, `models.call` under an aliased namespace import, and a
78/// named-import `call` all carry this name, while a user's own `llm` binding
79/// never can.
80pub fn is_checked_call(mangled: &crate::MangledName) -> bool {
81    *mangled == crate::mangle::package_symbol(MODULE_NAME, LLM_CALL)
82        || *mangled == crate::mangle::package_symbol(MODULE_NAME, LLM_BATCH)
83}
84
85pub fn package_declaration() -> PackageDeclaration {
86    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
87    insert_completion_interface(&mut defs);
88    insert_model_interface(&mut defs);
89    insert_generic_fn(
90        &mut defs,
91        LLM_CALL,
92        vec![CALL_TYPE_PARAM.to_string()],
93        vec![
94            Param::new("model", Type::String),
95            Param::new("prompt", Type::String),
96            schema_param(),
97        ],
98        call_type_var(),
99        "/**\n * Send one prompt to `model` and return its completion.\n *\n * With a \
100         type argument — `call<Severity>(model, prompt)` — a JSON Schema for `T` is \
101         emitted at compile time and sent to the provider, and the response is then \
102         checked structurally against `T` field by field. A response that does not \
103         match throws a catchable `TypeError`; nothing is coerced. The schema is \
104         advisory and the check is not, so a provider that ignores the schema and \
105         answers in prose throws rather than returning a wrongly-typed value. The \
106         typed form returns `T` itself, trading the `Completion` envelope for the \
107         checked value, exactly as `session.get<T>` does.\n *\n * `T` must be a type \
108         the schema can carry, which is narrower than what `as` can check: no \
109         functions, no `bigint`, no `Uint8Array`, no `unknown`, and no recursive type \
110         — each is rejected at compile time, naming the offending field.\n *\n * Check \
111         `ok` before reading `text`: `ok` means the model stopped naturally, not \
112         merely that nothing threw. A completion cut off at the output cap is `ok: \
113         false` with `reason` `\"truncated\"` and still carries the partial `text`, \
114         so a loop that skips every `!ok` element discards usable output.\n *\n * \
115         That envelope is what the typed form gives up, so a typed call on a \
116         failed completion throws a `TypeError` naming the reason and pointing \
117         back at the untyped form — there is no `ok` left to branch on, and the \
118         partial `text` is reachable only without a type argument.\n *\n * \
119         Throws a `QuotaExceededError` when the \
120         execution or server token budget cannot cover the call, a `RangeError` for prompt bounds, and a \
121         catchable error naming the model when no provider is configured. No error \
122         ever quotes the prompt or the completion.\n * @param model Model name. Call \
123         `models()` for the ones this runtime serves; an undeclared name throws.\n * \
124         @param prompt The prompt text. Bounded in bytes independently of the token \
125         ceiling.\n * @capability llm.call { model: $model, \
126         prompt_count: 1 }\n */",
127    );
128    insert_generic_fn(
129        &mut defs,
130        LLM_BATCH,
131        vec![CALL_TYPE_PARAM.to_string()],
132        vec![
133            Param::new("model", Type::String),
134            Param::new("prompts", Type::Array(Box::new(Type::String))),
135            schema_param(),
136        ],
137        call_type_var(),
138        "/**\n * Send every prompt to `model` and return one completion each, \
139         positionally: `result[i]` is the outcome of `prompts[i]`, including when \
140         that element failed.\n *\n * Fan-out happens host-side at a bounded \
141         concurrency, so this is not sugar for a loop — a sequential version would \
142         misrepresent its own cost. One failing element never discards the ones that \
143         succeeded: each element carries its own `ok`, `reason`, and token counts.\n \
144         *\n * The whole slice is checked against the prompt-count and prompt-size \
145         bounds, and its tokens reserved, before any bytes leave the process — so an \
146         oversized batch is refused without partially dispatching.\n *\n * A type \
147         argument names the shape of the *whole result*, not of one element, so the \
148         typed form is `batch<Severity[]>(model, prompts)`. Every element is then \
149         checked structurally, and one non-conforming response throws for the batch \
150         rather than yielding a wrongly-typed element — which is why the untyped form, \
151         whose per-element `ok` survives a partial failure, is the right one when some \
152         prompts are expected to fail.\n * @param model Model name. \
153         Call `models()` for the ones this runtime serves.\n * @param prompts The \
154         prompts, in the order the results come back. Bounded in count, and each in \
155         bytes.\n * @capability llm.call { model: $model, \
156         prompt_count: $prompts.length }\n */",
157    );
158    insert_fn(
159        &mut defs,
160        "models",
161        Vec::new(),
162        Type::Array(Box::new(model_type())),
163        "/**\n * The models this runtime serves and this caller may call.\n *\n * \
164         Each candidate is filtered under `llm.call` by the same `model` \
165         filter that gates calling — so a listing never offers a model the caller \
166         would be denied at `call` time. The list can therefore come back short, or \
167         empty, and nothing in it reveals how many candidates were filtered out.\n \
168         *\n * Choosing a model is the delegation decision — a cheap model for a \
169         thousand classifications, a strong one for the synthesis — so branch on \
170         `contextWindow`, which is a fact you can compute against. Both \
171         `contextWindow` and `description` are `undefined` when the operator declared \
172         none; a program filtering on `contextWindow` drops those, which is correct \
173         for a chunk-size decision, because you should not size against a number \
174         nobody asserted.\n *\n * Treat `description` as advice, not fact: it is \
175         operator-authored free text that steers which model your program calls.\n * \
176         @capability llm.call { prompt_count: 0 }\n */",
177    );
178    defs
179}
180
181/// The compile-time schema slot. `undefined` on the untyped path; the typed lowering
182/// substitutes the JSON Schema emitted from `T`. Declared with a default so the
183/// arity a program writes stays two.
184fn schema_param() -> Param {
185    Param::with_default(
186        "schema",
187        Type::union(vec![Type::String, Type::Undefined]),
188        crate::DefaultValue::Undefined,
189    )
190}
191
192fn completion_type() -> Type {
193    Type::InterfaceRef {
194        mangled: crate::mangle::package_symbol(MODULE_NAME, "Completion"),
195        package: crate::Package(MODULE_NAME.to_string()),
196        name: "Completion".to_string(),
197        args: Vec::new(),
198    }
199}
200
201fn model_type() -> Type {
202    Type::InterfaceRef {
203        mangled: crate::mangle::package_symbol(MODULE_NAME, "Model"),
204        package: crate::Package(MODULE_NAME.to_string()),
205        name: "Model".to_string(),
206        args: Vec::new(),
207    }
208}
209
210fn optional_string() -> Type {
211    Type::union(vec![Type::String, Type::Undefined])
212}
213
214fn optional_number() -> Type {
215    Type::union(vec![Type::Number, Type::Undefined])
216}
217
218fn insert_completion_interface(defs: &mut PackageDeclaration) {
219    let mut properties = BTreeMap::new();
220    insert_property(
221        &mut properties,
222        "ok",
223        Type::Boolean,
224        "/** Whether the model stopped naturally. Not \"nothing threw\": a truncated or content-filtered completion is `false` and still carries `text`. */",
225    );
226    insert_property(
227        &mut properties,
228        "text",
229        optional_string(),
230        "/** The completion text. Always a string when `ok`; on the failure arm `undefined` only when the model produced none at all, so `\"\"` and `undefined` mean different things. */",
231    );
232    insert_property(
233        &mut properties,
234        "reason",
235        optional_string(),
236        "/** Why this element is not a clean completion, `undefined` when `ok`. One of `\"truncated\"`, `\"content-filtered\"`, `\"invalid-output\"`, `\"rate-limited\"`, `\"request-rejected\"`, `\"provider-unavailable\"`, `\"transport\"`, `\"cancelled\"`, `\"incomplete\"` — branch on this, not on `message`. */",
237    );
238    insert_property(
239        &mut properties,
240        "message",
241        optional_string(),
242        "/** A fixed classification of the failure, `undefined` when `ok`. Never the prompt, the completion, or a provider response body. */",
243    );
244    insert_property(
245        &mut properties,
246        "retryable",
247        Type::Boolean,
248        "/** Whether re-sending this identical prompt could plausibly succeed. `false` on the success arm, where there is nothing to retry. */",
249    );
250    insert_property(
251        &mut properties,
252        "status",
253        optional_number(),
254        "/** The HTTP status the provider answered with, `undefined` when none was observed — which is what distinguishes a dead connection from a provider that answered with an error. */",
255    );
256    insert_property(
257        &mut properties,
258        "finishReason",
259        optional_string(),
260        "/** The provider's own raw stop reason, for diagnosis only. Varies per provider and per SDK version — branch on `reason` instead. */",
261    );
262    insert_property(
263        &mut properties,
264        "inputTokens",
265        optional_number(),
266        "/** Prompt tokens the provider reported. `undefined` means indeterminate, not free: a throttled call may still have been billed, so it is never `0` as a stand-in for unknown. */",
267    );
268    insert_property(
269        &mut properties,
270        "outputTokens",
271        optional_number(),
272        "/** Completion tokens the provider reported, with the same `undefined`-means-indeterminate rule as `inputTokens`. */",
273    );
274    insert_interface(
275        defs,
276        "Completion",
277        properties,
278        "/** One prompt's outcome, positionally matched to the prompt that produced it. Success and failure are one shape rather than two, because a truncated or filtered completion is `ok: false` and still carries usable `text`. Constructed only by `call` and `batch`. */",
279    );
280}
281
282fn insert_model_interface(defs: &mut PackageDeclaration) {
283    let mut properties = BTreeMap::new();
284    insert_property(
285        &mut properties,
286        "name",
287        Type::String,
288        "/** The model name, exactly as `call` and `batch` expect it. */",
289    );
290    insert_property(
291        &mut properties,
292        "description",
293        optional_string(),
294        "/** Operator-authored deployment intent, or `undefined` when none was declared. Advice, not fact — it is free text that steers model selection, so weigh it after `contextWindow`. Always a single line, within a fixed length bound. */",
295    );
296    insert_property(
297        &mut properties,
298        "contextWindow",
299        optional_number(),
300        "/** The model's context window in tokens, or `undefined` when the operator declared none. Absent means unknown, not zero, and there is no fallback table — a program sizing chunks should drop a model rather than guess. */",
301    );
302    insert_interface(
303        defs,
304        "Model",
305        properties,
306        "/** One model this caller may call. Constructed only by `models()`, and the list carries nothing — no index, no position, no count — derived from the candidates the policy filtered out. */",
307    );
308}
309
310fn insert_interface(
311    defs: &mut PackageDeclaration,
312    name: &str,
313    properties: BTreeMap<String, PropertySig>,
314    doc: &str,
315) {
316    defs.types.insert(
317        name.to_string(),
318        TypeSymbol {
319            name: name.to_string(),
320            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
321            declaration_span: Span::at(crate::FileId::LLM),
322            kind: TypeKind::Interface {
323                index: None,
324                generics: Vec::new(),
325                methods: BTreeMap::new(),
326                properties,
327                dispatch: Dispatch::Direct,
328                doc: crate::doc(crate::FileId::LLM, doc),
329            },
330        },
331    );
332}
333
334fn insert_property(
335    properties: &mut BTreeMap<String, PropertySig>,
336    name: &str,
337    ty: Type,
338    doc: &str,
339) {
340    properties.insert(
341        name.to_string(),
342        PropertySig {
343            ty,
344            readonly: true,
345            optional: false,
346            intrinsic: false,
347            doc: crate::doc(crate::FileId::LLM, doc),
348        },
349    );
350}
351
352fn insert_fn(defs: &mut PackageDeclaration, name: &str, params: Vec<Param>, ret: Type, doc: &str) {
353    insert_generic_fn(defs, name, Vec::new(), params, ret, doc);
354}
355
356fn insert_generic_fn(
357    defs: &mut PackageDeclaration,
358    name: &str,
359    generics: Vec<String>,
360    params: Vec<Param>,
361    ret: Type,
362    doc: &str,
363) {
364    defs.values.insert(
365        name.to_string(),
366        ValueSymbol {
367            name: name.to_string(),
368            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
369            declaration_span: Span::at(crate::FileId::LLM),
370            kind: ValueKind::Function {
371                generics,
372                params,
373                ret,
374                type_predicate: None,
375                doc: crate::doc(crate::FileId::LLM, doc),
376            },
377        },
378    );
379}