Skip to main content

submilli_engine/stdlib/llm/
declaration.rs

1//! Type surface of `submilli:llm`.
2//!
3//! Two forms, one per meaning. `text` and `texts` return the completion text:
4//! they are plain functions, and a `schema` they take is the program's own,
5//! sent to the provider as written and never used to parse or check the
6//! answer. `call<T>` and `batch<T>` return a checked `T`: they are declared
7//! **generic** and **runtime-generic**, so like every generic function a call
8//! passes one `$TypeDescriptor` per type parameter in a trailing `$types`
9//! environment — the type's structural predicate, its name, its JSON Schema,
10//! and its TypeInfo reference (`codegen::runtime_descriptors`). The host sends
11//! the schema to the provider, parses each response as JSON and runs the
12//! predicate on it; a rejected value becomes an `ok: false` entry with
13//! `reason: "invalid-output"`. So the `value` of a `Success<T>` holds only
14//! what the predicate accepted, which is what lets the declared return type
15//! `Completion<T>` be trusted without a cast around the call. `T` has no
16//! default: it is written, or inferred from the expected type, and the call is
17//! typed either way. The typechecker gates it through its rows in the
18//! checked-call table (`stdlib::checked_call`; [`is_checked_call`] and
19//! [`is_checked_batch`] are its hooks), and codegen emits the schema per type;
20//! nothing about the descriptor is a parameter, so the written declaration is
21//! the whole public API.
22
23use std::collections::BTreeMap;
24
25use crate::{
26    Dispatch, MangledName, PackageDeclaration, Param, PropertySig, Span, Type, TypeKind,
27    TypeSymbol, ValueKind, ValueSymbol,
28};
29
30use super::MODULE_NAME;
31
32/// The export names of the checked reads. Shared with the typechecker's gate
33/// so the declaration and the check that keeps it sound cannot drift apart.
34const LLM_CALL: &str = "call";
35const LLM_BATCH: &str = "batch";
36/// The text forms: plain functions, no type argument, no check.
37const LLM_TEXT: &str = "text";
38const LLM_TEXTS: &str = "texts";
39
40/// The type parameter of [`LLM_CALL`] and [`LLM_BATCH`].
41const CALL_TYPE_PARAM: &str = "T";
42
43/// The type parameter as a type, for the generic return positions.
44fn call_type_var() -> Type {
45    Type::TypeVar(CALL_TYPE_PARAM.to_string())
46}
47
48// The checked reads, `call<T>` and `batch<T>`, are recognised by their
49// package-export mangled name rather than a receiver name, which is what makes
50// the typechecker's gate independent of how the symbol was imported —
51// `llm.call`, `models.call` under an aliased namespace import, and a
52// named-import `call` all carry this name, while a user's own `llm` binding
53// never can.
54
55/// Whether `mangled` is `call<T>`.
56pub fn is_checked_call(mangled: &MangledName) -> bool {
57    *mangled == crate::mangle::package_symbol(MODULE_NAME, LLM_CALL)
58}
59
60/// Whether `mangled` is `batch<T>`.
61pub fn is_checked_batch(mangled: &MangledName) -> bool {
62    *mangled == crate::mangle::package_symbol(MODULE_NAME, LLM_BATCH)
63}
64
65pub fn package_declaration() -> PackageDeclaration {
66    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
67    insert_success_interface(&mut defs);
68    insert_failure_interface(&mut defs);
69    insert_completion_alias(&mut defs);
70    insert_model_interface(&mut defs);
71    insert_fn(
72        &mut defs,
73        LLM_TEXT,
74        vec![
75            Param::new("model", Type::String),
76            Param::new("prompt", Type::String),
77            Param::with_default(
78                "schema",
79                Type::Union(vec![Type::String, Type::Undefined]),
80                crate::DefaultValue::Undefined,
81            ),
82        ],
83        completion_type(Type::String),
84        "/**\n * Send one prompt to `model` and return its completion text as a \
85         `Completion<string>`: `{ ok: true, value }` or `{ ok: false, reason, message }`. \
86         Check `ok` before reading `value`; the compiler will not let you read it \
87         otherwise.\n *\n * `schema`, when given, is a JSON Schema sent to the provider \
88         as written, to constrain the output. It must be a JSON object, else a catchable `TypeError`, and it is held to the same \
89         byte bound as a prompt. The answer is not parsed or checked against it: \
90         `value` is the text, and a program that wants a checked value calls \
91         `call<T>` instead, whose schema the compiler emits from `T`.\n *\n * \
92         `ok: false` means the model did not produce a clean completion; branch on \
93         `reason`, which is a closed set, not on `message`. A completion cut off at \
94         the output cap is `reason: \"truncated\"` and still carries the partial \
95         `text`, so a loop that only reads `ok` elements discards usable output.\n *\n \
96         * Throws a `QuotaExceededError` when the execution or server token budget \
97         cannot cover the call, a `RangeError` for prompt bounds, and a catchable \
98         error naming the model when no provider is configured.\n * @param model \
99         Model name. Call `models()` for the ones this runtime serves; an undeclared \
100         name throws.\n * @param prompt The prompt text. Bounded in bytes \
101         independently of the token ceiling.\n * @param schema A JSON Schema for the \
102         provider, sent unchanged; the result stays text.\n * @capability llm.call \
103         { model: $model, prompt_count: 1 }\n */",
104    );
105    insert_fn(
106        &mut defs,
107        LLM_TEXTS,
108        vec![
109            Param::new("model", Type::String),
110            Param::new("prompts", Type::Array(Box::new(Type::String))),
111            Param::with_default(
112                "schema",
113                Type::Union(vec![Type::String, Type::Undefined]),
114                crate::DefaultValue::Undefined,
115            ),
116        ],
117        Type::Array(Box::new(completion_type(Type::String))),
118        "/**\n * Send every prompt to `model` and return one `Completion<string>` each, \
119         positionally: `result[i]` is the text outcome of `prompts[i]`, including when \
120         that element failed. An empty `prompts` returns `[]`.\n *\n * Fan-out \
121         happens host-side at a bounded concurrency, so this is not sugar for a loop. \
122         One failing element never discards the ones that succeeded: each element \
123         carries its own `ok`, `reason` and token counts. `schema`, when given, goes \
124         to the provider with every prompt and never checks an answer; it must be a \
125         JSON object, and it is held to the per-prompt byte \
126         bound. For checked values call `batch<T>`.\n *\n * The whole slice is \
127         checked against the \
128         prompt-count and prompt-size bounds, and its tokens reserved, before any \
129         bytes leave the process.\n * @param model Model name. Call `models()` for \
130         the ones this runtime serves.\n * @param prompts The prompts, in the order \
131         the results come back. Bounded in count, and each in bytes.\n * @param \
132         schema A JSON Schema for the provider, sent unchanged; the results stay \
133         text.\n * @capability llm.call { model: $model, prompt_count: \
134         $prompts.length }\n */",
135    );
136    insert_generic_fn(
137        &mut defs,
138        LLM_CALL,
139        vec![CALL_TYPE_PARAM.to_string()],
140        vec![
141            Param::new("model", Type::String),
142            Param::new("prompt", Type::String),
143        ],
144        completion_type(call_type_var()),
145        "/**\n * Send one prompt to `model` and return its answer checked as a `T`: \
146         `{ ok: true, value }` or `{ ok: false, reason, message }`. Check `ok` before \
147         reading `value`; the compiler will not let you read it otherwise.\n *\n * \
148         `call<Verdict>(model, prompt)` emits a JSON Schema for `Verdict` at compile \
149         time and sends it to the provider; the response is parsed as JSON and \
150         checked field by field, and `value` is the checked `Verdict`. `T` is \
151         required: write it, or let the expected type supply it. For the completion \
152         text call `text(model, prompt)` instead. A response that \
153         is not JSON, or that does not match, is `ok: false` with `reason: \
154         \"invalid-output\"` and a `message` naming the expected type; nothing is \
155         coerced, and no error ever quotes the prompt or the completion.\n *\n * `T` \
156         must be a type the schema can carry: no functions, no \
157         `bigint`, no `Uint8Array`, no `unknown`, and no recursive type — each is \
158         rejected at compile time, naming the offending field. A generic may pass \
159         its own type parameter (`call<T>` inside `function f<T>`): the caller's \
160         type is asked for and checked, and one with no JSON Schema throws a \
161         catchable `TypeError` when the call runs.\n *\n * `ok: false` \
162         means the model did not produce a clean completion; branch on `reason`, \
163         which is a closed set, not on `message`. A completion cut off at the output \
164         cap is `reason: \"truncated\"` and carries no text: a typed `value` could \
165         never hold it.\n *\n * Throws a \
166         `QuotaExceededError` when the execution or server token budget cannot cover \
167         the call, a `RangeError` for prompt bounds, and a catchable error naming the \
168         model when no provider is configured.\n * @param model Model name. Call \
169         `models()` for the ones this runtime serves; an undeclared name throws.\n * \
170         @param prompt The prompt text. Bounded in bytes independently of the token \
171         ceiling.\n * @capability llm.call { model: $model, prompt_count: 1 }\n */",
172    );
173    insert_generic_fn(
174        &mut defs,
175        LLM_BATCH,
176        vec![CALL_TYPE_PARAM.to_string()],
177        vec![
178            Param::new("model", Type::String),
179            Param::new("prompts", Type::Array(Box::new(Type::String))),
180        ],
181        Type::Array(Box::new(completion_type(call_type_var()))),
182        "/**\n * Send every prompt to `model` and return one `Completion` each, \
183         positionally: `result[i]` is the outcome of `prompts[i]`, including when that \
184         element failed. An empty `prompts` returns `[]`.\n *\n * Fan-out happens \
185         host-side at a bounded concurrency, so this is not sugar for a loop — a \
186         sequential version would misrepresent its own cost. One failing element never \
187         discards the ones that succeeded: each element carries its own `ok`, `reason` \
188         and token counts.\n *\n * A type argument names the shape of *one* response: \
189         `batch<Verdict>(model, prompts)` returns `Completion<Verdict>[]`, each element \
190         parsed and checked on its own, so a response that is not JSON or does not \
191         match `Verdict` is `ok: false` with `reason: \"invalid-output\"` for that \
192         element only. `T` is required; for the completion text of each prompt call \
193         `texts(model, prompts)` instead.\n *\n * The whole slice is checked against the prompt-count and \
194         prompt-size bounds, and its tokens reserved, before any bytes leave the \
195         process — so an oversized batch is refused without partially dispatching.\n \
196         * @param model Model name. Call `models()` for the ones this runtime \
197         serves.\n * @param prompts The prompts, in the order the results come back. \
198         Bounded in count, and each in bytes.\n * @capability llm.call { model: \
199         $model, prompt_count: $prompts.length }\n */",
200    );
201    for name in [LLM_CALL, LLM_BATCH] {
202        defs.runtime_generics
203            .insert(crate::mangle::package_symbol(MODULE_NAME, name));
204    }
205    insert_fn(
206        &mut defs,
207        "models",
208        Vec::new(),
209        Type::Array(Box::new(model_type())),
210        "/**\n * The models this runtime serves and this caller may call.\n *\n * \
211         Each candidate is filtered under `llm.call` by the same `model` \
212         filter that gates calling — so a listing never offers a model the caller \
213         would be denied at `call` time. The list can therefore come back short, or \
214         empty, and nothing in it reveals how many candidates were filtered out.\n \
215         *\n * Choosing a model is the delegation decision — a cheap model for a \
216         thousand classifications, a strong one for the synthesis — so branch on \
217         `contextWindow`, which is a fact you can compute against. Both \
218         `contextWindow` and `description` are absent when the operator declared \
219         none; a program filtering on `contextWindow` drops those, which is correct \
220         for a chunk-size decision, because you should not size against a number \
221         nobody asserted.\n *\n * Treat `description` as advice, not fact: it is \
222         operator-authored free text that steers which model your program calls.\n * \
223         @capability llm.call { prompt_count: 0 }\n */",
224    );
225    defs
226}
227
228fn package() -> crate::Package {
229    crate::Package(MODULE_NAME.to_string())
230}
231
232/// `Completion<value>`: the declared alias, so diagnostics and hovers show the
233/// name a program wrote rather than the expanded union.
234fn completion_type(value: Type) -> Type {
235    Type::AliasRef {
236        mangled: crate::mangle::package_symbol(MODULE_NAME, "Completion"),
237        package: package(),
238        name: "Completion".to_string(),
239        args: vec![value],
240    }
241}
242
243fn success_type(value: Type) -> Type {
244    Type::InterfaceRef {
245        mangled: crate::mangle::package_symbol(MODULE_NAME, "Success"),
246        package: package(),
247        name: "Success".to_string(),
248        args: vec![value],
249    }
250}
251
252fn failure_type() -> Type {
253    Type::InterfaceRef {
254        mangled: crate::mangle::package_symbol(MODULE_NAME, "Failure"),
255        package: package(),
256        name: "Failure".to_string(),
257        args: Vec::new(),
258    }
259}
260
261fn model_type() -> Type {
262    Type::InterfaceRef {
263        mangled: crate::mangle::package_symbol(MODULE_NAME, "Model"),
264        package: package(),
265        name: "Model".to_string(),
266        args: Vec::new(),
267    }
268}
269
270/// The closed set of failure reasons, as the union of their spellings, taken
271/// from the runtime's own list so the two cannot drift.
272fn reason_type() -> Type {
273    Type::union(
274        crate::runtime::llm::FailureReason::ALL
275            .iter()
276            .map(|reason| Type::StringLiteral(reason.as_str().to_string()))
277            .collect(),
278    )
279}
280
281fn insert_success_interface(defs: &mut PackageDeclaration) {
282    let mut properties = BTreeMap::new();
283    insert_property(
284        &mut properties,
285        "ok",
286        Type::BooleanLiteral(true),
287        "/** `true`: the model stopped naturally and `value` holds its answer. */",
288    );
289    insert_property(
290        &mut properties,
291        "value",
292        call_type_var(),
293        "/** The completion text from `text`/`texts`, or from `call<T>`/`batch<T>` the parsed response, checked against `T` field by field. Only what passed the check is ever stored here. */",
294    );
295    insert_optional_property(
296        &mut properties,
297        "inputTokens",
298        Type::Number,
299        "/** Prompt tokens the provider reported. Absent means indeterminate, not free: a throttled call may still have been billed, so it is never `0` as a stand-in for unknown. */",
300    );
301    insert_optional_property(
302        &mut properties,
303        "outputTokens",
304        Type::Number,
305        "/** Completion tokens the provider reported, with the same absent-means-indeterminate rule as `inputTokens`. */",
306    );
307    insert_interface(
308        defs,
309        "Success",
310        vec![CALL_TYPE_PARAM.to_string()],
311        properties,
312        "/** The `ok: true` arm of a `Completion<T>`: the model stopped naturally and `value` is its answer — the text, or the checked `T`. */",
313    );
314}
315
316fn insert_failure_interface(defs: &mut PackageDeclaration) {
317    let mut properties = BTreeMap::new();
318    insert_property(
319        &mut properties,
320        "ok",
321        Type::BooleanLiteral(false),
322        "/** `false`: this element is not a clean completion. Not \"something threw\": a truncated or content-filtered completion is `ok: false` and may still carry `text`. */",
323    );
324    insert_property(
325        &mut properties,
326        "reason",
327        reason_type(),
328        "/** Why this element is not a clean completion. One of `\"truncated\"`, `\"content-filtered\"`, `\"invalid-output\"`, `\"rate-limited\"`, `\"request-rejected\"`, `\"provider-unavailable\"`, `\"transport\"`, `\"cancelled\"`, `\"incomplete\"` — branch on this, not on `message`. A typed call's response that is not JSON or does not match `T` is `\"invalid-output\"`. */",
329    );
330    insert_property(
331        &mut properties,
332        "message",
333        Type::String,
334        "/** A fixed classification of the failure, or for `\"invalid-output\"` the type the response did not match. Never the prompt, the completion, or a provider response body. */",
335    );
336    insert_property(
337        &mut properties,
338        "retryable",
339        Type::Boolean,
340        "/** Whether re-sending this identical prompt could plausibly succeed. */",
341    );
342    insert_optional_property(
343        &mut properties,
344        "status",
345        Type::Number,
346        "/** The HTTP status the provider answered with, absent when none was observed — which is what distinguishes a dead connection from a provider that answered with an error. */",
347    );
348    insert_optional_property(
349        &mut properties,
350        "finishReason",
351        Type::String,
352        "/** The provider's own raw stop reason, for diagnosis only. Varies per provider and per SDK version — branch on `reason` instead. */",
353    );
354    insert_optional_property(
355        &mut properties,
356        "text",
357        Type::String,
358        "/** The partial text of a truncated or filtered completion, absent when the model produced none at all. Usable output: a loop that skips every `!ok` element discards it. Only `text` and `texts` carry it: a checked `call<T>` never does. */",
359    );
360    insert_optional_property(
361        &mut properties,
362        "inputTokens",
363        Type::Number,
364        "/** Prompt tokens the provider reported for this element, absent when indeterminate. A failed element may still have been billed. */",
365    );
366    insert_optional_property(
367        &mut properties,
368        "outputTokens",
369        Type::Number,
370        "/** Completion tokens the provider reported for this element, absent when indeterminate. */",
371    );
372    insert_interface(
373        defs,
374        "Failure",
375        Vec::new(),
376        properties,
377        "/** The `ok: false` arm of a `Completion<T>`: why this element is not a clean completion, and the partial `text` when the model produced some. */",
378    );
379}
380
381fn insert_completion_alias(defs: &mut PackageDeclaration) {
382    defs.types.insert(
383        "Completion".to_string(),
384        TypeSymbol {
385            name: "Completion".to_string(),
386            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Completion"),
387            declaration_span: Span::at(crate::FileId::LLM),
388            kind: TypeKind::Alias {
389                generics: crate::GenericParameters::unconstrained(vec![CALL_TYPE_PARAM.to_string()]),
390                ty: Type::union(vec![success_type(call_type_var()), failure_type()]),
391                doc: crate::doc(
392                    crate::FileId::LLM,
393                    "/** One prompt's outcome: `Success<T>` when the model stopped naturally, `Failure` otherwise. Narrow on `ok` before reading `value`. `T` is `string` for `text` and `texts`. Constructed only by this package's calls, but a plain object of the same shape is one too. */",
394                ),
395            },
396        },
397    );
398}
399
400fn insert_model_interface(defs: &mut PackageDeclaration) {
401    let mut properties = BTreeMap::new();
402    insert_property(
403        &mut properties,
404        "name",
405        Type::String,
406        "/** The model name, exactly as `call` and `batch` expect it. */",
407    );
408    insert_optional_property(
409        &mut properties,
410        "description",
411        Type::String,
412        "/** Operator-authored deployment intent, absent 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. */",
413    );
414    insert_optional_property(
415        &mut properties,
416        "contextWindow",
417        Type::Number,
418        "/** The model's context window in tokens, absent 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. */",
419    );
420    insert_interface(
421        defs,
422        "Model",
423        Vec::new(),
424        properties,
425        "/** 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. */",
426    );
427}
428
429/// A data-only interface whose values are ordinary objects: no methods, no
430/// host getters, so property reads, `Object.keys`, `as Record<string, unknown>`
431/// and structural casts all go through the shared object shape.
432fn insert_interface(
433    defs: &mut PackageDeclaration,
434    name: &str,
435    generics: Vec<String>,
436    properties: BTreeMap<String, PropertySig>,
437    doc: &str,
438) {
439    defs.types.insert(
440        name.to_string(),
441        TypeSymbol {
442            name: name.to_string(),
443            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
444            declaration_span: Span::at(crate::FileId::LLM),
445            kind: TypeKind::Interface {
446                index: None,
447                generics: crate::GenericParameters::unconstrained(generics),
448                methods: BTreeMap::new(),
449                properties,
450                dispatch: Dispatch::VTable,
451                doc: crate::doc(crate::FileId::LLM, doc),
452            },
453        },
454    );
455}
456
457fn insert_property(
458    properties: &mut BTreeMap<String, PropertySig>,
459    name: &str,
460    ty: Type,
461    doc: &str,
462) {
463    properties.insert(
464        name.to_string(),
465        PropertySig {
466            ty,
467            readonly: true,
468            optional: false,
469            intrinsic: false,
470            doc: crate::doc(crate::FileId::LLM, doc),
471        },
472    );
473}
474
475/// An optional property is an absent key when there is no value, so
476/// `Object.keys` lists exactly what `JSON.stringify` writes.
477fn insert_optional_property(
478    properties: &mut BTreeMap<String, PropertySig>,
479    name: &str,
480    ty: Type,
481    doc: &str,
482) {
483    properties.insert(
484        name.to_string(),
485        PropertySig {
486            ty,
487            readonly: true,
488            optional: true,
489            intrinsic: false,
490            doc: crate::doc(crate::FileId::LLM, doc),
491        },
492    );
493}
494
495fn insert_fn(defs: &mut PackageDeclaration, name: &str, params: Vec<Param>, ret: Type, doc: &str) {
496    insert_generic_fn(defs, name, Vec::new(), params, ret, doc);
497}
498
499fn insert_generic_fn(
500    defs: &mut PackageDeclaration,
501    name: &str,
502    generics: Vec<String>,
503    params: Vec<Param>,
504    ret: Type,
505    doc: &str,
506) {
507    defs.values.insert(
508        name.to_string(),
509        ValueSymbol {
510            name: name.to_string(),
511            mangled_name: crate::mangle::package_symbol(MODULE_NAME, name),
512            declaration_span: Span::at(crate::FileId::LLM),
513            kind: ValueKind::Function {
514                generics: crate::GenericParameters::unconstrained(generics),
515                params,
516                ret,
517                type_predicate: None,
518                doc: crate::doc(crate::FileId::LLM, doc),
519            },
520        },
521    );
522}