diff --git a/baml_language/.cargo/size-gate.toml b/baml_language/.cargo/size-gate.toml index a705b2fad21..dd777502da4 100644 --- a/baml_language/.cargo/size-gate.toml +++ b/baml_language/.cargo/size-gate.toml @@ -55,14 +55,17 @@ max_file_bytes = "22.5 MiB" kind = "pack" policy.max_delta_pct = 3.0 +# Bumped ~3% above the Aug 2026 baselines when the builtin ai/provider +# stdlib packages landed (PR #4352): the packed program carries their +# bytecode (~700 KB). [artifacts.packed-program.platform.aarch64-apple-darwin] -max_file_bytes = "13.6 MiB" +max_file_bytes = "14.3 MiB" [artifacts.packed-program.platform.x86_64-unknown-linux-gnu] -max_file_bytes = "17.4 MiB" +max_file_bytes = "18.3 MiB" [artifacts.packed-program.platform.x86_64-pc-windows-msvc] -max_file_bytes = "14.5 MiB" +max_file_bytes = "15.3 MiB" [artifacts.packed-program.pack] cli_package = "baml_cli" diff --git a/baml_language/.ci/size-gate/aarch64-apple-darwin.toml b/baml_language/.ci/size-gate/aarch64-apple-darwin.toml index 4f241e3cb9e..d452fbb29b9 100644 --- a/baml_language/.ci/size-gate/aarch64-apple-darwin.toml +++ b/baml_language/.ci/size-gate/aarch64-apple-darwin.toml @@ -8,5 +8,5 @@ stripped_bytes = "20.3 MiB" gzip_bytes = "9.7 MiB" [artifacts.packed-program] -file_bytes = "13.2 MiB" -gzip_bytes = "6.1 MiB" +file_bytes = "13.8 MiB" +gzip_bytes = "6.3 MiB" diff --git a/baml_language/.ci/size-gate/x86_64-pc-windows-msvc.toml b/baml_language/.ci/size-gate/x86_64-pc-windows-msvc.toml index 495596ed2be..f83a294262c 100644 --- a/baml_language/.ci/size-gate/x86_64-pc-windows-msvc.toml +++ b/baml_language/.ci/size-gate/x86_64-pc-windows-msvc.toml @@ -8,5 +8,5 @@ stripped_bytes = "21.9 MiB" gzip_bytes = "9.9 MiB" [artifacts.packed-program] -file_bytes = "14.1 MiB" -gzip_bytes = "6.2 MiB" +file_bytes = "14.8 MiB" +gzip_bytes = "6.4 MiB" diff --git a/baml_language/.ci/size-gate/x86_64-unknown-linux-gnu.toml b/baml_language/.ci/size-gate/x86_64-unknown-linux-gnu.toml index 25e5a3177f2..92b58af37d5 100644 --- a/baml_language/.ci/size-gate/x86_64-unknown-linux-gnu.toml +++ b/baml_language/.ci/size-gate/x86_64-unknown-linux-gnu.toml @@ -8,5 +8,5 @@ stripped_bytes = "26.1 MiB" gzip_bytes = "11.1 MiB" [artifacts.packed-program] -file_bytes = "16.9 MiB" -gzip_bytes = "7.0 MiB" +file_bytes = "17.7 MiB" +gzip_bytes = "7.2 MiB" diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index a5e9951c93d..413fb0a0b35 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -6811,6 +6811,7 @@ version = "0.0.0-beta" dependencies = [ "baml_base", "baml_codegen_types", + "base64", ] [[package]] diff --git a/baml_language/crates/baml_base/src/core_types.rs b/baml_language/crates/baml_base/src/core_types.rs index e57327a8d04..ab1ad7255f9 100644 --- a/baml_language/crates/baml_base/src/core_types.rs +++ b/baml_language/crates/baml_base/src/core_types.rs @@ -180,7 +180,7 @@ impl TypePath { Self(vec![name]) } - /// Build a `TypePath` from a compile-time dotted literal like `"baml.llm.Client"`. + /// Build a `TypePath` from a compile-time dotted literal like `"ai.Prompt"`. /// Use only at synthetic construction sites; runtime input should come from /// already-segmented data (e.g., parser tokens). pub fn from_dotted(s: &str) -> Self { diff --git a/baml_language/crates/baml_base/src/qualified_name.rs b/baml_language/crates/baml_base/src/qualified_name.rs index cdb55439cf1..90514a5f8e3 100644 --- a/baml_language/crates/baml_base/src/qualified_name.rs +++ b/baml_language/crates/baml_base/src/qualified_name.rs @@ -12,7 +12,7 @@ use crate::Name; /// Prefix used for standard library items in qualified names. /// /// All `Namespace::BamlStd` and standard `Namespace::Builtin` items -/// are displayed with this prefix (e.g., `baml.llm.call_llm_function`). +/// are displayed with this prefix (e.g., `baml.prompt.render_output_format`). pub const BAML_STD_PREFIX: &str = "baml."; /// FQN (as a display string) of the recursive `json` type alias defined in @@ -22,6 +22,14 @@ pub const BAML_STD_PREFIX: &str = "baml."; /// recursion for output-format rendering, SAP streaming, and Python codegen. pub const BAML_JSON_JSON: &str = "baml.json.json"; +/// Canonical host-facing typed AI stream. Compiler synthesis, engine handle +/// lifting, and SDK generators must share this identity rather than carrying +/// independent string literals. +pub const AI_STREAM_STREAM: &str = "ai.stream.Stream"; + +/// Canonical nominal end-of-stream marker used by AI streams. +pub const AI_STREAM_DONE: &str = "ai.stream.Done"; + /// A qualified name that unambiguously identifies an item. /// /// Combines a namespace (where the item lives) with a name (what the item diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/journal.baml b/baml_language/crates/baml_builtins2/baml_std/ai/journal.baml new file mode 100644 index 00000000000..0d6b5896f1d --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/journal.baml @@ -0,0 +1,33 @@ +// The Journal: the append-only typed record of one run. +// +// The event types themselves live in root.events, and the content blocks they +// carry live in root.content. Journal stays at the package root because it is +// one of the nine names the BEP's flow diagram names directly. +// +// Mirrors _planv2/pages/02_guides/04_the_journal.md. + +// The COMPLETE record. Repair attempts, feedback, failures — everything that +// happened is an event. +class Journal { + log: root.events.Event[], + + function entries(self) -> root.events.Event[] { + self.log + } + + // A journal for one run of a spec, with RunStarted appended. + function new(spec: FunctionSpec) -> Journal { + Journal { + log: [ + root.events.RunStarted { spec_name: spec.name(), arguments: spec.arguments() }, + ], + } + } + + // The driving runner is the only writer; clients and tools never append. + function append_all(self, events: root.events.Event[]) -> void { + for (let e in events) { + self.log.push(e); + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml new file mode 100644 index 00000000000..57b523aeed8 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml @@ -0,0 +1,190 @@ +// Reliability composes at the client boundary: wrapper clients. +// Mirrors _planv2/pages/02_guides/03_clients/04_reliability.md. + +// The default judgment: a request the provider rejected does not become +// valid by resending. +function default_retry_judgment(f: root.errors.Failure) -> bool { + match (f) { + let r: root.errors.Refused => false, + let i: root.errors.InvalidRequest => false, + let p: root.errors.ParseFailed => false, + _ => true, + } +} + +class Backoff { + initial_ms: int, + multiplier: int, + max_ms: int, + + function new(initial_ms: int = 250, multiplier: int = 2, max_ms: int = 10000) -> Backoff { + Backoff { initial_ms: initial_ms, multiplier: multiplier, max_ms: max_ms } + } + + // The delay before attempt N (first retry is attempt 1). A + // root.errors.RateLimited.retry_after_ms hint overrides the computed value. + function delay_ms(self, attempt: int, f: root.errors.Failure) -> int { + let hinted = match (f) { + let r: root.errors.RateLimited => r.retry_after_ms, + _ => null, + }; + if let h: int = hinted { + h + } else { + let d = self.initial_ms; + let i = 1; + while (i < attempt) { + d = d * self.multiplier; + i = i + 1; + } + if (d > self.max_ms) { + self.max_ms + } else { + d + } + } + } +} + +class Retry { + inner: root.Client, + max_attempts: int, + retry_if: (root.errors.Failure) -> bool throws never, + backoff: Backoff, + + function new( + inner: root.Client, + max_attempts: int = 3, + retry_if: ((root.errors.Failure) -> bool throws never)? = null, + backoff: Backoff? = null, + ) -> Retry { + Retry { + inner: inner, + max_attempts: max_attempts, + retry_if: retry_if ?? default_retry_judgment, + backoff: backoff ?? Backoff.new(), + } + } + + function _attempt(self, input: root.ModelTurnInput, remaining: int) -> root.ModelTurn { + self.inner.invoke(input) catch_all (e) { + let f: root.errors.Failure => { + // the safety gate is not overridable; the judgment is + if ( + remaining > 1 + && f.retry_safety() == root.errors.RetrySafety.Safe + && self.retry_if(f) + ) { + let attempt = self.max_attempts - remaining + 1; + let _ = baml.sys.sleep( + baml.time.Duration.from_milliseconds(self.backoff.delay_ms(attempt, f)), + ) catch_all (se) { + _ => null, + }; + self._attempt(input, remaining - 1) + } else { + throw e + } + }, + _ => throw e, // untyped failures are never retried + } + } + + implements root.Client { + function id(self) -> string { + self.inner.id() + } + + function invoke(self, input: root.ModelTurnInput) -> root.ModelTurn { + self._attempt(input, self.max_attempts) catch_all (e) { + _ => throw root.errors.normalize(e), + } + } + } +} + +// Rotates invocations across members. Replaces the legacy round-robin +// provider: distribution is a client-boundary concern, exactly like Retry +// and Fallback. The counter mutates on the shared value, so every call +// site holding this client participates in one rotation. +class RoundRobin { + members: root.Client[], + counter: int, + + function new(members: root.Client[]) -> RoundRobin { + if (members.length() == 0) { + throw baml.errors.InvalidArgument { message: "RoundRobin needs at least one member" } + } + RoundRobin { members: members, counter: 0 } + } + + implements root.Client { + function id(self) -> string { + if let c: root.Client = self.members.at(0) { + c.id() + } else { + "round-robin" + } + } + + function invoke(self, input: root.ModelTurnInput) -> root.ModelTurn { + { + let index = self.counter % self.members.length(); + self.counter = self.counter + 1; + match (self.members.at(index)) { + let c: root.Client => c.invoke(input), + null => { + throw baml.errors.InvalidArgument { message: "RoundRobin needs at least one member" } + }, + } + } catch_all (e) { + _ => throw root.errors.normalize(e), + } + } + } +} + +class Fallback { + members: root.Client[], + + function _try(self, input: root.ModelTurnInput, index: int) -> root.ModelTurn { + match (self.members.at(index)) { + let c: root.Client => { + c.invoke(input) catch_all (e) { + let f: root.errors.Failure => { + if ( + index + 1 < self.members.length() + && f.retry_safety() == root.errors.RetrySafety.Safe + ) { + self._try(input, index + 1) + } else { + throw e + } + }, + _ => throw e, + } + }, + null => { + throw baml.errors.InvalidArgument { message: "Fallback needs at least one member" } + }, + } + } + + implements root.Client { + function id(self) -> string { + // NOTE: `?.method()` on an interface-typed optional trips a VM bug; + // if-let is the reliable form. + if let c: root.Client = self.members.at(0) { + c.id() + } else { + "fallback" + } + } + + function invoke(self, input: root.ModelTurnInput) -> root.ModelTurn { + self._try(input, 0) catch_all (e) { + _ => throw root.errors.normalize(e), + } + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_content/content.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_content/content.baml new file mode 100644 index 00000000000..63f2762e5ea --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_content/content.baml @@ -0,0 +1,32 @@ +// Canonical assistant content: what a model turn produced, normalized away +// from any one provider's envelope. Clients build these; the journal records +// them inside root.events.AssistantMessage. +// +// Mirrors _planv2/pages/04_reference/01_api.md (the `ai.content` namespace). + +// A model turn is structured blocks, never a bare string and never an HTTP +// envelope. +class Text { + text: string, +} + +class Reasoning { + summary: string, +} + +class ToolUse { + id: string, + name: string, + args: map, +} + +type Block = Text | Reasoning | ToolUse; + +// Why the model stopped. `ToolUse` means the turn ended with tool calls the +// runner must execute before the next turn. +enum StopReason { + Complete, + ToolUse, + MaxTokens, + Refused, +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_errors/errors.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_errors/errors.baml new file mode 100644 index 00000000000..d7ee64f3116 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_errors/errors.baml @@ -0,0 +1,137 @@ +// The failure taxonomy. Errors carry facts; callers make judgments. +// Mirrors _planv2/pages/04_reference/03_errors.md. + +enum RetrySafety { + Safe, // failed before anything could commit + Unknown, // request sent, outcome never observed + Unsafe, // effects happened; never blind-replay +} + +interface Failure { + function retry_safety(self) -> RetrySafety throws never +} + +// Close an untyped error channel at an ai boundary. Classified failures and +// already-normalized unknown errors retain their identity; everything else is +// captured in the standard untyped wrapper. +function normalize(error: unknown) -> Failure | baml.errors.UnknownError throws never { + match (error) { + let failure: Failure => failure, + let unknown_error: baml.errors.UnknownError => unknown_error, + _ => baml.errors.UnknownError { data: error, message: [error.to_string()] }, + } +} + +class RateLimited { + provider: string, + retry_after_ms: int?, + raw_body: string?, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +class NetworkFailure { + provider: string, + detail: string, + raw_body: string?, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +class InvalidRequest { + provider: string, + status_code: int?, + detail: string, + raw_body: string?, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +class Refused { + provider: string, + reason: string, + raw_body: string?, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +class ParseFailed { + provider: string, + raw_output: string, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +// The runner hit its step budget. Unlike the client-layer failures above, +// this is thrown AFTER turns were committed to the journal — and any tools +// those turns called have already run. Local state advanced, so a blind +// replay is not safe. +class StepBudgetExceeded { + steps: int, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Unsafe + } + } +} + +// A tool failed and the run was configured to raise. Thrown after the +// ToolFailed event is on the record, and a tool is arbitrary user code that +// may have committed effects before it failed — so this is never blind- +// replayable, whatever the underlying `cause` reports. +class ToolFailedError { + id: string, + name: string, + message: string, + cause: Failure?, // the classified original, when the tool threw one + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Unsafe + } + } +} + +// The selected client cannot stream: it does not implement +// ai.StreamingClient. Thrown by ai.from_spec when the spec's default +// client lacks the capability (an explicit override is caught at the call +// site by its StreamingClient type). +class StreamingUnsupported { + client_id: string, + implements Failure { + function retry_safety(self) -> RetrySafety { + RetrySafety.Safe + } + } +} + +// HTTP status -> classified failure. The classifier attaches the body it +// received as raw_body, so a failure always carries what the provider said. +function classify_http(provider: string, status_code: int, body: string) -> Failure { + if (status_code == 429) { + RateLimited { provider: provider, retry_after_ms: null, raw_body: body } + } else if (status_code == 408 || status_code >= 500) { + NetworkFailure { provider: provider, detail: `http ${status_code}`, raw_body: body } + } else { + InvalidRequest { + provider: provider, + status_code: status_code, + detail: `http ${status_code}`, + raw_body: body, + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_events/events.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_events/events.baml new file mode 100644 index 00000000000..a8df375e5c4 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_events/events.baml @@ -0,0 +1,54 @@ +// The event catalog: the typed things that can appear in a root.Journal. +// Only the runner produces events — clients and tools never append. +// +// Mirrors _planv2/pages/04_reference/02_events.md. + +class RunStarted { + spec_name: string, + arguments: map, +} + +class UserMessage { + content: string, +} + +class AssistantMessage { + content: root.content.Block[], + client_id: string, +} + +class ToolRequested { + id: string, + name: string, + args: map, +} + +class ToolCompleted { + id: string, + output: string, // the tool's result, serialized as JSON text +} + +class ToolFailed { + id: string, + message: string, +} + +class Usage { + input_tokens: int, + output_tokens: int, + cached_input_tokens: int?, + reasoning_tokens: int?, +} + +class FinalProduced { + value_json: string, +} + +type Event = RunStarted + | UserMessage + | AssistantMessage + | ToolRequested + | ToolCompleted + | ToolFailed + | Usage + | FinalProduced; diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_internal/helpers.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_internal/helpers.baml new file mode 100644 index 00000000000..a453d603c48 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_internal/helpers.baml @@ -0,0 +1,34 @@ +// Internal helpers. Nothing here is part of the public ai surface. + +// One JSON Schema object for a function's parameters, derived from the +// signature: every parameter typed via baml.json.schema. +function _schema_for_signature(handler: baml.AnyFunction) -> map { + let sig = reflect.signature(handler); + let props: map = {}; + let required: string[] = []; + for (let a in sig.args) { + let _ = props.set(a.name, baml.json.schema(a.type)); + required.push(a.name); + } + { + "type": "object", + "properties": props, + "required": required, + "additionalProperties": false, + } +} + +// Bound as `ctx` inside a compiler-desugared `@spec` prompt template. +// `output_format` is the one placeholder the BEP allows. +class SpecCtx { + output_format: string, +} + +/// Assemble a structural `ai.Prompt` from a tagged template's flattened +/// `parts` and `values`. `baml.prompt.Role` values start new messages; media +/// remains structural until a provider lowers the prompt. +//baml:mut_vm +//baml:may_yield +function assemble_prompt(parts: string[], values: unknown[]) -> root.Prompt throws never { + $rust_function +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_mcp/mcp.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_mcp/mcp.baml new file mode 100644 index 00000000000..baf8df13c6d --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_mcp/mcp.baml @@ -0,0 +1,210 @@ +// mcp — MCP servers as ordinary BAML tools. A library over public +// primitives (baml.sys.start_process, root.raw_tool), not part of +// ai: the core namespace carries no protocol knowledge. +// +// One McpConnection is one server child process speaking JSON-RPC 2.0 over +// stdio, one message per line. tools() projects the server's catalog into +// root.Tool values whose handlers proxy tools/call, so MCP calls are +// executed by the runner, journaled as ToolRequested/ToolCompleted, and +// visible to on_event — with any client. +// Mirrors _planv2/pages/03_how_to/06_use_mcp_tools_with_any_client.md. + +// A tools/call proxy bound to one tool name. A helper function rather than +// an inline closure in the tools() loop: a closure in a loop captures the +// loop variable's slot, so every tool would proxy the last catalog entry; +// a parameter is a fresh binding per call. +function _proxy( + conn: McpConnection, + name: string, +) -> (map) -> string throws unknown { + (args: map) -> { + conn.call_tool(name, args) + } +} + +// One JSON-RPC message as one line. Pure; tested with literal values. +function rpc_line(id: int?, method: string, params: map?) -> string { + let m: map = { + "jsonrpc": "2.0", + "method": method, + }; + if let i: int = id { + let _ = m.set("id", i); + } + if let p: map = params { + let _ = m.set("params", p); + } + baml.json.stringify(baml.json.to_json(m)) +} + +class McpConnection { + server_name: string, + p: baml.sys.Process, + next_id: int, + + // Spawn the server and perform the initialize handshake. The connection + // is live until close(); the caller owns the lifetime. + function connect( + name: string, + command: string, + args: string[], + timeout_ms: int = 30000, + ) -> McpConnection { + let p = baml.sys.start_process( + command, + args, + baml.sys.ProcessOptions { + cwd: null, + env: null, + timeout_ms: timeout_ms, + stdin: null, + keep_stdin_open: true, + }, + ) catch_all (e) { + _ => { + throw root.errors.NetworkFailure { + provider: `mcp/${name}`, + detail: `the MCP server could not start: ${e.to_string()}`, + raw_body: null, + } + }, + }; + let conn = McpConnection { server_name: name, p: p, next_id: 1 }; + let init: map = { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "baml-mcp", + "version": "0.1", + }, + }; + let _ = conn._request("initialize", init); + conn._send(rpc_line(null, "notifications/initialized", null)); + conn + } + + function _send(self, line: string) -> null { + // Template literals trim trailing whitespace, so `${line}\n` would + // lose the newline the protocol frames on; the newline is a quoted + // string, appended before the single write. + let framed = [line, "\n"].join(""); + let _ = self.p.write_stdin(framed) catch_all (e) { + _ => { + throw root.errors.NetworkFailure { + provider: `mcp/${self.server_name}`, + detail: `writing to the MCP server failed: ${e.to_string()}`, + raw_body: null, + } + }, + }; + null + } + + // One request/response round-trip. Server notifications and unrelated + // ids are skipped; a JSON-RPC error object throws. + function _request(self, method: string, params: map) -> json { + let id = self.next_id; + self.next_id += 1; + self._send(rpc_line(id, method, params)); + while (true) { + let next = self.p.stdout.next() catch_all (e) { + _ => { + throw root.errors.NetworkFailure { + provider: `mcp/${self.server_name}`, + detail: `reading from the MCP server failed: ${e.to_string()}`, + raw_body: null, + } + }, + }; + match (next) { + let line: string => { + let trimmed = line.trim(); + if (trimmed.length() > 0) { + // stdout is protocol-only per the spec; tolerate stray lines + let msg = baml.json.parse(trimmed) catch_all (e) { + _ => null, + }; + if (baml.json.path_or(msg, ".id", -1) == id) { + let err = baml.json.path_or(msg, ".error", null); + if (err != null) { + throw root.errors.InvalidRequest { + provider: `mcp/${self.server_name}`, + status_code: baml.json.path_or(err, ".code", null), + detail: baml.json.path_or(err, ".message", "MCP error"), + raw_body: baml.json.stringify(err), + } + } + return baml.json.path_or(msg, ".result", null); + } + } + }, + _ => { + throw root.errors.NetworkFailure { + provider: `mcp/${self.server_name}`, + detail: `the MCP server stream ended before answering ${method}`, + raw_body: null, + } + }, + }; + } + // unreachable: the loop returns on a matching id or throws at EOF + throw root.errors.NetworkFailure { + provider: `mcp/${self.server_name}`, + detail: `no response for ${method}`, + raw_body: null, + } + } + + // The server's catalog as ordinary tools. Each tool's inputSchema is + // already JSON Schema, so it carries into Tool.input_schema unchanged; + // the handler proxies tools/call over this connection. + function tools(self) -> root.tools.Tool[] { + let result = self._request("tools/list", {}); + let out: root.tools.Tool[] = []; + for (let t in baml.json.path_or(result, ".tools", [])) { + let tool_name = baml.json.path_or(t, ".name", ""); + let schema = baml.json.from_json>( + baml.json.path_or(t, ".inputSchema", {}), + ) catch_all (e) { + _ => { + let empty: map = {}; + empty + }, + }; + out.push( + root.tools.raw_tool( + tool_name, + baml.json.path_or(t, ".description", ""), + schema, + _proxy(self, tool_name), + ), + ); + } + out + } + + // One tools/call. Text content items join as the tool's result; an + // isError result throws, which the runner journals as ToolFailed. + function call_tool(self, name: string, args: map) -> string { + let params: map = { + "name": name, + "arguments": args, + }; + let result = self._request("tools/call", params); + let texts: string[] = []; + for (let item in baml.json.path_or(result, ".content", [])) { + if (baml.json.path_or(item, ".type", "") == "text") { + texts.push(baml.json.path_or(item, ".text", "")); + } + } + let joined = texts.join("\n"); + if (baml.json.path_or(result, ".isError", false)) { + throw baml.errors.InvalidArgument { message: `mcp tool ${name} failed: ${joined}` } + } + joined + } + + function close(self) -> null { + self.p.close() + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_stream/stream.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_stream/stream.baml new file mode 100644 index 00000000000..4bde25cc2f7 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_stream/stream.baml @@ -0,0 +1,349 @@ +// Streaming: the raw text-delta stream of one model turn (TurnStream), the +// typed partial-value stream returned to BAML and host SDKs (Stream), and the +// one-turn streaming runner (from_spec). Streaming is a client capability: a +// client that can stream implements StreamingClient; `Fn$stream` companions +// call from_spec. +// +// Phase 1 scope: one model turn, no tool loop. A spec whose toolbox is not +// empty is rejected with baml.errors.InvalidArgument. + +// A client that can render the same request as `invoke` but streamed. +// Implementors must also implement Client (`requires`). +interface StreamingClient requires root.Client { + function invoke_stream(self, input: root.ModelTurnInput) -> TurnStream throws unknown +} + +// One decoded raw SSE event, as produced by `baml.http.SseStream.next()`: +// the batch string is a JSON array of these objects. +class SseEvent { + event: string?, + data: string?, + id: string?, +} + +// One `SseStream.next()` batch -> the raw SSE events it contains. Provider +// decoders parse each event's `data` payload with their own envelope classes. +function decode_sse_batch(batch: string) -> SseEvent[] { + baml.json.from_string(batch) +} + +// The provider-agnostic events a decoder yields from one SSE batch. + +// One incremental piece of assistant text. The delta contract: the chunk is +// NOT the accumulated text so far, it is only the newly arrived piece. +class TextDelta { + text: string, +} + +// Turn metadata as it arrives on the wire. Providers send stop_reason and +// token counts on different events; each TurnMeta carries what its event had +// and TurnStream folds non-null fields together (last write wins). +class TurnMeta { + stop_reason: root.content.StopReason?, + input_tokens: int?, + output_tokens: int?, +} + +// The provider's explicit end-of-turn marker. Optional: a stream that simply +// ends (SSE close) also completes the turn. +class TurnDone {} + +type StreamEvent = TextDelta | TurnMeta | TurnDone; + +// The single end-of-stream marker returned by both raw turn streams and typed +// function streams. A nominal sentinel is required because null may itself be +// a valid partial value. +class Done {} + +// The raw text-delta stream of ONE model turn. Pull-based: nothing is read +// from the wire until `next()` or `final_turn()` is called. +// +// `next()` yields DELTA chunks (each item is only the newly arrived text, +// never the accumulated prefix) and returns Done when the turn is over. +// `final_turn()` drains the rest of the stream and returns the completed +// turn: the accumulated text as one Text block, the folded stop_reason, and +// usage where the wire provided token counts. +class TurnStream { + // wire-backed streams: the SSE connection plus a per-provider decoder + // that turns one `SseStream.next()` batch into StreamEvents. + _sse: baml.http.SseStream?, + _decode: ((string) -> StreamEvent[] throws unknown)?, + // scripted streams (tests): literal delta chunks, no network. + _chunks: string[], + _cursor: int, + // decoded events not yet delivered. + _pending: StreamEvent[], + _pcursor: int, + // folded state. + _text: string, + _stop_reason: root.content.StopReason?, + _input_tokens: int?, + _output_tokens: int?, + _done: bool, + + // A turn stream over a live SSE connection. `decode` receives the raw + // batch string from `sse.next()` (a JSON array of SseEvent objects; see + // decode_sse_batch) and yields the events it contains. + function from_sse( + sse: baml.http.SseStream, + decode: (string) -> StreamEvent[] throws unknown, + ) -> TurnStream { + TurnStream { + _sse: sse, + _decode: decode, + _chunks: [], + _cursor: 0, + _pending: [], + _pcursor: 0, + _text: "", + _stop_reason: null, + _input_tokens: null, + _output_tokens: null, + _done: false, + } + } + + // A turn stream fed from literal delta chunks — the scripted counterpart + // of a live stream, for tests. The turn completes as StopReason.Complete + // after the last chunk. + function from_chunks(chunks: string[]) -> TurnStream { + TurnStream { + _sse: null, + _decode: null, + _chunks: chunks, + _cursor: 0, + _pending: [], + _pcursor: 0, + _text: "", + _stop_reason: null, + _input_tokens: null, + _output_tokens: null, + _done: false, + } + } + + function _finish(self) -> null { + self._done = true; + if let sse: baml.http.SseStream = self._sse { + sse.close() + } else { + null + } + } + + // The next text DELTA, or Done when the turn is over. Metadata events are + // folded silently; calling next() after Done keeps returning Done. + function next(self) -> string | Done { + while (true) { + if (self._done) { + return Done {}; + } + // deliver decoded events before pulling more from the wire + if (self._pcursor < self._pending.length()) { + let e = self._pending.at(self._pcursor); + self._pcursor += 1; + match (e) { + let d: TextDelta => { + self._text = `${self._text}${d.text}`; + return d.text; + }, + let m: TurnMeta => { + if let s: root.content.StopReason = m.stop_reason { + self._stop_reason = s; + } + if let i: int = m.input_tokens { + self._input_tokens = i; + } + if let o: int = m.output_tokens { + self._output_tokens = o; + } + null + }, + let d: TurnDone => { + let _ = self._finish(); + return Done {}; + }, + null => null, + }; + continue; + } + match (self._sse) { + let sse: baml.http.SseStream => { + match (sse.next()) { + null => { + let _ = self._finish(); + return Done {}; + }, + let batch: string => { + if let decode: (string) -> StreamEvent[] throws unknown = self._decode { + for (let e in decode(batch)) { + self._pending.push(e); + } + null + } else { + null + }; + null + }, + }; + null + }, + null => { + // scripted: each chunk is one delta + match (self._chunks.at(self._cursor)) { + let c: string => { + self._cursor += 1; + self._text = `${self._text}${c}`; + return c; + }, + null => { + let _ = self._finish(); + return Done {}; + }, + } + }, + }; + } + baml.sys.panic("unreachable") + } + + // Drain the stream and return the completed turn. Safe to call at any + // point (including after Done); calling twice returns an equal turn. + function final_turn(self) -> root.ModelTurn { + while (true) { + match (self.next()) { + let d: Done => { + break; + }, + _ => null, + }; + } + let usage: root.events.Usage? = if (self._input_tokens != null || self._output_tokens != null) { + root.events.Usage { + input_tokens: self._input_tokens ?? 0, + output_tokens: self._output_tokens ?? 0, + cached_input_tokens: null, + reasoning_tokens: null, + } + } else { + null + }; + root.ModelTurn { + content: [root.content.Text { text: self._text }], + stop_reason: self._stop_reason ?? root.content.StopReason.Complete, + usage: usage, + } + } +} + +// The typed partial-value stream returned by an LLM function's `$stream` +// companion. This is the one canonical host-stream class; tagged handles carry +// its FQN across FFI so host bridges do not need to hardcode a namespace. +class Stream { + _next_text: () -> string? throws unknown, + _cache: baml.sap.ParseCache, + _text: string, + _finished: bool, + + /// Return the next parsed partial value, or Done after EOF. + function next(self) -> TStream | Done { + while (true) { + if (self._finished) { + return Done {} + } + match (self._next_text()) { + null => { + self._finished = true; + return Done {} + }, + let s: string => { + self._text = self._text + s; + let parsed: TStream | baml.sap.NoYield = + baml.sap.__parse_partial(self._text, self._cache); + match (parsed) { + baml.sap.NoYield => {}, + let p: TStream => { + return p + }, + } + }, + } + } + baml.sys.panic("unreachable") + } + + /// Drain the stream and parse the accumulated text as the final value. + function final(self) -> TFinal { + while (!self._finished) { + match (self._next_text()) { + null => { + self._finished = true + }, + let s: string => { + self._text = self._text + s + }, + } + } + baml.sap.__parse_final(self._text, self._cache) + } +} + +// One-turn streaming over a spec: render the spec's prompt exactly as the +// default runner's first turn would, require a streaming-capable client, and +// return the typed partial stream. `Fn$stream(...)` desugars to +// `ai.stream.from_spec(Fn$spec(...), client)`. +// +// Streaming does not run the tool loop yet: a spec with a non-empty toolbox +// is rejected. The client override is typed as the capability, so passing a +// non-streaming client fails at the call site; the spec's DEFAULT client is +// only checked at runtime (root.errors.StreamingUnsupported when it cannot +// stream). +function from_spec( + spec: root.FunctionSpec, + client: StreamingClient? = null, +) -> Stream { + if (!spec.tools().is_empty()) { + throw baml.errors.InvalidArgument { + message: `streaming does not run the tool loop yet: ${spec.name()} has a non-empty toolbox`, + } + } + let selected: StreamingClient = match (client) { + let s: StreamingClient => s, + null => { + match (spec.default_client) { + let s: StreamingClient => s, + _ => { + throw root.errors.StreamingUnsupported { + client_id: spec.default_client.id(), + } + }, + } + }, + }; + // StreamingClient requires Client, so the downcast always succeeds. + let provider = match (selected) { + let c: root.Client => c.id(), + }; + let turns = selected.invoke_stream( + root.ModelTurnInput { + prompt: spec.prompt_template, + journal: root.Journal.new(spec), + toolbox: spec.tools(), + output_type: spec.output_type(), + }, + ); + // The host-facing stream is `ai.stream.Stream`. It is provider-agnostic: + // all it needs is a way to pull the next text delta from TurnStream. + let _ = provider; + Stream { + _next_text: () -> { + match (turns.next()) { + let d: Done => null, + let s: string => s, + } + }, + _cache: baml.sap.__new_parse_cache(), + _text: "", + _finished: false, + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_tools/tools.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_tools/tools.baml new file mode 100644 index 00000000000..1fda737069f --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_tools/tools.baml @@ -0,0 +1,121 @@ +// Tools are plain functions. Schemas come from reflection; execution is +// reflect.call_any inside the runner, never inside a client. +// Mirrors _planv2/pages/02_guides/01_functions/02_tools.md. + +enum ErrorMode { + Report, // the failure becomes the call's result; the model adapts + Raise, // the failure is journaled, then the run throws root.errors.ToolFailedError +} + +class Tool { + name: string, + description: string, + input_schema: map, // JSON Schema for the arguments object + handler: baml.AnyFunction?, // signature-derived dispatch; null for raw tools + // Dynamic-source dispatch: the handler receives the validated-by-schema + // argument map as one value. Set by raw_tool() when the schema does not + // derive from a BAML signature (MCP servers, OpenAPI specs). + raw_handler: ((map) -> string throws unknown)?, + on_error: ErrorMode?, // null: inherit the run's tool_errors mode + + // The total boundary around one call: validate, dispatch through + // reflection, serialize. A throw here is the tool's failure, not the + // run's. The reflection boundary widens exactly representable integral + // arguments into float parameters (see reflect.call_any). + function call(self, args: map) -> string { + if let rh: (map) -> string throws unknown = self.raw_handler { + return rh(args); + } + if let h: baml.AnyFunction = self.handler { + let value = reflect.call_any(h, args) catch (e) { + let bad: reflect.InvalidArgumentError => { + throw baml.errors.InvalidArgument { message: `invalid arguments for ${self.name}: ${bad.to_string()}` } + }, + }; + baml.json.stringify(baml.json.to_json(value)) + } else { + throw baml.errors.InvalidArgument { message: `tool ${self.name} has no handler` } + } + } +} + +// The explicit constructor. `tools: [f]` normalizes through this. +function tool( + handler: baml.AnyFunction, + name: string? = null, + description: string? = null, + on_error: ErrorMode? = null, +) -> Tool { + let sig = reflect.signature(handler); + let resolved_name = name ?? sig.name; + if let n: string = resolved_name { + if (n == "__baml_return_output") { + throw baml.errors.InvalidArgument { message: "the tool name __baml_return_output is reserved" } + } + Tool { + name: n, + description: description ?? sig.docstring ?? "", + input_schema: root.internal._schema_for_signature(handler), + handler: handler, + raw_handler: null, + on_error: on_error, + } + } else { + throw baml.errors.InvalidArgument { message: "anonymous tool needs a name" } + } +} + +// The constructor for tools whose schema does not derive from a BAML +// signature: the caller supplies the JSON Schema and a handler over the raw +// argument map. This is how dynamic tool sources (MCP servers) become +// ordinary tools. +function raw_tool( + name: string, + description: string, + input_schema: map, + handler: (map) -> string throws unknown, + on_error: ErrorMode? = null, +) -> Tool { + if (name == "__baml_return_output") { + throw baml.errors.InvalidArgument { message: "the tool name __baml_return_output is reserved" } + } + Tool { + name: name, + description: description, + input_schema: input_schema, + handler: null, + raw_handler: handler, + on_error: on_error, + } +} + +class Toolbox { + entries: Tool[], + + // Rejects duplicate names, so a toolbox is well-formed on construction. + function new(tools: Tool[]) -> Toolbox { + let names: string[] = []; + for (let t in tools) { + if (names.includes(t.name)) { + throw baml.errors.InvalidArgument { message: `duplicate tool name: ${t.name}` } + } + names.push(t.name); + } + Toolbox { entries: tools } + } + + function list(self) -> Tool[] { + self.entries + } + + function get(self, name: string) -> Tool? { + self.entries.find((t) -> { + t.name == name + }) + } + + function is_empty(self) -> bool { + self.entries.length() == 0 + } +} + diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/ns_wire/wire.baml b/baml_language/crates/baml_builtins2/baml_std/ai/ns_wire/wire.baml new file mode 100644 index 00000000000..c4126697422 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/ns_wire/wire.baml @@ -0,0 +1,38 @@ +// Shared helpers for client authors: transport with classification, the +// output-format text, and tool schema lowering. +// Mirrors _planv2/pages/02_guides/03_clients/03_writing_a_client.md. + +// Send, classify non-2xx via root.errors.classify_http, decode the body as T. +// T = json-shaped envelope class; unknown fields are ignored by the decoder. +function send_as(req: baml.http.Request, provider: string) -> T { + let resp = baml.http.send(req) catch_all (e) { + _ => { + throw root.errors.NetworkFailure { + provider: provider, + detail: `transport failed before a response arrived: ${e.to_string()}`, + raw_body: null, + } + }, + }; + let text = resp.text() catch_all (e) { + _ => { + throw root.errors.NetworkFailure { + provider: provider, + detail: "the response body could not be read completely", + raw_body: null, + } + }, + }; + if (!resp.ok()) { + throw root.errors.classify_http(provider, resp.status_code, text) + } + baml.json.from_string(text) catch_all (e) { + _ => throw root.errors.ParseFailed { provider: provider, raw_output: text }, + } +} + +// The return type's schema as prompt text, in the standard dialect — +// exactly what ${ctx.output_format} renders in an LLM function block. +function render_output_format(t: type) -> string { + baml.prompt.render_output_format(t) +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/runner.baml b/baml_language/crates/baml_builtins2/baml_std/ai/runner.baml new file mode 100644 index 00000000000..dfb69fe889a --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/runner.baml @@ -0,0 +1,269 @@ +// The Runner interface, the loop building blocks, and the default Agent +// runner. The runner owns the loop: tool execution, correlation, budgets, +// journal writes, and the typed parse of the final output. +// Mirrors _planv2/pages/02_guides/02_specs_and_runners/02_the_default_runner.md. + +interface Runner { + type Output + type Error + + function run(self, spec: FunctionSpec) -> Self.Output throws Self.Error +} + +class RunResult { + value: Out, + journal: Journal, + usage: root.events.Usage, +} + +// The default runner. A spec-mode LLM function's direct call runs +// `Agent.new(client = client).run(Fn@spec(...)).value`; construct it +// yourself to set the other fields (`$` call-site parameters are a future +// phase). +class Agent { + max_steps: int, + client: Client?, + tool_errors: root.tools.ErrorMode, + on_event: (root.events.Event) -> null throws never, + + function new( + max_steps: int = 12, + client: Client? = null, + tool_errors: root.tools.ErrorMode = root.tools.ErrorMode.Report, + on_event: ((root.events.Event) -> null throws never)? = null, + ) -> Agent { + Agent { + max_steps: max_steps, + client: client, + tool_errors: tool_errors, + on_event: on_event + ?? ( + (e: root.events.Event) -> { + null + } + ), + } + } + + // Execute one call: validate, dispatch, append the correlated result. + // A Raise-mode failure throws ToolFailedError after ToolFailed lands. + function _run_one_tool(self, tb: root.tools.Toolbox, call: root.content.ToolUse, j: Journal) -> null { + match (tb.get(call.name)) { + let t: root.tools.Tool => { + let outcome: string? = t.call(call.args) catch_all (e) { + _ => { + j.append_all([root.events.ToolFailed { id: call.id, message: e.to_string() }]); + // the per-tool setting wins; null inherits the run's mode + if ((t.on_error ?? self.tool_errors) == root.tools.ErrorMode.Raise) { + let classified = match (e) { + let f: root.errors.Failure => f, + _ => null, + }; + throw root.errors.ToolFailedError { + id: call.id, + name: call.name, + message: e.to_string(), + cause: classified, + } + } else { + null + } + }, + }; + if let output: string = outcome { + j.append_all([root.events.ToolCompleted { id: call.id, output: output }]); + } + null + }, + null => { + j.append_all( + [ + root.events.ToolFailed { + id: call.id, + message: `tool is not in the active toolbox: ${call.name}`, + }, + ], + ); + null + }, + } + } + + function _parses(self, candidate: string) -> bool { + let _ = baml.sap.parse(candidate) catch_all (e) { + _ => { + return false; + }, + }; + true + } + + // One accepted model turn, with the within-turn repair loop: when a + // Complete turn's candidate does not parse, commit the failed turn and a + // correction request as ordinary events and invoke again, under a fixed + // attempt budget. Everything that happened is on the record; a repair + // attempt does not consume a step. Token usage of every attempt is + // added to `total`. + function _attempt_turn( + self, + c: Client, + spec: FunctionSpec, + j: Journal, + total: root.events.Usage, + attempts_left: int, + ) -> ModelTurn { + let turn = c.invoke( + ModelTurnInput { + prompt: spec.prompt_template, + journal: j, + toolbox: spec.tools(), + output_type: spec.output_type(), + }, + ); + if let u: root.events.Usage = turn.usage { + total.input_tokens += u.input_tokens; + total.output_tokens += u.output_tokens; + if let ci: int = u.cached_input_tokens { + total.cached_input_tokens = (total.cached_input_tokens ?? 0) + ci; + } + if let rt: int = u.reasoning_tokens { + total.reasoning_tokens = (total.reasoning_tokens ?? 0) + rt; + } + } + // every attempt commits: the journal is the complete record + let batch: root.events.Event[] = [root.events.AssistantMessage { content: turn.content, client_id: c.id() }]; + for (let u in turn.tool_uses()) { + batch.push(root.events.ToolRequested { id: u.id, name: u.name, args: u.args }); + } + if let u: root.events.Usage = turn.usage { + batch.push(u); + } + let candidate = turn.terminal_text() ?? ""; + let accepted = turn.tool_uses().length() > 0 + || turn.stop_reason == root.content.StopReason.MaxTokens + || turn.stop_reason == root.content.StopReason.Refused + || self._parses(candidate); + if (accepted) { + j.append_all(batch); + // terminal wire outcomes are thrown here, with the turn on the record + match (turn.stop_reason) { + root.content.StopReason.MaxTokens => { + throw root.errors.ParseFailed { provider: c.id(), raw_output: candidate } + }, + root.content.StopReason.Refused => { + throw root.errors.Refused { provider: c.id(), reason: "the model declined to answer", raw_body: null } + }, + _ => null, + }; + turn + } else if (attempts_left > 1) { + batch.push( + root.events.UserMessage { + content: "The reply did not match the required schema. Answer again with only the corrected value.", + }, + ); + j.append_all(batch); + self._attempt_turn(c, spec, j, total, attempts_left - 1) + } else { + j.append_all(batch); + throw root.errors.ParseFailed { provider: c.id(), raw_output: candidate } + } + } + + // Emit any events appended since `seen` to on_event; returns the new count. + function _emit_from(self, j: Journal, seen: int) -> int { + let entries = j.entries(); + let i = seen; + while (i < entries.length()) { + if let e: root.events.Event = entries.at(i) { + let _ = self.on_event(e); + } + i = i + 1; + } + entries.length() + } + + implements Runner { + type Output = RunResult + type Error = root.errors.Failure | baml.errors.UnknownError + + function run(self, spec: FunctionSpec) -> RunResult { + let selected = self.client ?? spec.default_client; + let j = Journal.new(spec); + let seen = self._emit_from(j, 0); + let steps = 0; + let total = root.events.Usage { + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: null, + reasoning_tokens: null, + }; + // the futility breaker: identical repeated failures stop the run + let fail_counts: map = {}; + while (steps < self.max_steps) { + // repair re-asks happen inside the step: 1 turn + up to 1 re-asks + let turn = self._attempt_turn(selected, spec, j, total, 2); + steps = steps + 1; + seen = self._emit_from(j, seen); + let calls = turn.tool_uses(); + if (calls.length() > 0) { + let before = j.entries().length(); + // execute concurrently; each call appends its correlated result + let futures = calls.map((call) -> { + spawn { self._run_one_tool(spec.tools(), call, j) } + }); + let _ = await baml.future.all(futures); + seen = self._emit_from(j, seen); + // Reporting a failure lets the model adapt; repeating the + // IDENTICAL failing call is futile, and each step can be + // expensive. Three identical failures end the run. + let entries = j.entries(); + let i = before; + while (i < entries.length()) { + if let f: root.events.ToolFailed = entries.at(i) { + let failed_call = calls.find((one: root.content.ToolUse) -> { + one.id == f.id + }); + if let known: root.content.ToolUse = failed_call { + let key = `${known.name}|${f.message}`; + let n = (fail_counts.get(key) ?? 0) + 1; + let _ = fail_counts.set(key, n); + if (n >= 3) { + throw root.errors.ToolFailedError { + id: known.id, + name: known.name, + message: `the same tool call failed ${n} times; stopping the run: ${f.message}`, + cause: null, + } + } + } + } + i = i + 1; + } + } else { + let candidate = turn.terminal_text() ?? ""; + let value = baml.sap.parse(candidate) catch_all (e) { + _ => { + throw root.errors.ParseFailed { provider: selected.id(), raw_output: candidate } + }, + }; + let value_json = baml.json.to_json(value) catch_all (e) { + _ => { + throw baml.errors.UnknownError { data: e, message: [e.to_string()] } + }, + }; + j.append_all( + [ + root.events.FinalProduced { + value_json: baml.json.stringify(value_json), + }, + ], + ); + seen = self._emit_from(j, seen); + return RunResult { value: value, journal: j, usage: total }; + } + } + throw root.errors.StepBudgetExceeded { steps: steps } + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/spec.baml b/baml_language/crates/baml_builtins2/baml_std/ai/spec.baml new file mode 100644 index 00000000000..1493bfcdcb5 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/spec.baml @@ -0,0 +1,92 @@ +// Prompt and FunctionSpec. A spec is a recipe: identity, bound arguments, +// the prompt template, the initial toolbox, and the resolved default client. +// No journal, no conversation, no wire state. +// Mirrors _planv2/pages/02_guides/02_specs_and_runners/01_specs.md. + +/// A single chat message in a rendered prompt. +class PromptMessage { + /// The chat role, e.g. "system", "user", or "assistant". Empty for + /// role-less content. + role: string, + /// The message's rendered text. Media parts use a readable placeholder. + content: string, +} + +/// The rendered, provider-neutral prompt passed to clients. Roles split the +/// prompt into messages and media remains structural until a provider lowers +/// it to its wire format. +class Prompt { + _data $rust_type + + /// Render the prompt as readable text. Message roles are emitted as headers. + //baml:vm + function text(self) -> string throws never { + $rust_function + } + + /// Return the ordered chat messages used by provider request builders. + //baml:mut_vm + function messages(self) -> PromptMessage[] throws never { + $rust_function + } + + implements baml.ToString { + function to_string(self) -> string throws never { + self.text() + } + } +} + +/// Standalone tagged prompt constructor. The returned closure renders a +/// structural `ai.Prompt` for the supplied low-level prompt context. +//baml:tagged_string +function prompt( + body: ( + role: (string) -> baml.prompt.Role throws never, + ctx: baml.prompt.Context, + ) -> baml.TaggedString throws never, +) -> (baml.prompt.Context) -> Prompt throws never { + let render = (ctx: baml.prompt.Context) -> { + let tagged = body(baml.prompt.make_role, ctx) + root.internal.assemble_prompt(tagged.parts, tagged.values) + } + render +} + +// One unit of model work, bound and unrun. Immutable; getters only. +// `MyFunc@spec(args)` desugars to a constructor like this: the compiler +// synthesizes a `MyFunc$spec` companion for every spec-mode LLM function +// (backtick prompt, "provider/model" client string, and a `tools` field — +// `tools []` opts a tool-less function in). +class FunctionSpec { + spec_name: string, + args: map, + prompt_template: (string) -> Prompt throws never, + toolbox: root.tools.Toolbox, + default_client: Client, + + function name(self) -> string { + self.spec_name + } + + function arguments(self) -> map { + self.args + } + + function output_type(self) -> type { + reflect.type_of() + } + + function prompt(self, output_format: string = "") -> Prompt { + self.prompt_template(output_format) + } + + function tools(self) -> root.tools.Toolbox { + self.toolbox + } + + // NOTE: `client` is a keyword, so the default client is read as the + // `default_client` field rather than a `client()` getter. Holding an + // eager value is safe because client construction is pure — credentials + // resolve from the environment at request time, never in `new()`. +} diff --git a/baml_language/crates/baml_builtins2/baml_std/ai/turn.baml b/baml_language/crates/baml_builtins2/baml_std/ai/turn.baml new file mode 100644 index 00000000000..34a98d09420 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/ai/turn.baml @@ -0,0 +1,46 @@ +// The client boundary: one operation. A client renders the input into its +// wire format, makes one call, and returns canonical content. It never +// writes the journal, executes a tool, or parses the output type. +// Mirrors _planv2/pages/02_guides/03_clients/02_the_client_interface.md. + +class ModelTurnInput { + // The bound prompt template. The client supplies the output-format text + // appropriate for its own wire format and receives one structural prompt. + prompt: (string) -> Prompt throws never, + journal: Journal, + toolbox: root.tools.Toolbox, + output_type: type, +} + +class ModelTurn { + content: root.content.Block[], + stop_reason: root.content.StopReason, + usage: root.events.Usage?, + + // The terminal Text block — the final candidate the runner parses. + function terminal_text(self) -> string? { + let out: string? = null; + for (let b in self.content) { + if let t: root.content.Text = b { + out = t.text; + } + } + out + } + + // The ToolUse blocks of the turn, in order. + function tool_uses(self) -> root.content.ToolUse[] { + let out: root.content.ToolUse[] = []; + for (let b in self.content) { + if let u: root.content.ToolUse = b { + out.push(u); + } + } + out + } +} + +interface Client { + function id(self) -> string throws never + function invoke(self, input: ModelTurnInput) -> ModelTurn throws root.errors.Failure | baml.errors.UnknownError +} diff --git a/baml_language/crates/baml_builtins2/baml_std/anthropic/messages.baml b/baml_language/crates/baml_builtins2/baml_std/anthropic/messages.baml new file mode 100644 index 00000000000..1f486095666 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/anthropic/messages.baml @@ -0,0 +1,43 @@ +class AnthropicClient { + model: string, + // null resolves from the environment at request time; construction + // never touches env, so declaring/constructing a client is always safe. + api_key: string?, + base_url: string?, + max_tokens: int, + + // NOTE: the task spec named "claude-3-5-haiku-latest" as the default, but + // that model was retired (the API 404s on it); claude-haiku-4-5 is the + // current Haiku. + function new( + model: string = "claude-haiku-4-5", + api_key: string? = null, + base_url: string? = null, + max_tokens: int = 4096, + ) -> AnthropicClient { + AnthropicClient { + model: model, + api_key: api_key, + base_url: base_url, + max_tokens: max_tokens, + } + } + + implements ai.Client { + function id(self) -> string { + `anthropic/${self.model}` + } + + function invoke(self, input: ai.ModelTurnInput) -> ai.ModelTurn { + root.internal.invoke(self, input) catch_all (e) { + _ => throw ai.errors.normalize(e), + } + } + } + + implements ai.stream.StreamingClient { + function invoke_stream(self, input: ai.ModelTurnInput) -> ai.stream.TurnStream { + root.internal.invoke_stream(self, input) + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/anthropic/ns_internal/messages.baml b/baml_language/crates/baml_builtins2/baml_std/anthropic/ns_internal/messages.baml new file mode 100644 index 00000000000..e8795712d6b --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/anthropic/ns_internal/messages.baml @@ -0,0 +1,428 @@ +// The live Anthropic client: one Messages API call per turn, SAP output +// mode (the schema rides in the prompt text; tools are native). +// Mirrors _planv2/pages/02_guides/03_clients/05_the_built_in_clients.md. + +// --------------------------------------------------------------------------- +// Response envelope. from_string ignores unknown fields; only what the +// client reads is typed, and per-block fields are optional because block +// kinds carry different fields. + +class AnthropicContentBlock { + type: string, + text: string?, + thinking: string?, + id: string?, + name: string?, + input: map?, +} + +class AnthropicUsage { + input_tokens: int, + output_tokens: int, +} + +class AnthropicResponse { + content: AnthropicContentBlock[], + stop_reason: string?, + usage: AnthropicUsage?, +} + +// --------------------------------------------------------------------------- +// ai.Journal lowering. Anthropic requires strict user/assistant alternation and +// tool_result blocks in a user message immediately after the assistant +// tool_use message, so consecutive tool results group into ONE user message. + +// An intermediate lowered message: role plus wire content blocks. +// tool_results marks a user message that is an open tool_result group. +class AnthropicMessage { + role: string, + tool_results: bool, + blocks: map[], +} + +// Anthropic carries system content outside the alternating messages list. +// Keep that split provider-local while preserving the authored prompt roles. +class AnthropicPrompt { + system: map[], + messages: AnthropicMessage[], +} + +function _anthropic_lower_prompt(prompt: ai.Prompt) -> AnthropicPrompt { + let system: map[] = []; + let messages: AnthropicMessage[] = []; + for (let message in prompt.messages()) { + let block: map = {}; + let _ = block.set("type", "text"); + let _ = block.set("text", message.content); + if (message.role == "system") { + system.push(block); + null + } else { + let role = if (message.role == "assistant") { "assistant" } else { "user" }; + messages.push( + AnthropicMessage { role: role, tool_results: false, blocks: [block] }, + ); + null + }; + } + AnthropicPrompt { system: system, messages: messages } +} + +function _anthropic_assistant_blocks(content: ai.content.Block[]) -> map[] { + let out: map[] = []; + for (let b in content) { + match (b) { + let t: ai.content.Text => { + let bb: map = {}; + let _ = bb.set("type", "text"); + let _ = bb.set("text", t.text); + out.push(bb); + null + }, + // Dropped: replaying thinking without its signature is rejected + // by the API; readable-projection replay is a phase 2 capability. + let r: ai.content.Reasoning => null, + let u: ai.content.ToolUse => { + let bb: map = {}; + let _ = bb.set("type", "tool_use"); + let _ = bb.set("id", u.id); + let _ = bb.set("name", u.name); + let _ = bb.set("input", u.args); + out.push(bb); + null + }, + }; + } + out +} + +// Append one tool_result block, extending the trailing group when open. +function _anthropic_push_tool_result( + msgs: AnthropicMessage[], + id: string, + content: string, + is_error: bool, +) -> null { + let block: map = {}; + let _ = block.set("type", "tool_result"); + let _ = block.set("tool_use_id", id); + let _ = block.set("content", content); + let _ = block.set("is_error", is_error); + // .at returns null on an empty list, covering the no-messages case. + if let m: AnthropicMessage = msgs.at(msgs.length() - 1) { + if (m.tool_results) { + m.blocks.push(block); + null + } else { + msgs.push(AnthropicMessage { role: "user", tool_results: true, blocks: [block] }); + null + } + } else { + msgs.push(AnthropicMessage { role: "user", tool_results: true, blocks: [block] }); + null + } +} + +// ai.RunStarted/ai.ToolRequested/ai.Usage/ai.FinalProduced are not promptable and skip; +// skipped events never close an open tool_result group. +function _anthropic_lower_journal(j: ai.Journal) -> AnthropicMessage[] { + let msgs: AnthropicMessage[] = []; + for (let e in j.entries()) { + match (e) { + let m: ai.events.UserMessage => { + let block: map = {}; + let _ = block.set("type", "text"); + let _ = block.set("text", m.content); + msgs.push(AnthropicMessage { role: "user", tool_results: false, blocks: [block] }); + null + }, + let m: ai.events.AssistantMessage => { + msgs.push( + AnthropicMessage { + role: "assistant", + tool_results: false, + blocks: _anthropic_assistant_blocks(m.content), + }, + ); + null + }, + let c: ai.events.ToolCompleted => _anthropic_push_tool_result(msgs, c.id, c.output, false), + let f: ai.events.ToolFailed => _anthropic_push_tool_result(msgs, f.id, f.message, true), + _ => null, + }; + } + msgs +} + +function _anthropic_render_messages(msgs: AnthropicMessage[]) -> map[] { + let out: map[] = []; + for (let m in msgs) { + let mm: map = {}; + let _ = mm.set("role", m.role); + let _ = mm.set("content", m.blocks); + out.push(mm); + } + out +} + +// --------------------------------------------------------------------------- +// The client. + +// The shared request builder: identical body for both paths, plus the +// `stream: true` flag on the streaming one. +function _anthropic_request( + c: root.AnthropicClient, + input: ai.ModelTurnInput, + stream: bool, +) -> baml.http.Request { + let prompt = input.prompt(ai.wire.render_output_format(input.output_type)); + let prompt_parts = _anthropic_lower_prompt(prompt); + let system = prompt_parts.system; + let lowered = prompt_parts.messages; + for (let message in _anthropic_lower_journal(input.journal)) { + lowered.push(message); + } + + let body: map = {}; + let _ = body.set("model", c.model); + let _ = body.set("max_tokens", c.max_tokens); + // The API requires a non-empty message list that starts with a user + // message. Normal role-less prompts already lower to user. For the unusual + // system-only/assistant-first shape, move the system blocks into a leading + // user message, matching the old first-turn fallback without flattening + // an otherwise well-formed multi-message prompt. + let first_role = lowered.at(0)?.role ?? ""; + if (first_role != "user" && system.length() > 0) { + let first = AnthropicMessage { + role: "user", + tool_results: false, + blocks: system, + }; + let _ = body.set("messages", _anthropic_render_messages([first].concat(lowered))); + null + } else { + if (system.length() > 0) { + let _ = body.set("system", system); + null + } else { + null + }; + let _ = body.set("messages", _anthropic_render_messages(lowered)); + null + }; + if (!input.toolbox.is_empty()) { + let tools: map[] = []; + for (let t in input.toolbox.list()) { + let tw: map = {}; + let _ = tw.set("name", t.name); + let _ = tw.set("description", t.description); + let _ = tw.set("input_schema", t.input_schema); + tools.push(tw); + } + let tc: map = {}; + let _ = tc.set("type", "auto"); + let _ = body.set("tools", tools); + let _ = body.set("tool_choice", tc); + null + } else { + null + }; + + if (stream) { + let _ = body.set("stream", true); + null + } else { + null + }; + + let base = c.base_url ?? "https://api.anthropic.com"; + baml.http.Request { + method: "POST", + url: `${base}/v1/messages`, + headers: { + "x-api-key": c.api_key ?? baml.env.get_or_panic("ANTHROPIC_API_KEY"), + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: baml.json.stringify(baml.json.to_json(body)), + } +} + +// One wire stop_reason string -> the canonical stop reason. +function _anthropic_stop_reason(s: string) -> ai.content.StopReason { + if (s == "tool_use") { + ai.content.StopReason.ToolUse + } else if (s == "max_tokens") { + ai.content.StopReason.MaxTokens + } else if (s == "refusal") { + ai.content.StopReason.Refused + } else { + ai.content.StopReason.Complete + } +} + +// The one internal entry point: render, send, normalize — one model turn. +function invoke( + c: root.AnthropicClient, + input: ai.ModelTurnInput, +) -> ai.ModelTurn { + let req = _anthropic_request(c, input, false); + let resp = ai.wire.send_as(req, "anthropic"); + + let content: ai.content.Block[] = []; + for (let b in resp.content) { + if (b.type == "text") { + content.push(ai.content.Text { text: b.text ?? "" }); + null + } else if (b.type == "thinking") { + content.push(ai.content.Reasoning { summary: b.thinking ?? "" }); + null + } else if (b.type == "tool_use") { + let empty_args: map = {}; + content.push( + ai.content.ToolUse { id: b.id ?? "", name: b.name ?? "", args: b.input ?? empty_args }, + ); + null + } else { + null + }; + } + + let stop = match (resp.stop_reason) { + null => ai.content.StopReason.Complete, + let s: string => _anthropic_stop_reason(s), + }; + + let usage: ai.events.Usage? = match (resp.usage) { + let u: AnthropicUsage => { + ai.events.Usage { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + cached_input_tokens: null, + reasoning_tokens: null, + } + }, + null => null, + }; + + ai.ModelTurn { content: content, stop_reason: stop, usage: usage } +} + +// --------------------------------------------------------------------------- +// Streaming (Messages API SSE). +// +// Wire events observed: `message_start` carries `message.usage.input_tokens`; +// `content_block_delta` carries text deltas as `delta.type == "text_delta"` +// with the piece in `delta.text`; `message_delta` carries +// `delta.stop_reason` and cumulative `usage.output_tokens`; `message_stop` +// ends the turn. + +// Streaming usage objects carry only some fields per event, so every field +// is optional (unlike the non-streaming AnthropicUsage). +class AnthStreamUsage { + input_tokens: int?, + output_tokens: int?, +} + +class AnthStreamDelta { + type: string?, + text: string?, + stop_reason: string?, +} + +class AnthStreamMessage { + usage: AnthStreamUsage?, +} + +// The envelope of one SSE `data:` payload; only what the decoder reads. +class AnthStreamEvent { + type: string?, + delta: AnthStreamDelta?, + message: AnthStreamMessage?, + usage: AnthStreamUsage?, +} + +// One SseStream batch -> provider-agnostic stream events. +function _anthropic_decode_batch(batch: string) -> ai.stream.StreamEvent[] { + let out: ai.stream.StreamEvent[] = []; + for (let raw in ai.stream.decode_sse_batch(batch)) { + if let data: string = raw.data { + let ev: AnthStreamEvent? = baml.json.from_string(data) catch_all (e) { + _ => null, + }; + if let event: AnthStreamEvent = ev { + let t = event.type ?? raw.event ?? ""; + if (t == "content_block_delta") { + if let d: AnthStreamDelta = event.delta { + if ((d.type ?? "") == "text_delta") { + if let text: string = d.text { + out.push(ai.stream.TextDelta { text: text }); + null + } else { + null + } + } else { + null + } + } else { + null + }; + null + } else if (t == "message_start") { + out.push( + ai.stream.TurnMeta { + stop_reason: null, + input_tokens: event.message?.usage?.input_tokens, + output_tokens: event.message?.usage?.output_tokens, + }, + ); + null + } else if (t == "message_delta") { + let stop: ai.content.StopReason? = match (event.delta?.stop_reason) { + null => null, + let s: string => _anthropic_stop_reason(s), + }; + out.push( + ai.stream.TurnMeta { + stop_reason: stop, + input_tokens: event.usage?.input_tokens, + output_tokens: event.usage?.output_tokens, + }, + ); + null + } else if (t == "message_stop") { + out.push(ai.stream.TurnDone {}); + null + } else { + null + }; + null + } else { + null + }; + null + } else { + null + }; + } + out +} + +// The streaming entry point: the same request as `invoke` with +// `stream: true`, wrapped in an ai.TurnStream over the SSE connection. +function invoke_stream( + c: root.AnthropicClient, + input: ai.ModelTurnInput, +) -> ai.stream.TurnStream { + let req = _anthropic_request(c, input, true); + let sse = baml.http.fetch_sse(req) catch_all (e) { + _ => { + throw ai.errors.NetworkFailure { + provider: "anthropic", + detail: `transport failed before a response arrived: ${e.to_string()}`, + raw_body: null, + } + }, + }; + ai.stream.TurnStream.from_sse(sse, _anthropic_decode_batch) +} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/float.baml b/baml_language/crates/baml_builtins2/baml_std/baml/float.baml index d40cce52031..7abc5651705 100644 --- a/baml_language/crates/baml_builtins2/baml_std/baml/float.baml +++ b/baml_language/crates/baml_builtins2/baml_std/baml/float.baml @@ -455,7 +455,7 @@ class Float { /// to the `int` range and mapping NaN to `0` — it never throws. /// /// Private helper (leading `_`; formerly the public `baml.math.trunc`) backing -/// internal retry-delay math in `baml.llm`. User code should prefer the +/// internal retry-delay math. User code should prefer the /// range-checked, throwing `float.itrunc()` (or the float-returning /// `float.trunc()`); this saturating form is intentionally not public surface. function _trunc_to_int(value: float) -> int throws never { diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml deleted file mode 100644 index a85d92e00db..00000000000 --- a/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm.baml +++ /dev/null @@ -1,152 +0,0 @@ -class OrchestrationStep { - primitive_client PrimitiveClient - delay_ms int -} - -class ExecutionContext { - jinja_string string - args map - function_name string - /// BEP-049 §10 (M5e/M5f). For new-mode (backtick) LLM functions, the compiled - /// `prompt`…`` closure the orchestrator invokes per attempt with a built - /// `Context`, replacing the Jinja render. `null` for legacy `#"..."#` functions. - prompt_closure ((Context) -> PromptAst throws never)? -} - -class PlannerState { - rr_client_names string[] - - function rr_local_offset(self, client_name: string) -> int { - let offset = 0; - for (let seen_name in self.rr_client_names) { - if (seen_name == client_name) { - offset += 1; - } - } - offset - } - - function rr_pick_index(self, client_name: string, counter: int, sub_client_count: int) -> int { - let local_offset = self.rr_local_offset(client_name); - self.rr_client_names.push(client_name); - (counter + local_offset) % sub_client_count - } -} - -/// Renders the Jinja prompt template for `function_name` with the given `args` using `client`'s configuration. -/// Returns the specialized `PromptAst` ready to be sent to the LLM provider. -/// The Jinja template to render for `function_name`. New-mode (BEP-049 M5) -/// functions have no template registered — their compiled `prompt`…`` closure -/// renders the prompt — so only look one up for the legacy `#"..."#` path. -function prompt_template_for(function_name: string, prompt_closure: ((Context) -> PromptAst throws never)?) -> string throws root.errors.InvalidArgument { - match (prompt_closure) { - null => get_jinja_template(function_name), - _ => "", - } -} - -function render_prompt(client: Client, function_name: string, args: map, prompt_closure: ((Context) -> PromptAst throws never)? = null) -> PromptAst { - let return_type = get_return_type(function_name); - // Rendering is offline: it needs the client's provider/role metadata but no - // credentials. Construct leniently so a missing `api_key` env var doesn't - // panic — `$render_prompt` must work without a key set. (`build_request` - // below stays strict, so the network paths still require the key.) - let primitive_client = client.to_primitive_client(lenient = true); - let template = prompt_template_for(function_name, prompt_closure); - // `render_specialized_prompt` is the same render the orchestrator uses, so the - // playground preview/cURL render byte-identically to execution. - primitive_client.render_specialized_prompt(template, args, return_type, prompt_closure) -} - -function build_request(client: Client, function_name: string, args: map, prompt_closure: ((Context) -> PromptAst throws never)? = null) -> root.http.Request { - let return_type = get_return_type(function_name); - let primitive_client = client.to_primitive_client(); - let template = prompt_template_for(function_name, prompt_closure); - let specialized_prompt = primitive_client.render_specialized_prompt(template, args, return_type, prompt_closure); - - primitive_client.build_request(specialized_prompt, return_type) -} - -function build_request_stream(client: Client, function_name: string, args: map, prompt_closure: ((Context) -> PromptAst throws never)? = null) -> root.http.Request { - let return_type = get_return_type(function_name); - let primitive_client = client.to_primitive_client(); - let template = prompt_template_for(function_name, prompt_closure); - let specialized_prompt = primitive_client.render_specialized_prompt(template, args, return_type, prompt_closure); - - primitive_client.build_request_stream(specialized_prompt, return_type) -} - -/// UNSAFE: do not call from user code -/// -/// Used by LLM function `$parse` companions, which pass -/// `` as explicit type args. -/// Only parses full responses, not partials. -/// -/// `$parse` is a purely local, network-free parse of an existing JSON string -/// into the function's return type, so a schema mismatch (e.g. an out-of-enum -/// value) is a *parse* failure — not an LLM/client failure. The underlying -/// schema-aligned parser reports failures as `LlmClient` because it is shared -/// with the network and streaming paths (whose `LlmClient` typing must stay -/// intact); remap it to the parse-flavored `ParseError` here so the surfaced -/// error name matches the operation and the documented -/// `catch (baml.errors.ParseError)` pattern is reachable (no E0063). -function parse(json: string) -> TFinal throws root.errors.ParseError { - let cache: StreamCache = StreamCache.new(reflect.type_of(), reflect.type_of()); - __sap_parse_final(json, cache) catch (_) { - root.errors.LlmClient { message } => throw root.errors.ParseError { message: message }, - } -} - -/// Internal bridge for callers outside `baml.llm` that need a typed stream -/// cache. Keeping the inference here avoids requiring an explicit static-method -/// owner at each call site. -function __new_stream_cache() -> StreamCache throws never { - let cache: StreamCache = StreamCache.new(reflect.type_of(), reflect.type_of()); - cache -} - -/// UNSAFE: do not call from user code -/// -/// Call an LLM function and return its full result. -function call_llm_function(client: Client, function_name: string, args: map, prompt_closure: ((Context) -> PromptAst throws never)? = null) -> T { - // New-mode (backtick) functions have no Jinja template registered; the - // closure renders the prompt, so only look one up for the legacy path. - let jinja_string = match (prompt_closure) { - null => get_jinja_template(function_name), - _ => "", - }; - - let context = ExecutionContext { - jinja_string: jinja_string, - args: args, - function_name: function_name, - prompt_closure: prompt_closure, - }; - - let result: T = client.execute_oneshot(context, 0); - result -} - -/// UNSAFE: do not call from user code -/// -/// The streaming counterpart of `call_llm_function`. -/// -/// Sends the request and returns a `Stream` which can be used to consume the data as it arrives. -function stream_llm_function(client: Client, function_name: string, args: map, prompt_closure: ((Context) -> PromptAst throws never)? = null) -> Stream { - // New-mode (backtick) functions have no Jinja template registered; the - // closure renders the prompt, so only look one up for the legacy path. - let jinja_string = match (prompt_closure) { - null => get_jinja_template(function_name), - _ => "", - }; - - let context = ExecutionContext { - jinja_string: jinja_string, - args: args, - function_name: function_name, - prompt_closure: prompt_closure, - }; - - let result: Stream = client.execute_stream(context, 0); - result -} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml deleted file mode 100644 index 9d6c196d8c6..00000000000 --- a/baml_language/crates/baml_builtins2/baml_std/baml/ns_llm/llm_types.baml +++ /dev/null @@ -1,898 +0,0 @@ -/// A single chat message in a rendered prompt: a chat role and its text -/// content. Produced by `PromptAst.messages()` for offline inspection of a -/// rendered prompt (e.g. what `$render_prompt` produced). -class PromptMessage { - /// The chat role, e.g. "system", "user", "assistant". Empty for role-less - /// content — a prompt with no `${role(...)}` markers that was never - /// specialized to a client's default role. - role string - /// The message's rendered text content. Media parts render as a - /// human-readable placeholder (e.g. `image::url(...)`). - content string -} - -/// The rendered prompt produced by `$render_prompt` and the built-in -/// `prompt` tag: a structural chat-message tree. Use the accessors below to read -/// it as text or structured messages. To inspect the fully-resolved provider -/// request instead (URL, headers, body, remapped roles), use -/// `$build_request`. -class PromptAst { - _data $rust_type - - /// The rendered prompt as readable plain text: each chat message rendered as a - /// `[role]` header line followed by its content, messages separated by a blank - /// line (role-less content has no header). This is also what - /// `string.from(prompt)`, string interpolation, and `to_string` yield. - function text(self) -> string throws never { - $rust_io_function - } - - /// The rendered prompt as an ordered list of chat messages (role + text - /// content). The primary structured accessor for offline prompt inspection. - function messages(self) -> PromptMessage[] throws never { - $rust_io_function - } - - /// Render readably instead of dumping the opaque handle: `string.from(prompt)`, - /// string interpolation, and `to_string` all yield `text()`. - implements baml.ToString { - function to_string(self) -> string throws never { - self.text() - } - } -} - -/// BEP-049 §10 (M5b.2). Opaque handle to a prebuilt output-format schema (the -/// rust `OutputFormatContent` for a return type). Carried on `Context` so -/// `ctx.output_format_with(...)` can re-render the schema with caller options -/// without rebuilding it. Built by `build_output_format(return_type)`. -class OutputFormat { - _data $rust_type -} - -/// BEP-049 §10. A typed chat-role marker produced by the in-template `role(...)` -/// constructor (e.g. `${role("system")}`). When a `prompt` tag assembles a -/// `PromptAst`, each `Role` value in the template's `values` array starts a new -/// message — replacing the magic-delimiter role mechanism of the Jinja path. -class Role { - /// The role name, e.g. "system", "user", "assistant". - name string - /// Provider-specific per-message metadata (e.g. cache_control). Opaque to the - /// type system; interpreted during `PromptAst` assembly / specialization. - metadata map -} - -/// BEP-049 §10. The resolved-client view exposed to a `prompt` tag body as -/// `ctx.client`. Flattened to mirror the runtime `RenderContextClient` the -/// orchestrator already assembles for the current attempt (sys_llm), so -/// `${ctx.client.provider}` etc. read the chosen client's fields directly. -class ContextClient { - name string - provider string - default_role string - allowed_roles string[] -} - -/// BEP-049 §10. Per-render context passed to a `prompt` tag body as `ctx`. -/// Carries the same per-attempt data BAML's Jinja runtime injects today -/// (client info, runtime tags, output-format schema), resolved AFTER the -/// orchestrator picks the client for this attempt — preserving lazy eval. -/// -/// `output_format` is the default-rendered schema string for the call's return -/// type — the dominant `${ctx.output_format}` usage, byte-equivalent to Jinja's -/// `{{ ctx.output_format }}`. `output_format_with(...)` re-renders the schema -/// with caller options (the rare `{{ ctx.output_format(...) }}` form), backed by -/// the opaque `_output_format` handle. The orchestrator populates both once the -/// client is chosen. -class Context { - /// The client chosen for this attempt. - client ContextClient - /// Runtime tags for this call. - tags map - /// The return type's schema, rendered with default options. - output_format string - /// Opaque handle to the return type's prebuilt output-format schema, backing - /// `output_format_with(...)`. - _output_format OutputFormat - - /// BEP-049 §10 (M5b.2). Render the return type's schema with custom options — - /// the parameterized form of `output_format`. Each option defaults to `null` - /// (auto); a supplied value overrides that aspect of the rendering. - function output_format_with( - self, - prefix: string? = null, - or_splitter: string? = null, - enum_value_prefix: string? = null, - hoisted_class_prefix: string? = null, - always_hoist_enums: bool? = null, - quote_class_fields: bool? = null, - hoist_classes: string[]? = null, - map_style: string? = null, - render_null_as: string? = null, - ) -> string throws never { - $rust_io_function - } -} - -/// The routing strategy of an LLM client. -enum ClientType { - Primitive - Fallback - RoundRobin -} - -/// Configures automatic retry behavior for an LLM client. -class RetryPolicy { - max_retries int - // Delay fields are optional: a `retry_policy` block may omit them (or use a - // `strategy { ... }` sub-block the config lowering does not yet expand into - // these fields), in which case they arrive as `null` at runtime. Reading a - // `null` in the backoff arithmetic below (`null + 0.0`) is a VM type error, - // so the `*_or_default` accessors substitute the canonical defaults — - // 200ms / 1.5x / 10000ms, matching the legacy engine — and return `float`, - // keeping the retry-delay math well-typed. See B-488. - initial_delay_ms int? - multiplier float? - max_delay_ms int? - - /// Initial backoff delay (ms) as a float; defaults to 200 when unset. - function initial_delay_or_default(self) -> float throws never { - (self.initial_delay_ms ?? 200) + 0.0 - } - - /// Backoff growth multiplier; defaults to 1.5 when unset. - function multiplier_or_default(self) -> float throws never { - self.multiplier ?? 1.5 - } - - /// Maximum backoff delay (ms) as a float; defaults to 10000 when unset. - function max_delay_or_default(self) -> float throws never { - (self.max_delay_ms ?? 10000) + 0.0 - } -} - -/// An LLM client (primitive, fallback, or round-robin). -/// Compiled as a global variable from `client` declarations. -class Client { - name string - client_type ClientType - sub_clients Client[] - retry RetryPolicy? - counter int - - /// Build the primitive client this client resolves to. - /// - /// `lenient` controls how the generated constructor reads `env.X` client - /// options: when true, a missing variable yields "" instead of panicking. - /// Only the offline `render_prompt` path passes `true` — it needs the - /// client's provider/role metadata to render a prompt but never its - /// credentials. Every network path keeps the default `false`, so a missing - /// `api_key` env var still panics for `$call` / `$build_request`. - function to_primitive_client(self, lenient: bool = false) -> PrimitiveClient { - if (self.name.includes("/")) { - from_shorthand(self.name) - } else { - self.get_constructor()(lenient) - } - } - - function get_constructor( - self - ) -> ((bool) -> PrimitiveClient throws root.errors.InvalidArgument) throws never { - $rust_io_function - } - - function build_attempt(self) -> OrchestrationStep[] { - let planner_state = PlannerState { rr_client_names: [] } - self.build_attempt_with_state(planner_state) - } - - function build_attempt_with_state( - self, - planner_state: PlannerState, - ) -> OrchestrationStep[] { - match (self.client_type) { - ClientType.Primitive => { - let primitive = self.to_primitive_client(); - [OrchestrationStep { primitive_client: primitive, delay_ms: 0 }] - } - - ClientType.Fallback => { - let steps: OrchestrationStep[] = [] - for (let sub in self.sub_clients) { - for (let step in sub.build_plan_with_state(planner_state)) { - steps.push(step) - } - } - steps - } - - ClientType.RoundRobin => { - if (self.sub_clients.length() == 0) { - [] - } else { - let idx = planner_state.rr_pick_index( - self.name, - self.counter, - self.sub_clients.length(), - ); - self.sub_clients[idx].build_plan_with_state(planner_state) - } - } - } - } - - function build_plan(self) -> OrchestrationStep[] { - let planner_state = PlannerState { rr_client_names: [] }; - self.build_plan_with_state(planner_state) - } - - function build_plan_with_state( - self, - planner_state: PlannerState, - ) -> OrchestrationStep[] { - match (self.retry) { - let r: RetryPolicy => { - let result: OrchestrationStep[] = []; - let current_delay = r.initial_delay_or_default(); - - for (let attempt = 0; attempt <= r.max_retries; attempt += 1) { - let delay = 0; - if (attempt > 0) { - delay = root._trunc_to_int(current_delay) - let next = current_delay * r.multiplier_or_default(); - if (next > r.max_delay_or_default()) { - current_delay = r.max_delay_or_default(); - } else { - current_delay = next; - } - } - - let inner = self.build_attempt_with_state(planner_state); - for (let step in inner) { - result.push(OrchestrationStep { - primitive_client: step.primitive_client, - delay_ms: delay, - }); - } - } - - result - } - null => self.build_attempt_with_state(planner_state), - } - } - - function execute_stream( - self, - context: ExecutionContext, - inherited_delay_ms: int, - ) -> Stream { - match (self.retry) { - let r: RetryPolicy => { - let current_delay = r.initial_delay_or_default() - - for (let attempt = 0; attempt <= r.max_retries; attempt += 1) { - let attempt_delay = inherited_delay_ms - if (attempt > 0) { - attempt_delay = root._trunc_to_int(current_delay) - let next = current_delay * r.multiplier_or_default() - if (next > r.max_delay_or_default()) { - current_delay = r.max_delay_or_default() - } else { - current_delay = next - } - } - - if (attempt == r.max_retries) { - attempt_delay = inherited_delay_ms - } - - let result2: Stream = self.execute_once_stream( - context, - attempt_delay, - ) catch (e) { - _ => { continue; } - }; - - return result2; - } - - throw root.errors.DevOther { message: "All orchestration steps failed" }; - } - null => { - let result: Stream = self.execute_once_stream( - context, - inherited_delay_ms, - ); - return result; - }, - } - } - - function execute_once_stream( - self, - context: ExecutionContext, - active_delay_ms: int, - ) -> Stream { - match (self.client_type) { - ClientType.Primitive => { - let primitive = self.to_primitive_client(); - // On the stream path `reflect.type_of()` is `BuiltinUnknown`: - // the `$stream` companion calls this without explicit type-args and - // inferred type-args don't reach MIR (see `__make_stream`'s registry - // fallback). Resolve the real return type from the registry by name and - // use it for the whole attempt — the Jinja template may render - // `{{ ctx.output_format }}`, the closure's `${ctx.output_format}` needs - // it, and `build_request_stream` keys provider config off it. (The - // oneshot path instead uses `reflect.type_of()`, which DOES reach - // MIR there.) Caveat: for a *generic* streaming function the registry - // holds the unspecialized return type, so `${ctx.output_format}` renders - // the generic schema — the oneshot path renders the specialized one. - let return_type = get_return_type(context.function_name); - - // Render + specialize via the shared `render_specialized_prompt` (same - // path as oneshot and the static `build_request_stream` companion). - let specialized = primitive.render_specialized_prompt( - context.jinja_string, context.args, return_type, context.prompt_closure); - - let http_request = primitive.build_request_stream(specialized, return_type); - let sse = root.http.fetch_sse(http_request); - let stream: Stream = self.__make_stream(sse); - return stream; - } - - ClientType.Fallback => { - for (let sub in self.sub_clients) { - let result2: Stream = sub.execute_stream( - context, - active_delay_ms, - ) catch (e) { - _ => { continue; } - }; - return result2; - } - throw root.errors.DevOther { message: "All orchestration steps failed" }; - } - - ClientType.RoundRobin => { - if (self.sub_clients.length() == 0) { - throw root.errors.DevOther { message: "RoundRobin client has no sub-clients" }; - } - let idx = self.counter % self.sub_clients.length() - self.counter += 1 - let result3: Stream = self.sub_clients[idx].execute_stream( - context, - active_delay_ms, - ); - return result3; - } - } - } - - /// DO NOT CALL FROM USER CODE - function __make_stream(self, sse: root.http.SseStream) -> Stream { - let primitive = self.to_primitive_client() - let acc = primitive.new_stream_accumulator() - let cache: StreamCache = StreamCache.new(reflect.type_of(), reflect.type_of()) - Stream { _client: primitive, _acc: acc, _sse: sse, _cache: cache } - } - - function execute_oneshot( - self, - context: ExecutionContext, - inherited_delay_ms: int, - ) -> T { - match (self.retry) { - let r: RetryPolicy => { - let current_delay = r.initial_delay_or_default() - - for (let attempt = 0; attempt <= r.max_retries; attempt += 1) { - let attempt_delay = inherited_delay_ms - if (attempt > 0) { - attempt_delay = root._trunc_to_int(current_delay) - let next = current_delay * r.multiplier_or_default() - if (next > r.max_delay_or_default()) { - current_delay = r.max_delay_or_default() - } else { - current_delay = next - } - } - - if (attempt == r.max_retries) { - attempt_delay = inherited_delay_ms - } - - let result2: T = self.execute_once_oneshot( - context, - attempt_delay, - ) catch (e) { - _ => { continue; } - }; - - return result2; - } - - throw root.errors.DevOther { message: "All orchestration steps failed" }; - } - null => { - let result: T = self.execute_once_oneshot( - context, - inherited_delay_ms, - ); - return result; - }, - } - } - - function execute_once_oneshot( - self, - context: ExecutionContext, - active_delay_ms: int, - ) -> T { - match (self.client_type) { - ClientType.Primitive => { - let primitive = self.to_primitive_client() - let return_type = reflect.type_of() - - // Render (new-mode closure or legacy Jinja) + specialize via the shared - // `render_specialized_prompt`, the same path the static `build_request` - // companion uses — so execution and preview never drift. - let specialized = primitive.render_specialized_prompt( - context.jinja_string, context.args, return_type, context.prompt_closure) - let http_request = primitive.build_request(specialized, return_type) - let http_response = root.http.send(http_request) - - if (http_response.ok()) { - let body = http_response.text() - let result: T = primitive.parse(body) catch (e) { - _ => { - if (active_delay_ms > 0) { - root.sys.sleep(root.time.Duration.from_nanoseconds(active_delay_ms * 1000000n)) - } - throw e; - } - }; - result - } else { - let error_body = http_response.text() catch (e) { - _ => { "" } - }; - if (active_delay_ms > 0) { - root.sys.sleep(root.time.Duration.from_nanoseconds(active_delay_ms * 1000000n)) - } - throw root.errors.DevOther { message: "HTTP request failed: " + error_body }; - } - } - - ClientType.Fallback => { - for (let sub in self.sub_clients) { - let result2: T = sub.execute_oneshot( - context, - active_delay_ms, - ) catch (e) { - _ => { continue; } - }; - return result2; - } - throw root.errors.DevOther { message: "All orchestration steps failed" }; - } - - ClientType.RoundRobin => { - if (self.sub_clients.length() == 0) { - throw root.errors.DevOther { message: "RoundRobin client has no sub-clients" }; - } - let idx = self.counter % self.sub_clients.length() - self.counter += 1 - let result3: T = self.sub_clients[idx].execute_oneshot( - context, - active_delay_ms, - ); - return result3; - } - } - } -} -// --------------------------------------------------------------------------- -// @providers annotations -// --------------------------------------------------------------------------- -// The `// @providers: ` comments below are NOT regular comments -- -// they are parsed at build time by `baml_compiler2_ast/build.rs` -// (`extract_provider_configs`) to associate each provider name with its -// options class. The compiler uses this mapping during `lower_cst` to -// validate and type-check `client` declarations: when a user writes -// `provider "aws-bedrock"`, the compiler knows the options block should -// match `BedrockOptions`. -// -// `@providers-no-options` lists providers that accept `PrimitiveClientOptions` -// directly and have no dedicated options class. -// -// To add a new provider: add its name here (and a new options class + annotation -// if it has provider-specific options). -// --------------------------------------------------------------------------- - -// @providers-no-options: openai, openai-generic, openai-responses, ai-gateway-images, ollama, openrouter, fallback, round-robin - -class MediaUrlHandler { - image string | null - audio string | null - video string | null - pdf string | null -} - -class PrimitiveClientOptions { - model string | null - base_url string | null - allowed_role_metadata unknown | null - finish_reason_allow_list string[] | null - finish_reason_deny_list string[] | null - supports_streaming bool | null - default_role string | null - allowed_roles string[] | null - remap_roles map | null - api_key string | null - provider_options AnthropicOptions | AzureOpenAiOptions | BedrockOptions | GoogleAiOptions | VertexAiOptions | null - media_url_handler MediaUrlHandler | null - - // request stuff here - headers map - query_params map - request_body map -} - -// @providers: azure-openai -/// Provider-specific options for `azure-openai` clients. -class AzureOpenAiOptions { - resource_name string | null - deployment_id string | null - api_version string - max_tokens int | null -} - -// @providers: anthropic -/// Provider-specific options for `anthropic` clients. -class AnthropicOptions { - max_tokens int | null -} - -// @providers: google-ai -/// Provider-specific options for `google-ai` clients. -/// -/// The Google Cloud fields are ignored while the client uses the Gemini API. -/// When `enterprise` or a `GOOGLE_GENAI_USE_*` environment flag selects the -/// Vertex backend, they have exactly the same meaning as on `VertexAiOptions`. -class GoogleAiOptions { - /// Route this client through the Gemini Enterprise backend, an alias for - /// Vertex AI (google-genai's `GOOGLE_GENAI_USE_ENTERPRISE`). Uses Google - /// Cloud credentials (ADC), and an unset location defaults to `global`. - /// Equivalent to setting `GOOGLE_GENAI_USE_ENTERPRISE=true`. - enterprise bool | null - credentials string | null - credentials_content string | null - location string | null - project_id string | null -} - -// @providers: vertex-ai -/// Provider-specific options for `vertex-ai` clients. -class VertexAiOptions { - /// Path to a Google credential JSON file (service account, authorized user, - /// workload identity federation, or impersonated service account). Inline - /// JSON is not supported here; use `credentials_content` for inline - /// credentials. When neither is set, Application Default Credentials apply. - credentials string | null - /// Inline credential JSON string. Used when `credentials` is not set. - credentials_content string | null - location string | null - project_id string | null -} - -// @providers: aws-bedrock -/// Provider-specific options for `aws-bedrock` clients. -class BedrockOptions { - region string | null - endpoint_url string | null - access_key_id string | null - secret_access_key string | null - session_token string | null - profile string | null - stop_sequences string[] | null - max_tokens int | null - temperature float | null - top_p float | null -} - -class StreamAccumulator { - _handle $rust_type - - function add_events(self, events: string) -> null throws root.errors.LlmClient { - $rust_io_function - } - - function content(self) -> string throws root.errors.LlmClient { - $rust_io_function - } - - function is_done(self) -> bool throws root.errors.LlmClient { - $rust_io_function - } - - function model(self) -> string? throws root.errors.LlmClient { - $rust_io_function - } - - function finish_reason(self) -> string? throws root.errors.LlmClient { - $rust_io_function - } - - function input_tokens(self) -> int? throws root.errors.LlmClient { - $rust_io_function - } - - function output_tokens(self) -> int? throws root.errors.LlmClient { - $rust_io_function - } -} - -/// DO NOT USE FROM USER CODE -/// Cached data for a stream. Contains type information, assert check cache, etc. -/// -/// Intended for use in `baml.llm.Stream` -class StreamCache { - _data $rust_type - - /// DO NOT CALL FROM USER CODE - function new(t_stream: type, t_final: type) -> StreamCache throws never { - $rust_io_function - } -} - -/// `Stream` provides a streaming interface for LLM calls. -/// -/// It is a pull-based interface: the sse stream is only flushed into the accumulator -/// and the value is only parsed when `next()` or `final()` is called. -/// -/// Created by `$stream` companion functions, -/// internally using `baml.llm.stream_llm_function`. -class Stream { - _client: PrimitiveClient - _acc: StreamAccumulator - _sse: root.http.SseStream - _cache: StreamCache - - /// Return the next partial value, or a `StreamFinished` value if the stream is done. - /// Will only return partial values: if the stream is done, use `final()` to get the final value. - /// Blocks until a partial value is available. - function next(self) -> TStream | root.stream.StreamFinished { - while (true) { - let next = match (self._sse.next()) { - null => { - return root.stream.StreamFinished {}; - } - let next: string => next, - }; - self._acc.add_events(next); - if (self._acc.is_done()) { - self._sse.close(); - return root.stream.StreamFinished {}; // user should call final() to get the final value - } - - let content = self._acc.content(); - let parsed: TStream | root.stream.StreamNoYield = __sap_parse_partial(content, self._cache); - match (parsed) { - root.stream.StreamNoYield => { - continue; - } - let parsed: TStream => { - return parsed; - } - } - } - root.sys.panic("Unreachable") - } - /// Return the final value of the stream. - /// If the final value is not yet available, this will block until the stream is done. - /// Calling this twice will return the same value. - function final(self) -> TFinal { - while (!self._acc.is_done()) { - let next = match (self._sse.next()) { - null => { - break; - } - let next: string => next, - }; - self._acc.add_events(next); - if (self._acc.is_done()) { - self._sse.close(); - break; - } - } - match (self._acc.finish_reason()) { - null => { - throw root.errors.LlmClient { message: "Streaming finished without finish_reason" }; - }, - let reason: string => self._client.validate_finish_reason(reason), - } - - let content = self._acc.content(); - __sap_parse_final(content, self._cache) - } -} - -class PrimitiveClient { - name string - provider string - options PrimitiveClientOptions - - function render_prompt( - self, - template: string, - args: map, - return_type: type, - ) -> PromptAst throws root.errors.RenderPrompt { - $rust_io_function - } - - function specialize_prompt( - self, - prompt: PromptAst, - ) -> PromptAst throws root.errors.RenderPrompt | root.errors.LlmClient { - $rust_io_function - } - - /// BEP-049 M5: turn a function's prompt into a request-ready (specialized) - /// `PromptAst`, dispatching on mode. A new-mode `prompt`…`` compiles to a - /// `(Context) -> PromptAst` closure rendered against a per-attempt `Context`; - /// a legacy `#"..."#` function (null closure) renders the stored Jinja - /// `template`. This is the SINGLE source of truth for prompt rendering: the - /// orchestrator (`execute_once_oneshot` / `execute_stream`) and the static - /// `render_prompt` / `build_request` companions both call it, so the playground - /// preview/cURL and actual execution render byte-identically. - function render_specialized_prompt( - self, - template: string, - args: map, - return_type: type, - prompt_closure: ((Context) -> PromptAst throws never)?, - ) -> PromptAst throws root.errors.RenderPrompt | root.errors.LlmClient { - let prompt = match (prompt_closure) { - null => self.render_prompt(template, args, return_type), - let closure: (Context) -> PromptAst throws never => closure(build_prompt_context(self, return_type)), - }; - self.specialize_prompt(prompt) - } - - function build_request( - self, - prompt: PromptAst, - return_type: type, - ) -> root.http.Request throws root.errors.LlmClient { - $rust_io_function - } - - function build_request_stream( - self, - prompt: PromptAst, - return_type: type, - ) -> root.http.Request throws root.errors.LlmClient { - $rust_io_function - } - - function new_stream_accumulator(self) -> StreamAccumulator throws root.errors.LlmClient { - $rust_io_function - } - - function validate_finish_reason( - self, - finish_reason: string, - ) -> null throws root.errors.LlmClient { - $rust_io_function - } - - /// DO NOT CALL FROM USER CODE - /// Requires the response to be done (no partials). - /// - /// The `type_def` parameter is automatically threaded by the compiler from - /// the call-site type argument `` — callers write `primitive.parse(body)` - /// without passing an explicit type value. - function parse( - self, - http_response_body: string, - ) -> T throws root.errors.LlmClient { - $rust_io_function - } -} - -function from_shorthand( - shorthand: string, -) -> PrimitiveClient throws root.errors.InvalidArgument { - $rust_io_function -} - -function get_jinja_template(function_name: string) -> string throws root.errors.InvalidArgument { - $rust_io_function -} - -/// BEP-049 §10 (M5d). Assemble a `PromptAst` from a tagged template's flattened -/// `parts`/`values`. A `Role` value (from `${role("system")}`) starts a new chat -/// message. Other values use the same recursive rendering and `baml.ToString` -/// overrides as `string.from`, except nested media stays structural instead of -/// becoming text. With no `Role` values the whole template becomes a simple -/// prompt. -//baml:mut_vm -//baml:may_yield -function assemble_prompt_ast(parts: string[], values: unknown[]) -> PromptAst throws never { - $rust_function -} - -/// BEP-049 §10 (M5b). Render `return_type`'s schema with default options — the -/// string `ctx.output_format` exposes to a `prompt` body. The orchestrator -/// calls this with the call's return type to populate `Context.output_format`, -/// producing output byte-equivalent to the legacy Jinja `{{ ctx.output_format }}`. -function render_output_format(return_type: type) -> string throws never { - $rust_io_function -} - -/// BEP-049 §10 (M5b.2). Build the opaque output-format schema handle for -/// `return_type`, for `Context._output_format`. The orchestrator builds this -/// once per attempt; `ctx.output_format_with(...)` renders it with options. -function build_output_format(return_type: type) -> OutputFormat throws never { - $rust_io_function -} - -/// BEP-049 §10 (M5e). Assemble the per-attempt `Context` a `prompt`…`` closure -/// is invoked with: the chosen primitive client's view (`ctx.client.*`) plus the -/// return type's output-format schema. Built AFTER the orchestrator picks the -/// client for this attempt, so `${if (ctx.client.provider == ...)}` and -/// `${ctx.output_format}` see the live client/type. Shared by oneshot + stream. -function build_prompt_context(primitive: PrimitiveClient, return_type: type) -> Context throws never { - Context { - client: ContextClient { - name: primitive.name, - provider: primitive.provider, - default_role: primitive.options.default_role ?? "system", - allowed_roles: primitive.options.allowed_roles ?? [], - }, - tags: {}, - output_format: render_output_format(return_type), - _output_format: build_output_format(return_type), - } -} - -/// The `role(...)` constructor passed into a `prompt` body lambda. Builds a -/// typed `Role` marker; `prompt` threads this in as the body's `role` parameter. -function make_role(name: string) -> Role throws never { - Role { name: name, metadata: {} } -} - -/// BEP-049 §10. The built-in `prompt` tag: `` prompt`...` `` evaluates to a -/// `(Context) -> PromptAst` closure that the LLM orchestrator invokes after it -/// has resolved the client for the current attempt (lazy per-attempt eval). -/// -/// The body lambda receives `role` (a `Role` constructor) and `ctx` (per-render -/// `Context`); `${role("user")}` markers split the rendered template into chat -/// messages. `ctx.output_format` schema injection lands in a later slice. -//baml:tagged_string -function prompt( - body: (role: (string) -> Role throws never, ctx: Context) -> baml.TaggedString throws never, -) -> (Context) -> PromptAst throws never { - let render = (ctx: Context) -> { - let t = body(make_role, ctx) - assemble_prompt_ast(t.parts, t.values) - } - render -} - -function get_return_type(function_name: string) -> type throws root.errors.InvalidArgument { - $rust_io_function -} - -/// DO NOT CALL FROM USER CODE -function __sap_parse_final(json: string, cache: StreamCache) -> TFinal throws root.errors.LlmClient { - $rust_io_function -} - -/// DO NOT CALL FROM USER CODE -function __sap_parse_partial(json: string, cache: StreamCache) -> TStream | root.stream.StreamNoYield throws root.errors.LlmClient { - $rust_io_function -} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/prompt.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/prompt.baml new file mode 100644 index 00000000000..f0acdb665ca --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/prompt.baml @@ -0,0 +1,64 @@ +/// Opaque handle to a prebuilt output-format schema. This supports the +/// standalone `prompt` tag's parameterized output-format rendering. +class OutputFormat { + _data $rust_type +} + +/// A typed chat-role marker produced by `${role("system")}` in a prompt. +class Role { + name string + /// Provider-specific per-message metadata such as cache control. + metadata map +} + +/// Client metadata available to a standalone `prompt` tag body. +class ContextClient { + name string + provider string + default_role string + allowed_roles string[] +} + +/// Render context accepted by a standalone `prompt` tag closure. +class Context { + client ContextClient + tags map + output_format string + _output_format OutputFormat + + function output_format_with( + self, + prefix: string? = null, + or_splitter: string? = null, + enum_value_prefix: string? = null, + hoisted_class_prefix: string? = null, + always_hoist_enums: bool? = null, + quote_class_fields: bool? = null, + hoist_classes: string[]? = null, + map_style: string? = null, + render_null_as: string? = null, + ) -> string throws never { + $rust_io_function + } +} + +/// Render a return type's schema with the default output-format options. +function render_output_format(return_type: type) -> string throws never { + $rust_io_function +} + +/// Build the output-format handle used by `Context.output_format_with`. +function build_output_format(return_type: type) -> OutputFormat throws never { + $rust_io_function +} + +/// Construct a typed role marker for structural prompt assembly. +function make_role(name: string) -> Role throws never { + Role { name: name, metadata: {} } +} + +/// Look up an LLM function's declared return type. Retained for type-reflection +/// compatibility; the `ai.Agent` path uses lexical type arguments instead. +function get_return_type(function_name: string) -> type throws root.errors.InvalidArgument { + $rust_io_function +} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/sys_llm_types.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/sys_llm_types.baml new file mode 100644 index 00000000000..f9324c6d8d2 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/baml/ns_prompt/sys_llm_types.baml @@ -0,0 +1,68 @@ +// Internal compatibility schemas for the Rust-only `sys_llm` implementation. +// They are generated into Rust data types but are not part of the `ai.Agent` +// execution architecture. New provider implementations expose `ai.Client` +// values and lower `ai.Prompt` directly. + +class MediaUrlHandler { + image string | null + audio string | null + video string | null + pdf string | null +} + +class PrimitiveClientOptions { + model string | null + base_url string | null + allowed_role_metadata unknown | null + finish_reason_allow_list string[] | null + finish_reason_deny_list string[] | null + supports_streaming bool | null + default_role string | null + allowed_roles string[] | null + remap_roles map | null + api_key string | null + provider_options AnthropicOptions | AzureOpenAiOptions | BedrockOptions | GoogleAiOptions | VertexAiOptions | null + media_url_handler MediaUrlHandler | null + headers map + query_params map + request_body map +} + +class AzureOpenAiOptions { + resource_name string | null + deployment_id string | null + api_version string + max_tokens int | null +} + +class AnthropicOptions { + max_tokens int | null +} + +class GoogleAiOptions { + enterprise bool | null + credentials string | null + credentials_content string | null + location string | null + project_id string | null +} + +class VertexAiOptions { + credentials string | null + credentials_content string | null + location string | null + project_id string | null +} + +class BedrockOptions { + region string | null + endpoint_url string | null + access_key_id string | null + secret_access_key string | null + session_token string | null + profile string | null + stop_sequences string[] | null + max_tokens int | null + temperature float | null + top_p float | null +} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_sap/sap.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_sap/sap.baml index 7629ffc563e..f4d3db2041c 100644 --- a/baml_language/crates/baml_builtins2/baml_std/baml/ns_sap/sap.baml +++ b/baml_language/crates/baml_builtins2/baml_std/baml/ns_sap/sap.baml @@ -1,15 +1,56 @@ // Public Schema-Aligned Parsing (SAP) exposes the same coercion used by BAML's -// built-in LLM path to BAML-authored providers. +// built-in LLM path to BAML-authored providers. The cache and no-yield marker +// below are parser implementation details shared by ordinary SAP and +// `ai.stream.Stream`; they are not prompt or transport concepts. + +/// Cached type and assertion data used by incremental schema-aligned parsing. +class ParseCache { + _data $rust_type + + /// Internal constructor. Callers normally use `__new_parse_cache` so the + /// type arguments determine both runtime type descriptors. + function new(t_stream: type, t_final: type) -> ParseCache throws never { + $rust_io_function + } +} + +/// Internal sentinel returned when an incremental parse needs more text. +class NoYield {} + +/// Internal typed cache constructor shared by `ai.stream` and public SAP. +function __new_parse_cache() -> ParseCache throws never { + let cache: ParseCache = ParseCache.new( + reflect.type_of(), + reflect.type_of(), + ) + cache +} + +/// Internal final SAP operation shared by streams and public SAP. +function __parse_final( + json: string, + cache: ParseCache, +) -> TFinal throws root.errors.LlmClient { + $rust_io_function +} + +/// Internal partial SAP operation used by `ai.stream.Stream`. +function __parse_partial( + json: string, + cache: ParseCache, +) -> TStream | NoYield throws root.errors.LlmClient { + $rust_io_function +} /// Parse an LLM's raw text output into `T` with Schema-Aligned Parsing. function parse(text: string) -> T throws root.errors.LlmClient { - let cache = root.llm.__new_stream_cache(); - root.llm.__sap_parse_final(text, cache) + let cache = __new_parse_cache(); + __parse_final(text, cache) } /// Parse against a runtime `type` value when the schema is data rather than a /// lexical type parameter. Narrow the returned `unknown` at the call site. function parse_type(t: type, text: string) -> unknown throws root.errors.LlmClient { - let cache: root.llm.StreamCache = root.llm.StreamCache.new(t, t); - root.llm.__sap_parse_final(text, cache) + let cache: ParseCache = ParseCache.new(t, t); + __parse_final(text, cache) } diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_stream/stream.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_stream/stream.baml deleted file mode 100644 index 0f7d37e8256..00000000000 --- a/baml_language/crates/baml_builtins2/baml_std/baml/ns_stream/stream.baml +++ /dev/null @@ -1,9 +0,0 @@ -/// A sentinel value returned by streaming handles when the stream is done. -/// The reason this is used instead of `null` is that `null` is a valid return value, -/// whereas this class will probably never be used as a return value. -class StreamFinished {} - -/// (internal) -/// -/// A sentinel used when a partial parse didn't return anything and we should wait for more data. -class StreamNoYield {} diff --git a/baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml b/baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml index 18062859d8b..812ddcbc020 100644 --- a/baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml +++ b/baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml @@ -1,4 +1,4 @@ -/// Options for `exec` and `shell`. +/// Options for `exec`, `start_process`, and `shell`. class ProcessOptions { /// Working directory for the child process. cwd string? @@ -8,6 +8,12 @@ class ProcessOptions { timeout_ms int? /// Data to write to the child process's stdin. stdin string? + /// Keep a writable stdin pipe on a process returned by `start_process`. + /// + /// When this is `true`, use `Process.write_stdin` to send data incrementally + /// and `Process.close_stdin` to deliver EOF. This option has no effect on + /// `exec` or `shell`. + keep_stdin_open bool? } /// The result of running an external process with `exec` or `shell`. @@ -22,11 +28,105 @@ class ShellOutput { } } +/// The terminal status of a process created with `start_process`. +class ProcessExit { + exit_code int + signal string? + + /// Returns `true` if the process exited normally with code `0`. + function ok(self) -> bool { + self.exit_code == 0 + } +} + +/// A live line-oriented child-process output stream. +/// +/// Iteration waits for one complete UTF-8 line without blocking other BAML +/// green threads. The final unterminated line is yielded at EOF. +class ProcessLineStream { + _handle $rust_type + + implements root.iter.Iterable { + type Item = string + type Error = root.errors.Io | root.errors.Timeout + + function iter(self) -> root.iter.Iterator throws never { + self + } + } + + implements root.iter.Iterator { + type Item = string + type Error = root.errors.Io | root.errors.Timeout + + function next(self) -> string | root.iter.Done throws root.errors.Io | root.errors.Timeout { + match (self._next()) { + let line: string => line, + null => root.iter.Done {}, + } + } + } + + /// Wait for and return the next line, or `null` at EOF. + function _next(self) -> string? throws root.errors.Io | root.errors.Timeout { + $rust_io_function + } + + /// Stop reading this stream. Later reads return EOF. + function close(self) -> null throws never { + $rust_io_function + } +} + +/// A live child process created with `start_process`. +/// +/// `stdout` is consumed incrementally. Stderr inherits the parent process so +/// diagnostics remain visible without risking a blocked, unread stderr pipe. +class Process { + stdout ProcessLineStream + _handle $rust_type + + /// Write data to the child process's open stdin pipe. + function write_stdin(self, data: string) -> null throws root.errors.Io { + $rust_io_function + } + + /// Close the child process's stdin pipe, delivering EOF. + function close_stdin(self) -> null throws root.errors.Io { + $rust_io_function + } + + /// Wait for the child to exit. + function wait(self) -> ProcessExit throws root.errors.Io | root.errors.Timeout { + $rust_io_function + } + + /// Request that the child terminate. + function kill(self) -> null throws root.errors.Io { + $rust_io_function + } + + /// Terminate an un-waited child and close its stdout stream. + /// + /// Safe to use with `defer { process.close() }`. + function close(self) -> null throws never { + $rust_io_function + } +} + /// Runs `program` with the given `args` and returns its output. Throws on I/O failure or timeout. function exec(program: string, args: string[]?, options: ProcessOptions?) -> ShellOutput throws root.errors.Io | root.errors.Timeout { $rust_io_function } +/// Starts `program` and returns immediately with a live stdout line stream. +/// +/// Unlike `exec`, output is not buffered until process exit. Reading +/// `process.stdout` suspends only the current BAML green thread. +function start_process(program: string, args: string[]?, options: ProcessOptions?) -> Process throws root.errors.Io { + $rust_io_function +} + /// Runs a shell `command` string (passed to `/bin/sh -c`) and returns its output. function shell(command: string, options: ProcessOptions?) -> ShellOutput throws root.errors.Io | root.errors.Timeout { $rust_io_function diff --git a/baml_language/crates/baml_builtins2/baml_std/claude_code/cli.baml b/baml_language/crates/baml_builtins2/baml_std/claude_code/cli.baml new file mode 100644 index 00000000000..1ec6e9c75fd --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/claude_code/cli.baml @@ -0,0 +1,49 @@ +// A harness client: the authenticated Claude Code CLI as a ai.Client. +// The transport is a local process, not HTTP; the output contract travels +// natively via --json-schema; BAML tools ride the `outcome` envelope. + +class ClaudeCodeClient { + executable: string, + model: string, + cwd: string?, + timeout_ms: int, + permission_mode: string, + harness_tools: string[], // Claude Code's OWN tools; BAML tools use the envelope + // MCP servers attached to the harness, name -> config (the value shape of + // an .mcp.json "mcpServers" entry). The map is read fresh on every + // invoke, so an entry added between turns attaches on the next turn. + mcp_servers: map, + + function new( + model: string = "haiku", + executable: string = "claude", + cwd: string? = null, + timeout_ms: int = 240000, + permission_mode: string = "default", + harness_tools: string[] = [], + mcp_servers: map? = null, + ) -> ClaudeCodeClient { + let servers: map = {}; + ClaudeCodeClient { + executable: executable, + model: model, + cwd: cwd, + timeout_ms: timeout_ms, + permission_mode: permission_mode, + harness_tools: harness_tools, + mcp_servers: mcp_servers ?? servers, + } + } + + implements ai.Client { + function id(self) -> string { + `claude-code/${self.model}` + } + + function invoke(self, input: ai.ModelTurnInput) -> ai.ModelTurn { + root.internal.invoke(self, input) catch_all (e) { + _ => throw ai.errors.normalize(e), + } + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/claude_code/ns_internal/cli.baml b/baml_language/crates/baml_builtins2/baml_std/claude_code/ns_internal/cli.baml new file mode 100644 index 00000000000..c14e5e28945 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/claude_code/ns_internal/cli.baml @@ -0,0 +1,384 @@ +// Claude Code CLI internals: prompt assembly, the outcome envelope, the +// process run, and result normalization. + +// The journal as labeled text. Claude Code's -p mode takes one prompt, so +// the conversation folds into it; the CLI's own session state is unused. +function transcript_text(j: ai.Journal) -> string { + let lines: string[] = []; + for (let e in j.entries()) { + match (e) { + let u: ai.events.UserMessage => { + lines.push(`user: ${u.content}`); + }, + let a: ai.events.AssistantMessage => { + for (let b in a.content) { + match (b) { + let t: ai.content.Text => { + lines.push(`assistant: ${t.text}`); + }, + let c: ai.content.ToolUse => { + lines.push( + `assistant tool_call: {"id": "${c.id}", "name": "${c.name}", "args": ${baml.json.stringify(baml.json.to_json(c.args))}}`, + ); + }, + _ => { + null + }, + }; + } + }, + let done: ai.events.ToolCompleted => { + lines.push(`tool result id=${done.id} is_error=false: ${done.output}`); + }, + let failed: ai.events.ToolFailed => { + lines.push(`tool result id=${failed.id} is_error=true: ${failed.message}`); + }, + _ => { + null + }, + }; + } + if (lines.length() == 0) { + "" + } else { + `\n\nConversation so far:\n${lines.join("\n")}` + } +} + +// {outcome: anyOf [T-schema, calls-schema]} — the constrained output that +// lets one CLI run return either the final result or BAML tool calls. T's +// $defs lift to the envelope root so its $refs stay valid. +function envelope_schema(output_type: type) -> map { + let final_root = baml.json.from_json>(baml.json.schema(output_type)); + let definitions = final_root.delete("$defs"); + let call_props: map = {}; + let _ = call_props.set("id", _type_schema("string")); + let _ = call_props.set("name", _type_schema("string")); + let _ = call_props.set("args", _type_schema("object")); + let call_item: map = {}; + let _ = call_item.set("type", "object"); + let _ = call_item.set("properties", call_props); + let _ = call_item.set("required", ["id", "name", "args"]); + let _ = call_item.set("additionalProperties", false); + let calls_array: map = {}; + let _ = calls_array.set("type", "array"); + let _ = calls_array.set("items", call_item); + let calls_props: map = {}; + let _ = calls_props.set("calls", calls_array); + let calls_schema: map = {}; + let _ = calls_schema.set("type", "object"); + let _ = calls_schema.set("properties", calls_props); + let _ = calls_schema.set("required", ["calls"]); + let _ = calls_schema.set("additionalProperties", false); + let outcome: map = {}; + let _ = outcome.set("anyOf", [baml.json.to_json(final_root), baml.json.to_json(calls_schema)]); + let props: map = {}; + let _ = props.set("outcome", outcome); + let envelope: map = {}; + let _ = envelope.set("type", "object"); + let _ = envelope.set("properties", props); + let _ = envelope.set("required", ["outcome"]); + let _ = envelope.set("additionalProperties", false); + if (definitions != null) { + let _ = envelope.set("$defs", definitions); + } + envelope +} + +function _type_schema(t: string) -> map { + let m: map = {}; + let _ = m.set("type", t); + m +} + +// The envelope protocol, appended to the prompt when BAML tools are active. +// The guidance about never repeating a successful call is load-bearing: +// each invoke is a fresh CLI process, and the conversation text is the only +// memory it has. +function tool_protocol(tb: ai.tools.Toolbox) -> string { + let catalog: string[] = []; + for (let t in tb.list()) { + catalog.push( + `{"name": "${t.name}", "description": "${t.description}", "parameters": ${baml.json.stringify(baml.json.to_json(t.input_schema))}}`, + ); + } + `\n\nThe output contract is one object with an outcome field. Set outcome +to either the final result or a tool request. The tools in the catalog +below are NOT installed in your environment — never attempt to invoke +them directly; any such attempt fails. The ONLY way to run one is to +finish your reply with outcome set to +{"calls": [{"id": "", "name": "", "args": {...}}]}; +the caller executes the calls and re-invokes you with their results in +the conversation. If the Conversation so far section shows no tool +results yet, your outcome MUST be a calls request — never invent data a +tool can provide. After tool results appear in the conversation, use +them; the Conversation so far section is authoritative. A tool result +with is_error=false means that call already ran — never repeat the same +successful tool call. Request only tools that are still necessary, or +return the final result. + +Application tool catalog (request via outcome.calls only): +[${catalog.join(", ")}]` +} + +// Consume the live JSONL stream: log every event as it arrives, return the +// terminal result event. +function _read_result(p: baml.sys.Process) -> json { + let result: json = null; + for (let line in p.stdout) { + let trimmed = line.trim(); + if (trimmed.length() > 0) { + let raw = baml.json.parse(trimmed) catch_all (e) { + _ => { + throw ai.errors.ParseFailed { provider: "claude-code", raw_output: trimmed } + }, + }; + _log_cc_event(raw); + if (baml.json.path_or(raw, ".type", "") == "result") { + result = raw; + } + } else { + null + }; + } + if (result == null) { + throw ai.errors.ParseFailed { + provider: "claude-code", + raw_output: "the Claude Code CLI stream ended without a result event", + } + } + result +} + +function _preview(text: string) -> string { + if (text.length() > 120) { + `${text.substring(0, 120)}...` + } else { + text + } +} + +// One log line per Claude Code JSONL event: the harness's own transcript. +function _log_cc_event(raw: json) -> null { + let kind = baml.json.path_or(raw, ".type", "?"); + if (kind == "system") { + let subtype = baml.json.path_or(raw, ".subtype", "?"); + if (subtype == "thinking_tokens") { + // a progress counter, never content — the thinking text arrives + // later inside the assistant message's thinking block + log.debug( + `cc event: thinking ~${baml.json.path_or(raw, ".estimated_tokens", 0)} tokens`, + ); + } else { + log.info( + `cc event: system/${subtype} model=${baml.json.path_or(raw, ".model", "?")}`, + ); + }; + } else if (kind == "assistant" || kind == "user") { + let parts: string[] = []; + let blocks = baml.json.path_or(raw, ".message.content", []); + for (let b in blocks) { + let bt = baml.json.path_or(b, ".type", "?"); + match (bt) { + "text" => parts.push(`text: ${_preview(baml.json.path_or(b, ".text", ""))}`), + "thinking" => { + parts.push( + `thinking: ${_preview(baml.json.path_or(b, ".thinking", ""))}`, + ) + }, + "tool_use" => parts.push(`tool_use: ${baml.json.path_or(b, ".name", "?")}`), + "tool_result" => parts.push("tool_result"), + _ => parts.push(bt), + }; + } + log.info(`cc event: ${kind} [${parts.join(" | ")}]`); + } else if (kind == "result") { + null // logged separately with cost and session + } else { + log.info(`cc event: ${kind}`); + }; + null +} + +// The client's mcp_servers as one --mcp-config JSON string, or null when no +// server is attached. The map is read here, at render time, so a server +// added between turns rides the next turn's process. +function mcp_config_json(c: root.ClaudeCodeClient) -> string? { + if (c.mcp_servers.length() == 0) { + null + } else { + let wrapper: map = {}; + let _ = wrapper.set("mcpServers", c.mcp_servers); + baml.json.stringify(baml.json.to_json(wrapper)) + } +} + +// One mcp__ entry per attached server: the --allowedTools value that +// permits every tool the server provides. +function allowed_mcp_tools(c: root.ClaudeCodeClient) -> string { + c.mcp_servers.keys() + .map((n: string) -> { + `mcp__${n}` + }) + .join(",") +} + +// One model turn = one CLI run: assemble the prompt, exec, normalize. +function invoke( + c: root.ClaudeCodeClient, + input: ai.ModelTurnInput, +) -> ai.ModelTurn { + // the contract travels via --json-schema, so the prompt's schema slot + // renders empty + // The CLI accepts one string rather than a chat-message array. Keep the + // structural prompt until this transport boundary, then use its readable + // role-headed projection. + let instructions = input.prompt("").text(); + let has_tools = !input.toolbox.is_empty(); + let schema = if (has_tools) { + baml.json.to_json(envelope_schema(input.output_type)) + } else { + baml.json.schema(input.output_type) + }; + let prompt_text = if (has_tools) { + `${instructions}${transcript_text(input.journal)}${tool_protocol(input.toolbox)}` + } else { + `${instructions}${transcript_text(input.journal)}` + }; + + log.info( + `claude-code invoke: model=${c.model} prompt_chars=${prompt_text.length()} baml_tools=${input.toolbox.list().length()}`, + ); + let args: string[] = []; + args.push("-p"); + args.push("--output-format"); + args.push("stream-json"); + args.push("--verbose"); + args.push("--model"); + args.push(c.model); + args.push("--permission-mode"); + args.push(c.permission_mode); + args.push("--no-session-persistence"); + args.push("--tools"); + args.push(c.harness_tools.join(",")); + // --tools governs the built-in set only; MCP servers attach separately. + // --strict-mcp-config keeps the set deterministic: only the client's + // servers, never inherited user or project MCP configuration. -p mode + // cannot answer a permission prompt, so attaching a server implies + // allowing its tools; the equals form matters, because --allowedTools + // is variadic and would otherwise swallow the trailing prompt argument. + if let mcp: string = mcp_config_json(c) { + args.push("--mcp-config"); + args.push(mcp); + args.push("--strict-mcp-config"); + args.push(`--allowedTools=${allowed_mcp_tools(c)}`); + } + args.push("--json-schema"); + args.push(baml.json.stringify(schema)); + args.push(prompt_text); + + let p = baml.sys.start_process( + c.executable, + args, + baml.sys.ProcessOptions { cwd: c.cwd, env: null, timeout_ms: c.timeout_ms, stdin: null }, + ) catch_all (e) { + _ => { + throw ai.errors.NetworkFailure { + provider: "claude-code", + detail: `the Claude Code CLI could not start: ${e.to_string()}`, + raw_body: null, + } + }, + }; + defer { + p.close() + } + // the CLI reads stdin when the pipe is open; deliver EOF immediately + let _ = p.close_stdin() catch_all (e) { + _ => null, + }; + // events stream LIVE: each JSONL line logs as it arrives, and the last + // relevant line is the result. Stderr inherits the parent process. + let result = _read_result(p); + let exit = p.wait() catch_all (e) { + _ => { + throw ai.errors.NetworkFailure { + provider: "claude-code", + detail: `the Claude Code CLI did not terminate cleanly: ${e.to_string()}`, + raw_body: null, + } + }, + }; + if (!exit.ok()) { + throw ai.errors.InvalidRequest { + provider: "claude-code", + status_code: null, + detail: `the Claude Code CLI exited with code ${exit.exit_code}`, + raw_body: null, // stderr inherits the parent and is not captured + } + } + + if (baml.json.path_or(result, ".is_error", false)) { + throw ai.errors.Refused { + provider: "claude-code", + reason: baml.json.path_or(result, ".subtype", "error"), + raw_body: baml.json.stringify(result), + } + } + log.info( + `claude-code result: subtype=${baml.json.path_or(result, ".subtype", "?")} cost_usd=${baml.json.path_or(result, ".total_cost_usd", 0.0)} session=${baml.json.path_or(result, ".session_id", "?")}`, + ); + let structured = baml.json.path(result, ".structured_output") catch_all (e) { + _ => { + baml.json.parse(baml.json.path_or(result, ".result", "")) catch_all (inner) { + _ => { + throw ai.errors.ParseFailed { + provider: "claude-code", + raw_output: baml.json.stringify(result), + } + }, + } + } + }; + + let usage = ai.events.Usage { + input_tokens: baml.json.path_or(result, ".usage.input_tokens", 0), + output_tokens: baml.json.path_or(result, ".usage.output_tokens", 0), + cached_input_tokens: baml.json.path_or(result, ".usage.cache_read_input_tokens", null), + reasoning_tokens: null, + }; + + let content: ai.content.Block[] = []; + let stop: ai.content.StopReason = ai.content.StopReason.Complete; + if (has_tools) { + let outcome = baml.json.path_or(result, ".structured_output.outcome", structured); + let calls = baml.json.path_or(outcome, ".calls", null); + if let call_list: json[] = calls { + let i = 0; + for (let raw_call in call_list) { + let id = baml.json.path_or( + raw_call, + ".id", + `cc_call_${input.journal.entries().length()}_${i}`, + ); + let name = baml.json.path_or(raw_call, ".name", ""); + let call_args = baml.json.from_json>( + baml.json.path_or(raw_call, ".args", {}), + ) catch_all (e) { + _ => { + let empty: map = {}; + empty + }, + }; + content.push(ai.content.ToolUse { id: id, name: name, args: call_args }); + i = i + 1; + } + stop = ai.content.StopReason.ToolUse; + } else { + content.push(ai.content.Text { text: baml.json.stringify(outcome) }); + } + } else { + content.push(ai.content.Text { text: baml.json.stringify(structured) }); + } + ai.ModelTurn { content: content, stop_reason: stop, usage: usage } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/google/gemini.baml b/baml_language/crates/baml_builtins2/baml_std/google/gemini.baml new file mode 100644 index 00000000000..4198e8e3989 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/google/gemini.baml @@ -0,0 +1,37 @@ +class GoogleClient { + model: string, + // null resolves from the environment at request time; construction + // never touches env, so declaring/constructing a client is always safe. + api_key: string?, + base_url: string?, + + function new( + model: string = "gemini-2.5-flash", + api_key: string? = null, + base_url: string? = null, + ) -> GoogleClient { + GoogleClient { + model: model, + api_key: api_key, + base_url: base_url, + } + } + + implements ai.Client { + function id(self) -> string { + `google/${self.model}` + } + + function invoke(self, input: ai.ModelTurnInput) -> ai.ModelTurn { + root.internal.invoke(self, input) catch_all (e) { + _ => throw ai.errors.normalize(e), + } + } + } + + implements ai.stream.StreamingClient { + function invoke_stream(self, input: ai.ModelTurnInput) -> ai.stream.TurnStream { + root.internal.invoke_stream(self, input) + } + } +} diff --git a/baml_language/crates/baml_builtins2/baml_std/google/ns_internal/gemini.baml b/baml_language/crates/baml_builtins2/baml_std/google/ns_internal/gemini.baml new file mode 100644 index 00000000000..afbcc8ace97 --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/google/ns_internal/gemini.baml @@ -0,0 +1,437 @@ +// root.GoogleClient — one model turn against the Gemini generateContent API. +// Phase 1: SAP output mode (schema text in the prompt), native function +// calling. Mirrors _planv2/pages/02_guides/03_clients/05_the_built_in_clients.md. + +// Envelope classes for the response body. from_string ignores unknown +// fields, so these type only what the parse reads. +class GmFunctionCall { + name: string, + args: map?, +} + +class GmPart { + text: string?, + thought: bool?, + functionCall: GmFunctionCall?, +} + +class GmContent { + parts: GmPart[]?, +} + +class GmCandidate { + content: GmContent?, + finishReason: string?, +} + +class GmUsageMetadata { + promptTokenCount: int?, + candidatesTokenCount: int?, +} + +class GmPromptFeedback { + blockReason: string?, +} + +class GmResponse { + candidates: GmCandidate[]?, + usageMetadata: GmUsageMetadata?, + promptFeedback: GmPromptFeedback?, +} + +// One {role, parts} content object. +function _gm_content(role: string, parts: map[]) -> map { + let m: map = {}; + let _ = m.set("role", role); + let _ = m.set("parts", parts); + m +} + +// One {text} part. +function _gm_text_part(text: string) -> map { + let p: map = {}; + let _ = p.set("text", text); + p +} + +// One {functionResponse: {name, response}} part. +function _gm_function_response( + name: string, + response: map, +) -> map { + let fr: map = {}; + let _ = fr.set("name", name); + let _ = fr.set("response", response); + let part: map = {}; + let _ = part.set("functionResponse", fr); + part +} + +class GooglePrompt { + system_parts: map[], + contents: map[], + first_role: string, +} + +// Gemini spells assistant as `model` and carries system instructions outside +// `contents`. Role-less prompt content is an ordinary user turn. +function google_lower_prompt(prompt: ai.Prompt) -> GooglePrompt { + let system_parts: map[] = []; + let contents: map[] = []; + let first_role = ""; + for (let message in prompt.messages()) { + let part = _gm_text_part(message.content); + if (message.role == "system") { + system_parts.push(part); + null + } else { + let role = if (message.role == "assistant") { "model" } else { "user" }; + if (first_role == "") { + first_role = role; + null + } else { + null + }; + contents.push(_gm_content(role, [part])); + null + }; + } + GooglePrompt { system_parts: system_parts, contents: contents, first_role: first_role } +} + +// Gemini correlates tool results by NAME, not id: find the tool name for a +// result id by scanning the ai.AssistantMessage ai.ToolUse blocks in the journal. +function _gm_tool_name_for(j: ai.Journal, id: string) -> string { + let name = ""; + for (let e in j.entries()) { + match (e) { + let a: ai.events.AssistantMessage => { + for (let b in a.content) { + match (b) { + let u: ai.content.ToolUse => { + if (u.id == id) { + name = u.name; + null + } else { + null + } + }, + _ => null, + }; + } + null + }, + _ => null, + }; + } + name +} + +// ai.Journal -> generateContent `contents`. Only promptable events lower; +// ai.RunStarted, ai.ToolRequested, ai.Usage, and ai.FinalProduced are journal-only. +// Consecutive tool results merge into ONE user content of functionResponse +// parts, matching how Gemini expects results for a multi-call turn. +function google_lower_journal(j: ai.Journal) -> map[] { + let contents: map[] = []; + let pending: map[] = []; // functionResponse parts awaiting flush + for (let e in j.entries()) { + match (e) { + let u: ai.events.UserMessage => { + if (pending.length() > 0) { + contents.push(_gm_content("user", pending)); + pending = []; + null + } else { + null + }; + contents.push(_gm_content("user", [_gm_text_part(u.content)])); + null + }, + let a: ai.events.AssistantMessage => { + if (pending.length() > 0) { + contents.push(_gm_content("user", pending)); + pending = []; + null + } else { + null + }; + let parts: map[] = []; + for (let b in a.content) { + match (b) { + let t: ai.content.Text => { + parts.push(_gm_text_part(t.text)); + null + }, + let use: ai.content.ToolUse => { + let fc: map = {}; + let _ = fc.set("name", use.name); + let _ = fc.set("args", use.args); + let part: map = {}; + let _ = part.set("functionCall", fc); + parts.push(part); + null + }, + // ai.Reasoning lowers to nothing in this phase. + _ => null, + }; + } + if (parts.length() > 0) { + contents.push(_gm_content("model", parts)); + null + } else { + null + }; + null + }, + let c: ai.events.ToolCompleted => { + let response: map = {}; + let _ = response.set("result", c.output); + pending.push(_gm_function_response(_gm_tool_name_for(j, c.id), response)); + null + }, + let f: ai.events.ToolFailed => { + let response: map = {}; + let _ = response.set("error", f.message); + pending.push(_gm_function_response(_gm_tool_name_for(j, f.id), response)); + null + }, + _ => null, + }; + } + if (pending.length() > 0) { + contents.push(_gm_content("user", pending)); + null + } else { + null + }; + contents +} + +// ai.ModelTurnInput -> one generateContent request. +function google_render( + c: root.GoogleClient, + input: ai.ModelTurnInput, +) -> baml.http.Request { + _google_request(c, input, false) +} + +// The shared request builder: identical body for both paths; streaming +// swaps the method for `:streamGenerateContent?alt=sse`. +function _google_request( + c: root.GoogleClient, + input: ai.ModelTurnInput, + stream: bool, +) -> baml.http.Request { + let prompt = input.prompt(ai.wire.render_output_format(input.output_type)); + let prompt_parts = google_lower_prompt(prompt); + let contents = prompt_parts.contents; + for (let item in google_lower_journal(input.journal)) { + contents.push(item); + } + let body: map = {}; + // A system-only/assistant-first prompt cannot satisfy Gemini's required + // leading user turn. In that unusual shape, preserve the old fallback by + // moving the system parts into a leading user content. Otherwise keep the + // authored system role in `systemInstruction`. + let first_role = prompt_parts.first_role; + if (first_role != "user" && prompt_parts.system_parts.length() > 0) { + contents = [_gm_content("user", prompt_parts.system_parts)].concat(contents); + null + } else if (prompt_parts.system_parts.length() > 0) { + let system_instruction: map = {}; + let _ = system_instruction.set("parts", prompt_parts.system_parts); + let _ = body.set("systemInstruction", system_instruction); + null + } else { + null + }; + let _ = body.set("contents", contents); + if (!input.toolbox.is_empty()) { + let decls: map[] = []; + for (let t in input.toolbox.list()) { + let d: map = {}; + let _ = d.set("name", t.name); + let _ = d.set("description", t.description); + let _ = d.set("parametersJsonSchema", t.input_schema); + decls.push(d); + } + let group: map = {}; + let _ = group.set("functionDeclarations", decls); + let _ = body.set("tools", [group]); + let fcc: map = {}; + let _ = fcc.set("mode", "AUTO"); + let tc: map = {}; + let _ = tc.set("functionCallingConfig", fcc); + let _ = body.set("toolConfig", tc); + null + } else { + null + }; + let method = if (stream) { + ":streamGenerateContent?alt=sse" + } else { + ":generateContent" + }; + baml.http.Request { + method: "POST", + url: `${c.base_url ?? "https://generativelanguage.googleapis.com/v1beta"}/models/${c.model}${method}`, + headers: { + "x-goog-api-key": c.api_key ?? baml.env.get_or_panic("GOOGLE_API_KEY"), + "content-type": "application/json", + }, + body: baml.json.stringify(baml.json.to_json(body)), + } +} + +// One decoded response -> canonical content blocks and a stop reason. +// Gemini sends no call ids; synthesize `call__` from the journal +// position and the call's index within the turn. +function google_parse(resp: GmResponse, seq: int) -> ai.ModelTurn { + let content: ai.content.Block[] = []; + let has_call = false; + let call_index = 0; + let cand: GmCandidate? = resp.candidates?.at(0); + let finish = cand?.finishReason ?? ""; + let parts: GmPart[] = cand?.content?.parts ?? []; + for (let p in parts) { + if let fc: GmFunctionCall = p.functionCall { + let args: map = {}; + if let a: map = fc.args { + args = a; + } + content.push( + ai.content.ToolUse { id: `call_${seq}_${call_index}`, name: fc.name, args: args }, + ); + call_index += 1; + has_call = true; + null + } else { + if let t: string = p.text { + if (p.thought ?? false) { + content.push(ai.content.Reasoning { summary: t }); + null + } else { + content.push(ai.content.Text { text: t }); + null + } + } else { + null + } + }; + } + let blocked = resp.promptFeedback?.blockReason != null; + let stop = if (has_call) { + ai.content.StopReason.ToolUse + } else if (finish == "MAX_TOKENS") { + ai.content.StopReason.MaxTokens + } else if (blocked || finish == "SAFETY" || finish == "RECITATION" || finish == "PROHIBITED_CONTENT") { + ai.content.StopReason.Refused + } else { + ai.content.StopReason.Complete + }; + let usage: ai.events.Usage? = if let um: GmUsageMetadata = resp.usageMetadata { + ai.events.Usage { + input_tokens: um.promptTokenCount ?? 0, + output_tokens: um.candidatesTokenCount ?? 0, + cached_input_tokens: null, + reasoning_tokens: null, + } + } else { + null + }; + ai.ModelTurn { content: content, stop_reason: stop, usage: usage } +} + +// The one internal entry point: render, send, normalize — one model turn. +function invoke(c: root.GoogleClient, input: ai.ModelTurnInput) -> ai.ModelTurn { + // ai.Journal position before this turn commits; the ai.AssistantMessage + // the runner appends will land at this index, so ids synthesized + // from it are stable across re-renders. + let seq = input.journal.entries().length(); + let req = google_render(c, input); + let resp = ai.wire.send_as(req, "google"); + google_parse(resp, seq) +} + +// --------------------------------------------------------------------------- +// Streaming (`:streamGenerateContent?alt=sse`). +// +// Wire chunks observed: each SSE `data:` payload is a GmResponse fragment; +// text deltas ride in candidates[0].content.parts[].text, the last chunk +// carries finishReason, and usageMetadata (cumulative) appears on chunks. +// There is no explicit terminal event: the stream simply closes. + +// One SseStream batch -> provider-agnostic stream events. +function _google_decode_batch(batch: string) -> ai.stream.StreamEvent[] { + let out: ai.stream.StreamEvent[] = []; + for (let raw in ai.stream.decode_sse_batch(batch)) { + if let data: string = raw.data { + let chunk: GmResponse? = baml.json.from_string(data) catch_all (e) { + _ => null, + }; + if let resp: GmResponse = chunk { + let cand: GmCandidate? = resp.candidates?.at(0); + let parts: GmPart[] = cand?.content?.parts ?? []; + for (let p in parts) { + if let t: string = p.text { + if (p.thought ?? false) { + null + } else { + out.push(ai.stream.TextDelta { text: t }); + null + } + } else { + null + }; + } + let finish = cand?.finishReason ?? ""; + let blocked = resp.promptFeedback?.blockReason != null; + let stop: ai.content.StopReason? = if (finish == "MAX_TOKENS") { + ai.content.StopReason.MaxTokens + } else if (blocked || finish == "SAFETY" || finish == "RECITATION" || finish == "PROHIBITED_CONTENT") { + ai.content.StopReason.Refused + } else if (finish != "") { + ai.content.StopReason.Complete + } else { + null + }; + let usage: GmUsageMetadata? = resp.usageMetadata; + if (stop != null || usage != null) { + out.push( + ai.stream.TurnMeta { + stop_reason: stop, + input_tokens: usage?.promptTokenCount, + output_tokens: usage?.candidatesTokenCount, + }, + ); + null + } else { + null + }; + null + } else { + null + }; + null + } else { + null + }; + } + out +} + +// The streaming entry point: the same request body as `invoke` against the +// `:streamGenerateContent?alt=sse` method, wrapped in an ai.TurnStream. +function invoke_stream(c: root.GoogleClient, input: ai.ModelTurnInput) -> ai.stream.TurnStream { + let req = _google_request(c, input, true); + let sse = baml.http.fetch_sse(req) catch_all (e) { + _ => { + throw ai.errors.NetworkFailure { + provider: "google", + detail: `transport failed before a response arrived: ${e.to_string()}`, + raw_body: null, + } + }, + }; + ai.stream.TurnStream.from_sse(sse, _google_decode_batch) +} diff --git a/baml_language/crates/baml_builtins2/baml_std/openai/ns_internal/responses.baml b/baml_language/crates/baml_builtins2/baml_std/openai/ns_internal/responses.baml new file mode 100644 index 00000000000..24a62681e5b --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/openai/ns_internal/responses.baml @@ -0,0 +1,321 @@ +// root.OpenAiClient — one model turn against the OpenAI Responses API. +// Phase 1: SAP output mode (schema text in the prompt), native function +// calling. Mirrors _planv2/pages/02_guides/03_clients/05_the_built_in_clients.md. + +// Envelope classes for the response body. from_string ignores unknown +// fields, so these type only what the parse reads. +class OaContentPart { + type: string?, + text: string?, +} + +class OaOutputItem { + type: string, + call_id: string?, + name: string?, + arguments: string?, + content: OaContentPart[]?, + summary: OaContentPart[]?, +} + +class OaUsage { + input_tokens: int, + output_tokens: int, +} + +class OaResponse { + status: string?, + output: OaOutputItem[], + usage: OaUsage?, +} + +// A rendered structural prompt becomes Responses input messages. Role-less +// content uses the ordinary user role; `tool` is not a valid +// free-standing Responses message role, so authored tool context is user +// content (actual tool outputs come from the journal path below). +function openai_lower_prompt(prompt: ai.Prompt) -> map[] { + let items: map[] = []; + for (let message in prompt.messages()) { + let role = if (message.role == "") { + "user" + } else if (message.role == "tool") { + "user" + } else { + message.role + }; + let item: map = {}; + let _ = item.set("role", role); + let _ = item.set("content", message.content); + items.push(item); + } + items +} + +// ai.Journal -> Responses API input items. Only promptable events lower; +// ai.RunStarted, ai.ToolRequested, ai.Usage, and ai.FinalProduced are journal-only. +function openai_lower_journal(j: ai.Journal) -> map[] { + let items: map[] = []; + for (let e in j.entries()) { + match (e) { + let u: ai.events.UserMessage => { + let m: map = {}; + let _ = m.set("role", "user"); + let _ = m.set("content", u.content); + items.push(m); + null + }, + let a: ai.events.AssistantMessage => { + for (let b in a.content) { + match (b) { + let t: ai.content.Text => { + let m: map = {}; + let _ = m.set("role", "assistant"); + let _ = m.set("content", t.text); + items.push(m); + null + }, + let use: ai.content.ToolUse => { + let m: map = {}; + let _ = m.set("type", "function_call"); + let _ = m.set("call_id", use.id); + let _ = m.set("name", use.name); + let _ = m.set("arguments", baml.json.stringify(baml.json.to_json(use.args))); + items.push(m); + null + }, + // ai.Reasoning lowers to nothing in this phase. + _ => null, + }; + } + null + }, + let c: ai.events.ToolCompleted => { + let m: map = {}; + let _ = m.set("type", "function_call_output"); + let _ = m.set("call_id", c.id); + let _ = m.set("output", c.output); + items.push(m); + null + }, + let f: ai.events.ToolFailed => { + let err: map = {}; + let _ = err.set("error", f.message); + let m: map = {}; + let _ = m.set("type", "function_call_output"); + let _ = m.set("call_id", f.id); + let _ = m.set("output", baml.json.stringify(baml.json.to_json(err))); + items.push(m); + null + }, + _ => null, + }; + } + items +} + +// ai.ModelTurnInput -> one Responses API request. +function openai_render( + c: root.OpenAiClient, + input: ai.ModelTurnInput, +) -> baml.http.Request { + _openai_request(c, input, false) +} + +// The shared request builder: identical body for both paths, plus the +// `stream: true` flag on the streaming one. +function _openai_request( + c: root.OpenAiClient, + input: ai.ModelTurnInput, + stream: bool, +) -> baml.http.Request { + let prompt = input.prompt(ai.wire.render_output_format(input.output_type)); + let items = openai_lower_prompt(prompt); + for (let item in openai_lower_journal(input.journal)) { + items.push(item); + } + let body: map = {}; + let _ = body.set("model", c.model); + let _ = body.set("store", false); + let _ = body.set("input", items); + if (!input.toolbox.is_empty()) { + let tools: map[] = []; + for (let t in input.toolbox.list()) { + let entry: map = {}; + let _ = entry.set("type", "function"); + let _ = entry.set("name", t.name); + let _ = entry.set("description", t.description); + let _ = entry.set("parameters", t.input_schema); + let _ = entry.set("strict", false); + tools.push(entry); + } + let _ = body.set("tools", tools); + let _ = body.set("tool_choice", "auto"); + null + } else { + null + }; + if (stream) { + let _ = body.set("stream", true); + null + } else { + null + }; + baml.http.Request { + method: "POST", + url: `${c.resolved_base_url() ?? "https://api.openai.com/v1"}/responses`, + headers: { + "authorization": `Bearer ${c.resolved_api_key()}`, + "content-type": "application/json", + }, + body: baml.json.stringify(baml.json.to_json(body)), + } +} + +// One decoded response -> canonical content blocks and a stop reason. +function openai_parse(resp: OaResponse) -> ai.ModelTurn { + let content: ai.content.Block[] = []; + let has_call = false; + for (let item in resp.output) { + if (item.type == "function_call") { + has_call = true; + let args: map = {}; + if let s: string = item.arguments { + args = baml.json.from_string>(s); + } + content.push( + ai.content.ToolUse { id: item.call_id ?? "", name: item.name ?? "", args: args }, + ); + null + } else if (item.type == "message") { + let parts: OaContentPart[] = item.content ?? []; + for (let part in parts) { + if ((part.type ?? "") == "output_text") { + content.push(ai.content.Text { text: part.text ?? "" }); + null + } else { + null + }; + } + null + } else if (item.type == "reasoning") { + let parts: OaContentPart[] = item.summary ?? []; + for (let part in parts) { + if let txt: string = part.text { + content.push(ai.content.Reasoning { summary: txt }); + null + } else { + null + }; + } + null + } else { + null + }; + } + let stop = if (has_call) { + ai.content.StopReason.ToolUse + } else if ((resp.status ?? "") == "incomplete") { + ai.content.StopReason.MaxTokens + } else { + ai.content.StopReason.Complete + }; + let usage: ai.events.Usage? = if let u: OaUsage = resp.usage { + ai.events.Usage { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + cached_input_tokens: null, + reasoning_tokens: null, + } + } else { + null + }; + ai.ModelTurn { content: content, stop_reason: stop, usage: usage } +} + +// The one internal entry point: render, send, normalize — one model turn. +function invoke(c: root.OpenAiClient, input: ai.ModelTurnInput) -> ai.ModelTurn { + let req = openai_render(c, input); + let resp = ai.wire.send_as(req, "openai"); + openai_parse(resp) +} + +// --------------------------------------------------------------------------- +// Streaming (Responses API SSE). +// +// Wire events observed: `response.output_text.delta` carries the text delta +// in `delta`; the terminal event (`response.completed` / `response.incomplete` +// / `response.failed`) carries the full response object in `response`, which +// yields the stop reason and usage through the same openai_parse as the +// non-streaming path. + +// The envelope of one SSE `data:` payload; only what the decoder reads. +class OaStreamEvent { + type: string?, + delta: string?, + response: OaResponse?, +} + +// One SseStream batch -> provider-agnostic stream events. +function _openai_decode_batch(batch: string) -> ai.stream.StreamEvent[] { + let out: ai.stream.StreamEvent[] = []; + for (let raw in ai.stream.decode_sse_batch(batch)) { + if let data: string = raw.data { + let ev: OaStreamEvent? = baml.json.from_string(data) catch_all (e) { + _ => null, + }; + if let event: OaStreamEvent = ev { + let t = event.type ?? raw.event ?? ""; + if (t == "response.output_text.delta") { + if let d: string = event.delta { + out.push(ai.stream.TextDelta { text: d }); + null + } else { + null + }; + null + } else if (t == "response.completed" || t == "response.incomplete" || t == "response.failed") { + if let r: OaResponse = event.response { + let turn = openai_parse(r); + out.push( + ai.stream.TurnMeta { + stop_reason: turn.stop_reason, + input_tokens: turn.usage?.input_tokens, + output_tokens: turn.usage?.output_tokens, + }, + ); + null + } else { + null + }; + out.push(ai.stream.TurnDone {}); + null + } else { + null + }; + null + } else { + null + }; + null + } else { + null + }; + } + out +} + +// The streaming entry point: the same request as `invoke` with +// `stream: true`, wrapped in an ai.TurnStream over the SSE connection. +function invoke_stream(c: root.OpenAiClient, input: ai.ModelTurnInput) -> ai.stream.TurnStream { + let req = _openai_request(c, input, true); + let sse = baml.http.fetch_sse(req) catch_all (e) { + _ => { + throw ai.errors.NetworkFailure { + provider: "openai", + detail: `transport failed before a response arrived: ${e.to_string()}`, + raw_body: null, + } + }, + }; + ai.stream.TurnStream.from_sse(sse, _openai_decode_batch) +} diff --git a/baml_language/crates/baml_builtins2/baml_std/openai/responses.baml b/baml_language/crates/baml_builtins2/baml_std/openai/responses.baml new file mode 100644 index 00000000000..7f06fc957fa --- /dev/null +++ b/baml_language/crates/baml_builtins2/baml_std/openai/responses.baml @@ -0,0 +1,73 @@ +class OpenAiClient { + model: string, + // null resolves OPENAI_API_KEY at request time; construction never + // touches env, so declaring/constructing a client is always safe. + api_key: string?, + base_url: string?, + // Names of environment variables to read INSTEAD, at request time. + // A `client Name = ...` declaration is evaluated during `$init`, which + // cannot run io sysops — so a declaration can never read env itself. + // Naming the variable keeps the declaration pure while still letting a + // deployment (or a replay harness) point the client somewhere else. + api_key_env: string?, + base_url_env: string?, + + function new( + model: string = "gpt-4o-mini", + api_key: string? = null, + base_url: string? = null, + api_key_env: string? = null, + base_url_env: string? = null, + ) -> OpenAiClient { + OpenAiClient { + model: model, + api_key: api_key, + base_url: base_url, + api_key_env: api_key_env, + base_url_env: base_url_env, + } + } + + // The request-time credential: an explicit value, else the named env + // var, else the provider's canonical one. + function resolved_api_key(self) -> string { + if let k: string = self.api_key { + return k; + } + if let name: string = self.api_key_env { + if let v: string = baml.env.get(name) { + return v; + } + } + baml.env.get_or_panic("OPENAI_API_KEY") + } + + // The request-time base URL, or null for the provider default. + function resolved_base_url(self) -> string? { + if let u: string = self.base_url { + return u; + } + if let name: string = self.base_url_env { + return baml.env.get(name); + } + null + } + + implements ai.Client { + function id(self) -> string { + `openai/${self.model}` + } + + function invoke(self, input: ai.ModelTurnInput) -> ai.ModelTurn { + root.internal.invoke(self, input) catch_all (e) { + _ => throw ai.errors.normalize(e), + } + } + } + + implements ai.stream.StreamingClient { + function invoke_stream(self, input: ai.ModelTurnInput) -> ai.stream.TurnStream { + root.internal.invoke_stream(self, input) + } + } +} diff --git a/baml_language/crates/baml_builtins2/src/lib.rs b/baml_language/crates/baml_builtins2/src/lib.rs index c389fc67e5a..19afa9fd707 100644 --- a/baml_language/crates/baml_builtins2/src/lib.rs +++ b/baml_language/crates/baml_builtins2/src/lib.rs @@ -118,11 +118,10 @@ pub const ALL: &[BuiltinFile] = &[ builtin!("baml", "ns_yaml/yaml.baml"), builtin!("baml", "ns_toml/toml.baml"), builtin!("baml", "ns_csv/csv.baml"), - builtin!("baml", "ns_llm/llm_types.baml"), - builtin!("baml", "ns_llm/llm.baml"), + builtin!("baml", "ns_prompt/prompt.baml"), + builtin!("baml", "ns_prompt/sys_llm_types.baml"), builtin!("baml", "ns_sap/sap.baml"), builtin!("baml", "ns_ws/ws.baml"), - builtin!("baml", "ns_stream/stream.baml"), builtin!("baml", "ns_iter/iter.baml"), builtin!("baml", "ns_future/future.baml"), builtin!("baml", "ns_spawn/spawn.baml"), @@ -150,6 +149,30 @@ pub const ALL: &[BuiltinFile] = &[ builtin!("assert", "assert.baml"), // --- log package --- builtin!("log", "log.baml"), + // --- ai package (specs, journal, runner, client interface) --- + builtin!("ai", "ns_content/content.baml"), + builtin!("ai", "ns_events/events.baml"), + builtin!("ai", "journal.baml"), + builtin!("ai", "spec.baml"), + builtin!("ai", "ns_tools/tools.baml"), + builtin!("ai", "turn.baml"), + builtin!("ai", "ns_wire/wire.baml"), + builtin!("ai", "ns_clients/clients.baml"), + builtin!("ai", "runner.baml"), + builtin!("ai", "ns_stream/stream.baml"), + builtin!("ai", "ns_errors/errors.baml"), + builtin!("ai", "ns_internal/helpers.baml"), + // --- provider client packages --- + builtin!("openai", "responses.baml"), + builtin!("openai", "ns_internal/responses.baml"), + builtin!("anthropic", "messages.baml"), + builtin!("anthropic", "ns_internal/messages.baml"), + builtin!("google", "gemini.baml"), + builtin!("google", "ns_internal/gemini.baml"), + builtin!("claude_code", "cli.baml"), + builtin!("claude_code", "ns_internal/cli.baml"), + // ai.mcp: MCP servers as ordinary ai tools (part of the ai package). + builtin!("ai", "ns_mcp/mcp.baml"), ]; /// The distinct standard-library / builtin package names, derived from the diff --git a/baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs b/baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs index 04b5a531b42..41f57efc166 100644 --- a/baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs +++ b/baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs @@ -74,7 +74,7 @@ impl IoNamespaceNode<'_> { /// All IO builtins start with "baml." and have a namespace as the second segment: /// - "baml.fs.open" → "fs" /// - "baml.fs.File.read" → "fs" -/// - `"baml.llm.get_client"` → `"llm"` +/// - `"baml.prompt.get_client"` → `"llm"` fn io_namespace_name(builtin: &NativeBuiltin) -> &str { let after_baml = builtin.path.strip_prefix("baml.").unwrap_or(&builtin.path); after_baml.split('.').next().unwrap_or("") @@ -1074,8 +1074,8 @@ fn emit_view_struct( /// A class is non-defaultable if it directly contains a `$rust_type` field, /// or if any of its fields transitively references a non-defaultable class. /// -/// Both the fully-qualified name (`baml.llm.StreamAccumulator`) and the short -/// name (`StreamAccumulator`) are stored, because field type references may use +/// Both a fully-qualified name (for example `baml.sap.ParseCache`) and its +/// short name are stored, because field type references may use /// either form depending on whether the path was single- or multi-segment. fn compute_non_defaultable_classes( class_defs_by_ns: &BTreeMap>, diff --git a/baml_language/crates/baml_builtins2_codegen/src/extract.rs b/baml_language/crates/baml_builtins2_codegen/src/extract.rs index c2e8c16124a..6bd075035b0 100644 --- a/baml_language/crates/baml_builtins2_codegen/src/extract.rs +++ b/baml_language/crates/baml_builtins2_codegen/src/extract.rs @@ -301,7 +301,7 @@ fn extract_from_class( // Always set receiver for class methods — even static methods (no `self`) // need it for dispatch routing. The runtime path is - // "baml.llm.StreamCache.new" which dispatches via class name. + // "baml.sap.ParseCache.new" which dispatches via class name. let receiver_type = if !has_self { ReceiverType::Static } else if is_mut { @@ -1148,24 +1148,15 @@ mod tests { assert_eq!(request.namespace_prefix, "baml.http"); assert_eq!(request.fields.len(), 4); - // LLM classes - let pc = class_defs + // The structural prompt lives in the ai package. + let (_ai_vm, _ai_io, ai_class_defs) = extract_native_builtins_for("ai").unwrap(); + let prompt = ai_class_defs .iter() - .find(|c| c.name == "PrimitiveClient") - .expect("missing PrimitiveClient"); - assert_eq!(pc.namespace_prefix, "baml.llm"); - - let client = class_defs - .iter() - .find(|c| c.name == "Client") - .expect("missing Client"); - assert_eq!(client.namespace_prefix, "baml.llm"); - - let retry = class_defs - .iter() - .find(|c| c.name == "RetryPolicy") - .expect("missing RetryPolicy"); - assert_eq!(retry.namespace_prefix, "baml.llm"); + .find(|c| c.name == "Prompt") + .expect("missing ai.Prompt"); + assert_eq!(prompt.namespace_prefix, "ai"); + assert_eq!(prompt.fields.len(), 1); + assert!(matches!(prompt.fields[0].field_type, BamlType::RustType)); } #[test] @@ -1204,13 +1195,10 @@ mod tests { "BamlHttpFetch" ); assert_eq!(make("baml.sys.panic").sys_op_variant_name(), "BamlSysPanic"); + assert_eq!(make("ai.Prompt.text").sys_op_variant_name(), "AiPromptText"); assert_eq!( - make("baml.llm.PrimitiveClient.render_prompt").sys_op_variant_name(), - "BamlLlmPrimitiveClientRenderPrompt" - ); - assert_eq!( - make("baml.llm.get_jinja_template").sys_op_variant_name(), - "BamlLlmGetJinjaTemplate" + make("baml.prompt.render_output_format").sys_op_variant_name(), + "BamlPromptRenderOutputFormat" ); } @@ -1337,16 +1325,10 @@ mod tests { .unwrap(); assert_eq!(http_fetch.throws, throws(&["Io", "Timeout"])); - let render_prompt = io_builtins - .iter() - .find(|b| b.path == "baml.llm.PrimitiveClient.render_prompt") - .unwrap(); - assert_eq!(render_prompt.throws, throws(&["RenderPrompt"])); - - let specialize = io_builtins + let sap_final = io_builtins .iter() - .find(|b| b.path == "baml.llm.PrimitiveClient.specialize_prompt") + .find(|b| b.path == "baml.sap.__parse_final") .unwrap(); - assert_eq!(specialize.throws, throws(&["RenderPrompt", "LlmClient"])); + assert_eq!(sap_final.throws, throws(&["LlmClient"])); } } diff --git a/baml_language/crates/baml_builtins2_codegen/src/types.rs b/baml_language/crates/baml_builtins2_codegen/src/types.rs index 3b8e77c2b11..f1e116c9388 100644 --- a/baml_language/crates/baml_builtins2_codegen/src/types.rs +++ b/baml_language/crates/baml_builtins2_codegen/src/types.rs @@ -47,7 +47,7 @@ pub struct NativeBuiltin { impl NativeBuiltin { /// Derive the `SysOp` enum variant name from the path. /// `"baml.fs.open"` → `"BamlFsOpen"`, `"baml.fs.File.read"` → `"BamlFsFileRead"`, - /// `"baml.env.get"` → `"BamlEnvGet"`, `"baml.llm.render_prompt"` → `"BamlLlmRenderPrompt"`. + /// `"baml.env.get"` → `"BamlEnvGet"`, `"ai.Prompt.text"` → `"AiPromptText"`. pub fn sys_op_variant_name(&self) -> String { self.path .split('.') diff --git a/baml_language/crates/baml_cli/src/describe_command_tests.rs b/baml_language/crates/baml_cli/src/describe_command_tests.rs index a7edd9727ba..7a4df4c3a18 100644 --- a/baml_language/crates/baml_cli/src/describe_command_tests.rs +++ b/baml_language/crates/baml_cli/src/describe_command_tests.rs @@ -429,12 +429,12 @@ fn render_builtin_namespace_env() { insta::assert_snapshot!(output); } -/// `baml describe baml.llm` — list items in the `llm` sub-namespace. +/// `baml describe baml.prompt` — list items in the `prompt` sub-namespace. #[test] -fn render_builtin_namespace_llm() { +fn render_builtin_namespace_prompt() { let db = simple_project(); let pkg_id = baml_compiler2_hir::package::PackageId::new(&db, baml_db::Name::new("baml")); - let ns_path = vec![baml_db::Name::new("llm")]; + let ns_path = vec![baml_db::Name::new("prompt")]; let entries = baml_lsp2_actions::list_namespace_items(&db, pkg_id, &ns_path).unwrap(); assert!(!entries.is_empty()); let output = capture_listing(&entries); diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_llm.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_llm.snap deleted file mode 100644 index e0522fc9505..00000000000 --- a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_llm.snap +++ /dev/null @@ -1,46 +0,0 @@ ---- -source: crates/baml_cli/src/describe_command_tests.rs -expression: output ---- -class baml.llm.OrchestrationStep /baml/ns_llm/llm.baml:1 -class baml.llm.ExecutionContext /baml/ns_llm/llm.baml:6 -class baml.llm.PlannerState /baml/ns_llm/llm.baml:16 -function baml.llm.prompt_template_for /baml/ns_llm/llm.baml:41 -function baml.llm.render_prompt /baml/ns_llm/llm.baml:48 -function baml.llm.build_request /baml/ns_llm/llm.baml:61 -function baml.llm.build_request_stream /baml/ns_llm/llm.baml:70 -function baml.llm.parse /baml/ns_llm/llm.baml:93 -function baml.llm.__new_stream_cache /baml/ns_llm/llm.baml:103 -function baml.llm.call_llm_function /baml/ns_llm/llm.baml:111 -function baml.llm.stream_llm_function /baml/ns_llm/llm.baml:135 -class baml.llm.PromptMessage /baml/ns_llm/llm_types.baml:4 -class baml.llm.PromptAst /baml/ns_llm/llm_types.baml:19 -class baml.llm.OutputFormat /baml/ns_llm/llm_types.baml:49 -class baml.llm.Role /baml/ns_llm/llm_types.baml:57 -class baml.llm.ContextClient /baml/ns_llm/llm_types.baml:69 -class baml.llm.Context /baml/ns_llm/llm_types.baml:87 -enum baml.llm.ClientType /baml/ns_llm/llm_types.baml:118 -class baml.llm.RetryPolicy /baml/ns_llm/llm_types.baml:125 -class baml.llm.Client /baml/ns_llm/llm_types.baml:156 -class baml.llm.MediaUrlHandler /baml/ns_llm/llm_types.baml:516 -class baml.llm.PrimitiveClientOptions /baml/ns_llm/llm_types.baml:523 -class baml.llm.AzureOpenAiOptions /baml/ns_llm/llm_types.baml:545 -class baml.llm.AnthropicOptions /baml/ns_llm/llm_types.baml:554 -class baml.llm.GoogleAiOptions /baml/ns_llm/llm_types.baml:564 -class baml.llm.VertexAiOptions /baml/ns_llm/llm_types.baml:578 -class baml.llm.BedrockOptions /baml/ns_llm/llm_types.baml:592 -class baml.llm.StreamAccumulator /baml/ns_llm/llm_types.baml:605 -class baml.llm.StreamCache /baml/ns_llm/llm_types.baml:641 -class baml.llm.Stream /baml/ns_llm/llm_types.baml:657 -class baml.llm.PrimitiveClient /baml/ns_llm/llm_types.baml:722 -function baml.llm.from_shorthand /baml/ns_llm/llm_types.baml:806 -function baml.llm.get_jinja_template /baml/ns_llm/llm_types.baml:812 -function baml.llm.assemble_prompt_ast /baml/ns_llm/llm_types.baml:824 -function baml.llm.render_output_format /baml/ns_llm/llm_types.baml:832 -function baml.llm.build_output_format /baml/ns_llm/llm_types.baml:839 -function baml.llm.build_prompt_context /baml/ns_llm/llm_types.baml:848 -function baml.llm.make_role /baml/ns_llm/llm_types.baml:864 -function baml.llm.prompt /baml/ns_llm/llm_types.baml:876 -function baml.llm.get_return_type /baml/ns_llm/llm_types.baml:886 -function baml.llm.__sap_parse_final /baml/ns_llm/llm_types.baml:891 -function baml.llm.__sap_parse_partial /baml/ns_llm/llm_types.baml:896 diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_prompt.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_prompt.snap new file mode 100644 index 00000000000..3e31b39020c --- /dev/null +++ b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_prompt.snap @@ -0,0 +1,19 @@ +--- +source: crates/baml_cli/src/describe_command_tests.rs +expression: output +--- +class baml.prompt.OutputFormat /baml/ns_prompt/prompt.baml:3 +class baml.prompt.Role /baml/ns_prompt/prompt.baml:8 +class baml.prompt.ContextClient /baml/ns_prompt/prompt.baml:15 +class baml.prompt.Context /baml/ns_prompt/prompt.baml:23 +function baml.prompt.render_output_format /baml/ns_prompt/prompt.baml:46 +function baml.prompt.build_output_format /baml/ns_prompt/prompt.baml:51 +function baml.prompt.make_role /baml/ns_prompt/prompt.baml:56 +function baml.prompt.get_return_type /baml/ns_prompt/prompt.baml:62 +class baml.prompt.MediaUrlHandler /baml/ns_prompt/sys_llm_types.baml:6 +class baml.prompt.PrimitiveClientOptions /baml/ns_prompt/sys_llm_types.baml:13 +class baml.prompt.AzureOpenAiOptions /baml/ns_prompt/sys_llm_types.baml:31 +class baml.prompt.AnthropicOptions /baml/ns_prompt/sys_llm_types.baml:38 +class baml.prompt.GoogleAiOptions /baml/ns_prompt/sys_llm_types.baml:42 +class baml.prompt.VertexAiOptions /baml/ns_prompt/sys_llm_types.baml:50 +class baml.prompt.BedrockOptions /baml/ns_prompt/sys_llm_types.baml:57 diff --git a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap index a5279aeff2c..cfc278c78b0 100644 --- a/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap +++ b/baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_package_listing.snap @@ -184,48 +184,6 @@ function baml.json.path_or /baml/ns_json/json.ba function baml.json.schema /baml/ns_json/json.baml:316 function baml.json.serialize /baml/ns_json/json.baml:336 function baml.json.deserialize /baml/ns_json/json.baml:355 -class baml.llm.OrchestrationStep /baml/ns_llm/llm.baml:1 -class baml.llm.ExecutionContext /baml/ns_llm/llm.baml:6 -class baml.llm.PlannerState /baml/ns_llm/llm.baml:16 -function baml.llm.prompt_template_for /baml/ns_llm/llm.baml:41 -function baml.llm.render_prompt /baml/ns_llm/llm.baml:48 -function baml.llm.build_request /baml/ns_llm/llm.baml:61 -function baml.llm.build_request_stream /baml/ns_llm/llm.baml:70 -function baml.llm.parse /baml/ns_llm/llm.baml:93 -function baml.llm.__new_stream_cache /baml/ns_llm/llm.baml:103 -function baml.llm.call_llm_function /baml/ns_llm/llm.baml:111 -function baml.llm.stream_llm_function /baml/ns_llm/llm.baml:135 -class baml.llm.PromptMessage /baml/ns_llm/llm_types.baml:4 -class baml.llm.PromptAst /baml/ns_llm/llm_types.baml:19 -class baml.llm.OutputFormat /baml/ns_llm/llm_types.baml:49 -class baml.llm.Role /baml/ns_llm/llm_types.baml:57 -class baml.llm.ContextClient /baml/ns_llm/llm_types.baml:69 -class baml.llm.Context /baml/ns_llm/llm_types.baml:87 -enum baml.llm.ClientType /baml/ns_llm/llm_types.baml:118 -class baml.llm.RetryPolicy /baml/ns_llm/llm_types.baml:125 -class baml.llm.Client /baml/ns_llm/llm_types.baml:156 -class baml.llm.MediaUrlHandler /baml/ns_llm/llm_types.baml:516 -class baml.llm.PrimitiveClientOptions /baml/ns_llm/llm_types.baml:523 -class baml.llm.AzureOpenAiOptions /baml/ns_llm/llm_types.baml:545 -class baml.llm.AnthropicOptions /baml/ns_llm/llm_types.baml:554 -class baml.llm.GoogleAiOptions /baml/ns_llm/llm_types.baml:564 -class baml.llm.VertexAiOptions /baml/ns_llm/llm_types.baml:578 -class baml.llm.BedrockOptions /baml/ns_llm/llm_types.baml:592 -class baml.llm.StreamAccumulator /baml/ns_llm/llm_types.baml:605 -class baml.llm.StreamCache /baml/ns_llm/llm_types.baml:641 -class baml.llm.Stream /baml/ns_llm/llm_types.baml:657 -class baml.llm.PrimitiveClient /baml/ns_llm/llm_types.baml:722 -function baml.llm.from_shorthand /baml/ns_llm/llm_types.baml:806 -function baml.llm.get_jinja_template /baml/ns_llm/llm_types.baml:812 -function baml.llm.assemble_prompt_ast /baml/ns_llm/llm_types.baml:824 -function baml.llm.render_output_format /baml/ns_llm/llm_types.baml:832 -function baml.llm.build_output_format /baml/ns_llm/llm_types.baml:839 -function baml.llm.build_prompt_context /baml/ns_llm/llm_types.baml:848 -function baml.llm.make_role /baml/ns_llm/llm_types.baml:864 -function baml.llm.prompt /baml/ns_llm/llm_types.baml:876 -function baml.llm.get_return_type /baml/ns_llm/llm_types.baml:886 -function baml.llm.__sap_parse_final /baml/ns_llm/llm_types.baml:891 -function baml.llm.__sap_parse_partial /baml/ns_llm/llm_types.baml:896 class baml.media.Pdf /baml/ns_media/media.baml:2 class baml.media.Audio /baml/ns_media/media.baml:46 class baml.media.Video /baml/ns_media/media.baml:90 @@ -267,27 +225,49 @@ class baml.panics.SdkPanic /baml/ns_panics/panic class baml.panics.HostContractViolation /baml/ns_panics/panics.baml:90 class baml.panics.IntegerOverflow /baml/ns_panics/panics.baml:100 type baml.panics.Panic /baml/ns_panics/panics.baml:104 +class baml.prompt.OutputFormat /baml/ns_prompt/prompt.baml:3 +class baml.prompt.Role /baml/ns_prompt/prompt.baml:8 +class baml.prompt.ContextClient /baml/ns_prompt/prompt.baml:15 +class baml.prompt.Context /baml/ns_prompt/prompt.baml:23 +function baml.prompt.render_output_format /baml/ns_prompt/prompt.baml:46 +function baml.prompt.build_output_format /baml/ns_prompt/prompt.baml:51 +function baml.prompt.make_role /baml/ns_prompt/prompt.baml:56 +function baml.prompt.get_return_type /baml/ns_prompt/prompt.baml:62 +class baml.prompt.MediaUrlHandler /baml/ns_prompt/sys_llm_types.baml:6 +class baml.prompt.PrimitiveClientOptions /baml/ns_prompt/sys_llm_types.baml:13 +class baml.prompt.AzureOpenAiOptions /baml/ns_prompt/sys_llm_types.baml:31 +class baml.prompt.AnthropicOptions /baml/ns_prompt/sys_llm_types.baml:38 +class baml.prompt.GoogleAiOptions /baml/ns_prompt/sys_llm_types.baml:42 +class baml.prompt.VertexAiOptions /baml/ns_prompt/sys_llm_types.baml:50 +class baml.prompt.BedrockOptions /baml/ns_prompt/sys_llm_types.baml:57 interface baml.random.Rng /baml/ns_random/random.baml:7 class baml.random.SystemRandom /baml/ns_random/random.baml:45 class baml.random.Xoshiro256PlusPlus /baml/ns_random/random.baml:68 class baml.random.ChaCha20 /baml/ns_random/random.baml:114 -function baml.sap.parse /baml/ns_sap/sap.baml:5 -function baml.sap.parse_type /baml/ns_sap/sap.baml:12 +class baml.sap.ParseCache /baml/ns_sap/sap.baml:7 +class baml.sap.NoYield /baml/ns_sap/sap.baml:18 +function baml.sap.__new_parse_cache /baml/ns_sap/sap.baml:21 +function baml.sap.__parse_final /baml/ns_sap/sap.baml:30 +function baml.sap.__parse_partial /baml/ns_sap/sap.baml:38 +function baml.sap.parse /baml/ns_sap/sap.baml:46 +function baml.sap.parse_type /baml/ns_sap/sap.baml:53 class baml.spawn.SpawnParams /baml/ns_spawn/spawn.baml:19 class baml.spawn.TaskGroup /baml/ns_spawn/spawn.baml:31 class baml.spawn.CancelToken /baml/ns_spawn/spawn.baml:85 function baml.spawn.options /baml/ns_spawn/spawn.baml:123 -class baml.stream.StreamFinished /baml/ns_stream/stream.baml:4 -class baml.stream.StreamNoYield /baml/ns_stream/stream.baml:9 class baml.sys.ProcessOptions /baml/ns_sys/sys.baml:2 -class baml.sys.ShellOutput /baml/ns_sys/sys.baml:14 -function baml.sys.exec /baml/ns_sys/sys.baml:26 -function baml.sys.shell /baml/ns_sys/sys.baml:31 -function baml.sys.sleep /baml/ns_sys/sys.baml:36 -function baml.sys.collect_garbage /baml/ns_sys/sys.baml:43 -function baml.sys.panic /baml/ns_sys/sys.baml:49 -function baml.sys.exit /baml/ns_sys/sys.baml:55 -function baml.sys.argv /baml/ns_sys/sys.baml:61 +class baml.sys.ShellOutput /baml/ns_sys/sys.baml:20 +class baml.sys.ProcessExit /baml/ns_sys/sys.baml:32 +class baml.sys.ProcessLineStream /baml/ns_sys/sys.baml:46 +class baml.sys.Process /baml/ns_sys/sys.baml:85 +function baml.sys.exec /baml/ns_sys/sys.baml:118 +function baml.sys.start_process /baml/ns_sys/sys.baml:126 +function baml.sys.shell /baml/ns_sys/sys.baml:131 +function baml.sys.sleep /baml/ns_sys/sys.baml:136 +function baml.sys.collect_garbage /baml/ns_sys/sys.baml:143 +function baml.sys.panic /baml/ns_sys/sys.baml:149 +function baml.sys.exit /baml/ns_sys/sys.baml:155 +function baml.sys.argv /baml/ns_sys/sys.baml:161 class baml.time.Duration /baml/ns_time/duration.baml:4 class baml.time.Instant /baml/ns_time/instant.baml:2 class baml.time.PlainDate /baml/ns_time/plaindate.baml:5 diff --git a/baml_language/crates/baml_cli/tests/test_profiles_e2e.rs b/baml_language/crates/baml_cli/tests/test_profiles_e2e.rs index a1e2fcbc054..1e24be8b7b3 100644 --- a/baml_language/crates/baml_cli/tests/test_profiles_e2e.rs +++ b/baml_language/crates/baml_cli/tests/test_profiles_e2e.rs @@ -36,14 +36,11 @@ testset "integration" { test "creates_order" { assert.is_true(true) } } -client TestClient { - provider openai - options { model "gpt-4o-mini" } -} +client TestClient = openai.OpenAiClient.new(model = "gpt-4o-mini"); function Summarize(input: string) -> string { client TestClient - prompt #"{{ input }}"# + prompt `${input}` } test BasicTest { diff --git a/baml_language/crates/baml_compiler2_ast/build.rs b/baml_language/crates/baml_compiler2_ast/build.rs deleted file mode 100644 index bd45982ca30..00000000000 --- a/baml_language/crates/baml_compiler2_ast/build.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Extracts client option field names and provider mappings from `llm_types.baml`. -//! -//! This avoids manually maintaining field lists in `lower_cst.rs` -- adding a -//! new field to `PrimitiveClientOptions` or a new provider options class in the -//! BAML file is enough; the compiler picks it up automatically. - -use std::io::Write; - -const LLM_TYPES_BAML: &str = "../baml_builtins2/baml_std/baml/ns_llm/llm_types.baml"; - -/// Namespace prefix for provider options types. -const NS_PREFIX: &str = "baml.llm."; - -fn main() { - println!("cargo:rerun-if-changed={LLM_TYPES_BAML}"); - println!("cargo:rerun-if-changed=build.rs"); - - let source = std::fs::read_to_string(LLM_TYPES_BAML) - .unwrap_or_else(|e| panic!("failed to read {LLM_TYPES_BAML}: {e}")); - - let client_option_fields = extract_class_fields(&source, "PrimitiveClientOptions"); - let provider_configs = extract_provider_configs(&source); - let no_options_providers = extract_no_options_providers(&source); - assert_google_vertex_options_match(&source); - - let out_dir = std::env::var("OUT_DIR").unwrap(); - let out_path = format!("{out_dir}/client_fields_generated.rs"); - let mut out = std::fs::File::create(&out_path) - .unwrap_or_else(|e| panic!("failed to create {out_path}: {e}")); - - // Emit CLIENT_OPTION_FIELDS - writeln!( - out, - "/// Field names on `PrimitiveClientOptions`, extracted from `llm_types.baml`." - ) - .unwrap(); - writeln!(out, "pub(super) const CLIENT_OPTION_FIELDS: &[&str] = &[").unwrap(); - for field in &client_option_fields { - writeln!(out, " \"{field}\",").unwrap(); - } - writeln!(out, "];").unwrap(); - writeln!(out).unwrap(); - - // Emit ProviderFieldConfig struct - writeln!( - out, - "/// Provider-to-options-class mapping, extracted from `llm_types.baml`." - ) - .unwrap(); - writeln!(out, "#[derive(Debug)]").unwrap(); - writeln!(out, "pub(super) struct ProviderFieldConfig {{").unwrap(); - writeln!(out, " pub(super) providers: &'static [&'static str],").unwrap(); - writeln!(out, " pub(super) options_type: Option<&'static str>,").unwrap(); - writeln!(out, " pub(super) fields: &'static [&'static str],").unwrap(); - writeln!(out, "}}").unwrap(); - writeln!(out).unwrap(); - - // Emit PROVIDER_CONFIGS - writeln!( - out, - "pub(super) const PROVIDER_CONFIGS: &[ProviderFieldConfig] = &[" - ) - .unwrap(); - - for config in &provider_configs { - let providers_list: Vec = config - .providers - .iter() - .map(|p| format!("\"{p}\"")) - .collect(); - let fields_list: Vec = config.fields.iter().map(|f| format!("\"{f}\"")).collect(); - writeln!(out, " ProviderFieldConfig {{").unwrap(); - writeln!(out, " providers: &[{}],", providers_list.join(", ")).unwrap(); - writeln!( - out, - " options_type: Some(\"{NS_PREFIX}{}\"),", - config.class_name - ) - .unwrap(); - writeln!(out, " fields: &[{}],", fields_list.join(", ")).unwrap(); - writeln!(out, " }},").unwrap(); - } - - // Emit no-options providers - if !no_options_providers.is_empty() { - let providers_list: Vec = no_options_providers - .iter() - .map(|p| format!("\"{p}\"")) - .collect(); - writeln!(out, " ProviderFieldConfig {{").unwrap(); - writeln!(out, " providers: &[{}],", providers_list.join(", ")).unwrap(); - writeln!(out, " options_type: None,").unwrap(); - writeln!(out, " fields: &[],").unwrap(); - writeln!(out, " }},").unwrap(); - } - - writeln!(out, "];").unwrap(); -} - -struct ProviderConfig { - providers: Vec, - class_name: String, - fields: Vec, -} - -/// Keep the Vertex-compatible portion of `GoogleAiOptions` synchronized with -/// `VertexAiOptions`. `enterprise` is the only Google-only field. -/// -/// This check intentionally compares both field order and type spelling: the -/// lowering code constructs provider option objects positionally in schema -/// order, and `sys_llm` projects Google options into the generated Vertex type. -fn assert_google_vertex_options_match(source: &str) { - let google = extract_class_shape(source, "GoogleAiOptions"); - let vertex = extract_class_shape(source, "VertexAiOptions"); - - let enterprise = google - .first() - .unwrap_or_else(|| panic!("GoogleAiOptions must declare enterprise as its first field")); - assert_eq!( - enterprise, - &("enterprise".to_string(), "bool | null".to_string()), - "GoogleAiOptions.enterprise must have type `bool | null`" - ); - assert_eq!( - &google[1..], - vertex.as_slice(), - "GoogleAiOptions fields after `enterprise` must exactly match VertexAiOptions" - ); -} - -/// Extract field names from a BAML class definition. -fn extract_class_fields(source: &str, class_name: &str) -> Vec { - extract_class_shape(source, class_name) - .into_iter() - .map(|(name, _)| name) - .collect() -} - -/// Extract top-level `(field name, type)` pairs from a BAML class definition. -fn extract_class_shape(source: &str, class_name: &str) -> Vec<(String, String)> { - let marker = format!("class {class_name} {{"); - let Some(start) = source.find(&marker) else { - panic!("class {class_name} not found in {LLM_TYPES_BAML}"); - }; - let body = &source[start + marker.len()..]; - let mut fields = Vec::new(); - let mut brace_depth = 1; - - for line in body.lines() { - // Strip inline comments before counting braces. - let code = line.split_once("//").map_or(line, |(code, _)| code).trim(); - brace_depth += code.chars().filter(|&c| c == '{').count(); - brace_depth -= code.chars().filter(|&c| c == '}').count(); - if brace_depth == 0 { - break; - } - // Skip empty lines and nested blocks (functions, etc.) - if code.is_empty() || code.starts_with("function ") { - continue; - } - // Skip lines that are inside nested blocks (brace_depth > 1) - if brace_depth > 1 { - continue; - } - // Field line: first word is the field name - if let Some((name, field_type)) = code.split_once(char::is_whitespace) { - // Skip keywords - if name == "class" || name == "}" || name == "{" { - continue; - } - fields.push((name.to_string(), field_type.trim().to_string())); - } - } - fields -} - -/// Extract `// @providers:` annotations and the class that follows each one. -fn extract_provider_configs(source: &str) -> Vec { - let mut configs = Vec::new(); - let lines: Vec<&str> = source.lines().collect(); - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("// @providers:") { - let providers: Vec = rest - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - // Find the next `class` line - for next_line in &lines[(i + 1)..] { - let next = next_line.trim(); - if next.is_empty() || next.starts_with("//") { - continue; - } - if let Some(rest) = next.strip_prefix("class ") { - let class_name = rest - .split_whitespace() - .next() - .unwrap_or("") - .trim_end_matches('{') - .trim() - .to_string(); - - let fields = extract_class_fields(source, &class_name); - configs.push(ProviderConfig { - providers, - class_name, - fields, - }); - } else { - panic!("@providers annotation not followed by a class, found: {next}"); - } - break; - } - } - } - configs -} - -/// Extract `// @providers-no-options:` provider list. -fn extract_no_options_providers(source: &str) -> Vec { - for line in source.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("// @providers-no-options:") { - return rest - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - } - Vec::new() -} diff --git a/baml_language/crates/baml_compiler2_ast/src/ast.rs b/baml_language/crates/baml_compiler2_ast/src/ast.rs index e89a58a0740..2952a206eaa 100644 --- a/baml_language/crates/baml_compiler2_ast/src/ast.rs +++ b/baml_language/crates/baml_compiler2_ast/src/ast.rs @@ -1500,8 +1500,9 @@ pub enum Item { pub enum DeclarativeMeta { /// LLM function metadata (client name, prompt template). /// Present only for functions declared with `{ client ...; prompt ... }` syntax. - /// The body is desugared to a synthetic `Expr` calling `baml.llm.call_llm_function`, - /// while this field preserves the original metadata for Jinja type-checking. + /// The body is desugared to a synthetic `Expr` that constructs an + /// `ai.FunctionSpec` and runs it through `ai.Agent`, while this field + /// preserves the original declaration metadata. Llm(LlmBodyDef), } @@ -1623,25 +1624,21 @@ pub enum BuiltinKind { pub struct LlmBodyDef { pub client: Option, pub prompt: Option, - /// BEP-049 M5e: for a new-mode (backtick) prompt, the pre-lowered body of - /// the `$stream` companion — a `stream_llm_function(...)` call whose 4th - /// argument is the synthesized prompt closure. Built in `lower_cst` while - /// the CST backtick literal is still in hand (the AST must stay CST-free for - /// Salsa: a rowan node is `!Send`), and consumed by PPIR when it - /// materializes the `$stream` companion. The closure must capture the - /// companion's params, so it can't be shared with the oneshot body by - /// `ExprId` — it's a fully independent arena. `None` for legacy Jinja - /// `#"..."#` prompts (their `$stream` companion uses the 3-arg Jinja path). - pub stream_body: Option<(ExprBody, AstSourceMap)>, - /// BEP-049 M5: for a new-mode (backtick) prompt, the pre-lowered bodies of - /// the `render_prompt` / `build_request` / `build_request_stream` companions, - /// keyed by target name. Each is a `(client, fn, args, - /// prompt_closure=…)` call carrying the same synthesized prompt closure, so - /// the static preview/cURL render through the closure exactly like execution. - /// Built in `lower_cst` while the CST backtick is in hand (same reason as - /// `stream_body`) and read back by `make_llm_companion`. Empty for legacy - /// Jinja `#"..."#` prompts (their companions use the 3-arg Jinja path). + /// Pre-lowered companion bodies keyed by target name. The single-path + /// world stashes exactly one: `"spec"` — the `$spec` body, built in + /// `lower_cst` while the CST backtick is still in hand (the AST must stay + /// CST-free for Salsa: a rowan node is `!Send`), and read back by + /// `companions::llm_spec`. Absent when the prompt or client is unusable + /// (a migration diagnostic was emitted instead). pub companion_bodies: Vec<(std::string::String, (ExprBody, AstSourceMap))>, + /// True when the function's `tools` field can hold tools at runtime: + /// any value other than an absent field or a literal empty list (`tools + /// []`). A non-literal expression (`tools shared()`) counts as `true` + /// even if it evaluates empty — the compile-time signal is conservative. + /// PPIR skips `$stream` synthesis when set (streaming does not run the + /// tool loop); `ai.stream.from_spec`'s runtime empty-toolbox check covers the + /// dynamic cases. + pub has_tools: bool, pub span: TextRange, } diff --git a/baml_language/crates/baml_compiler2_ast/src/companions.rs b/baml_language/crates/baml_compiler2_ast/src/companions.rs index 64a9ff0a336..20e141ad84b 100644 --- a/baml_language/crates/baml_compiler2_ast/src/companions.rs +++ b/baml_language/crates/baml_compiler2_ast/src/companions.rs @@ -7,27 +7,25 @@ //! //! Adding a new companion = writing one `fn(&FunctionDef) -> Option` //! and appending it to `COMPANIONS`. +//! +//! Every LLM function lives in the single-path ai world and gets three +//! companions: `$spec` (the bound, unrun `ai.FunctionSpec`), `$render_prompt` +//! (the spec's prompt rendered with the return type's output-format text), and +//! `$parse` (a network-free `baml.sap.parse` of an existing reply). +//! `$stream` is synthesized at PPIR level — its body needs the stream-expanded +//! return type, which only PPIR can compute. use baml_base::Name; use crate::{ DeclarativeMeta, - ast::{FunctionBodyDef, FunctionDef, Param, TypeExpr, TypeExprKind}, - lower_cst::{synthesize_llm_builtin_call, synthesize_llm_parse_call}, + ast::{FunctionBodyDef, FunctionDef, Param, TypeExprKind}, + lower_expr_body, }; type CompanionExpander = fn(&FunctionDef) -> Option; -// NOTE: the `$stream` and `$parse_stream` companions are synthesized at PPIR -// level (`baml_compiler2_ppir::ppir_expansion_items`), not here — their -// bodies need the stream-expanded return type, which only PPIR can compute. -// `$parse` (below) is defined here but likewise *invoked* from PPIR, with the -// stream-expanded type passed in. -const COMPANIONS: &[CompanionExpander] = &[ - llm_render_prompt, - llm_build_request, - llm_build_request_stream, -]; +const COMPANIONS: &[CompanionExpander] = &[llm_spec, llm_render_prompt, llm_parse]; /// Run all companion expanders on the given function. /// Works identically for top-level functions and class methods. @@ -38,84 +36,47 @@ pub(crate) fn expand_companions(func: &FunctionDef) -> Vec { .collect() } -fn llm_render_prompt(parent: &FunctionDef) -> Option { - // Only LLM functions get render_prompt / build_request companions. - if !matches!(&parent.declarative_meta, Some(DeclarativeMeta::Llm(_))) { - return None; +/// The parent's LLM metadata when its spec desugar succeeded — the gate for +/// every companion. `None` for non-LLM functions and for LLM functions whose +/// prompt/client was unusable (a migration diagnostic already fired; extra +/// companions would only cascade). +fn spec_llm_meta(parent: &FunctionDef) -> Option<&crate::ast::LlmBodyDef> { + match &parent.declarative_meta { + Some(DeclarativeMeta::Llm(llm)) + if llm.companion_bodies.iter().any(|(t, _)| t == "spec") => + { + Some(llm) + } + _ => None, } - Some(make_llm_companion( - parent, - "render_prompt", - &["baml", "llm", "PromptAst"], - )) } -fn llm_build_request(parent: &FunctionDef) -> Option { - // Only LLM functions get render_prompt / build_request companions. - if !matches!(&parent.declarative_meta, Some(DeclarativeMeta::Llm(_))) { - return None; - } - Some(make_llm_companion( - parent, - "build_request", - &["baml", "http", "Request"], - )) -} - -fn llm_build_request_stream(parent: &FunctionDef) -> Option { - if !matches!(&parent.declarative_meta, Some(DeclarativeMeta::Llm(_))) { - return None; - } - Some(make_llm_companion( - parent, - "build_request_stream", - &["baml", "http", "Request"], - )) +/// The function's own parameters — the compiler-injected `client` override +/// belongs to the runner, never to a companion's surface. +fn own_params(parent: &FunctionDef) -> Vec { + parent + .params + .iter() + .filter(|p| p.name.as_str() != "client") + .cloned() + .collect() } -/// Build the `$parse` companion for an LLM function. -/// -/// Unlike the expanders in `COMPANIONS`, this is invoked from PPIR -/// (`ppir_expansion_items`), not at CST-lowering time: the companion body -/// passes `` as explicit type args -/// (`baml.llm.parse<...>(json)`), and the stream-expanded return type is -/// only computable with PPIR context (package items, block attrs, alias -/// bodies) — hence the extra `type_args` parameter. -pub fn llm_parse(parent: &FunctionDef, type_args: Vec) -> Option { - // Only LLM functions get a parse companion. - if !matches!(&parent.declarative_meta, Some(DeclarativeMeta::Llm(_))) { - return None; - } - - let name = Name::new(format!("{}$parse", parent.name)); - - // Parse takes a `json: string` parameter instead of the parent's prompt - // params, but keeps the LLM client's default override for API consistency. - let json_param = Param { - name: Name::new("json"), - type_expr: Some((TypeExprKind::String { attrs: vec![] }).at(parent.span)), - default: None, - span: parent.span, - name_span: parent.name_span, - }; - - // Return type is the same as the parent function. - let return_type = parent.return_type.clone(); - - let (body, source_map) = synthesize_llm_parse_call(type_args, parent.span); - let mut params = vec![json_param]; - if let Some(client_param) = parent.params.iter().find(|p| p.name.as_str() == "client") { - params.push(client_param.clone()); - } - - Some(FunctionDef { +fn companion_def( + parent: &FunctionDef, + name: Name, + params: Vec, + return_type: Option, + body: (crate::ast::ExprBody, crate::ast::AstSourceMap), +) -> FunctionDef { + FunctionDef { name, generic_params: parent.generic_params.clone(), params, defaults: parent.defaults.clone(), return_type, throws: None, - body: Some(FunctionBodyDef::Expr(body, source_map)), + body: Some(FunctionBodyDef::Expr(body.0, body.1)), declarative_meta: None, metadata: crate::ast::FunctionMetadata::user_facing(crate::ast::FunctionOrigin::Companion), attributes: vec![], @@ -123,83 +84,85 @@ pub fn llm_parse(parent: &FunctionDef, type_args: Vec) -> Option$spec` companion: the function's parameters in, an +/// `ai.FunctionSpec` out. The body was pre-lowered in `lower_cst` (the +/// CST backtick is unreachable here) and stashed under the `"spec"` key. +fn llm_spec(parent: &FunctionDef) -> Option { + let llm = spec_llm_meta(parent)?; + let (body, source_map) = llm + .companion_bodies + .iter() + .find(|(t, _)| t == "spec") + .map(|(_, b)| b.clone())?; + let out = parent.return_type.clone()?; + + let return_type = (TypeExprKind::Path { + segments: vec![Name::new("ai"), Name::new("FunctionSpec")], + generic_args: vec![out], + associated_type_bindings: vec![], + attrs: vec![], }) + .at(parent.span); + + Some(companion_def( + parent, + Name::new(format!("{}$spec", parent.name)), + own_params(parent), + Some(return_type), + (body, source_map), + )) } -fn make_llm_companion( - parent: &FunctionDef, - target: &str, - return_type_path: &[&str], -) -> FunctionDef { - let name = Name::new(format!("{}${}", parent.name, target)); +/// Build the `$render_prompt` companion: the spec's prompt rendered with +/// the return type's output-format text as a structural `ai.Prompt`. +fn llm_render_prompt(parent: &FunctionDef) -> Option { + spec_llm_meta(parent)?; + let out = parent.return_type.clone()?; + let params = own_params(parent); + let param_names: Vec = params.iter().map(|p| p.name.clone()).collect(); + let body = lower_expr_body::synthesize_spec_render_prompt_body( + parent.name.as_str(), + ¶m_names, + Some(out), + parent.span, + ); let return_type = (TypeExprKind::Path { - segments: return_type_path.iter().map(Name::new).collect(), + segments: vec![Name::new("ai"), Name::new("Prompt")], generic_args: vec![], associated_type_bindings: vec![], attrs: vec![], }) .at(parent.span); - let param_names: Vec = parent - .params - .iter() - .filter(|p| p.name.as_str() != "client") - .map(|p| p.name.clone()) - .collect(); - // Extract client name from parent's LLM declarative meta. - let client_arg_name = match &parent.declarative_meta { - Some(DeclarativeMeta::Llm(_)) - if parent.params.iter().any(|p| p.name.as_str() == "client") => - { - Some("client") - } - Some(DeclarativeMeta::Llm(llm)) => llm.client.as_ref().map(smol_str::SmolStr::as_str), - _ => None, - }; - // New-mode (backtick) parents stashed a closure-carrying companion body for - // this target during CST lowering (the backtick CST isn't reachable here); - // use it so the companion renders the prompt through its closure — matching - // execution. Legacy Jinja parents fall back to the plain 3-arg builtin call. - let (body, source_map) = llm_companion_body(parent, target).unwrap_or_else(|| { - synthesize_llm_builtin_call( - target, - parent.name.as_str(), - ¶m_names, - client_arg_name, - Vec::new(), - parent.span, - ) - }); - FunctionDef { - name, - generic_params: parent.generic_params.clone(), - params: parent.params.clone(), - defaults: parent.defaults.clone(), - return_type: Some(return_type), - throws: None, - body: Some(FunctionBodyDef::Expr(body, source_map)), - declarative_meta: None, - metadata: crate::ast::FunctionMetadata::user_facing(crate::ast::FunctionOrigin::Companion), - attributes: vec![], - docstring: parent.docstring.clone(), - is_tagged_template_tag: parent.is_tagged_template_tag, - span: parent.span, - name_span: parent.name_span, - } + Some(companion_def( + parent, + Name::new(format!("{}$render_prompt", parent.name)), + params, + Some(return_type), + body, + )) } -/// The pre-lowered, closure-carrying companion body for `target` that a new-mode -/// (backtick) LLM parent stashed during CST lowering (see -/// [`LlmBodyDef::companion_bodies`](crate::ast::LlmBodyDef::companion_bodies)), -/// or `None` for a legacy Jinja parent (which renders via the 3-arg builtin). -fn llm_companion_body( - parent: &FunctionDef, - target: &str, -) -> Option<(crate::ast::ExprBody, crate::ast::AstSourceMap)> { - let Some(DeclarativeMeta::Llm(llm)) = &parent.declarative_meta else { - return None; +/// Build the `$parse` companion: a network-free parse of an existing +/// reply string into the function's return type. +fn llm_parse(parent: &FunctionDef) -> Option { + spec_llm_meta(parent)?; + let return_type = parent.return_type.clone()?; + let json_param = Param { + name: Name::new("json"), + type_expr: Some((TypeExprKind::String { attrs: vec![] }).at(parent.span)), + default: None, + span: parent.span, + name_span: parent.name_span, }; - llm.companion_bodies - .iter() - .find(|(t, _)| t == target) - .map(|(_, body)| body.clone()) + let body = lower_expr_body::synthesize_spec_parse_body(Some(return_type.clone()), parent.span); + Some(companion_def( + parent, + Name::new(format!("{}$parse", parent.name)), + vec![json_param], + Some(return_type), + body, + )) } diff --git a/baml_language/crates/baml_compiler2_ast/src/lib.rs b/baml_language/crates/baml_compiler2_ast/src/lib.rs index 624de52e2d0..4c622353bc1 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lib.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lib.rs @@ -12,7 +12,6 @@ pub mod cleanup_guard; pub(crate) mod companions; pub(crate) mod disambiguate; pub mod docstring; -pub(crate) mod lower_config_item; pub(crate) mod lower_cst; pub(crate) mod lower_expr_body; pub(crate) mod lower_type_expr; @@ -25,14 +24,10 @@ pub use ast::*; /// Re-exported from [`baml_base::escape::unescape_string_literal`] so existing /// callers don't need to change their import path. pub use baml_base::escape::unescape_string_literal; -pub use companions::llm_parse as llm_parse_companion; pub use disambiguate::is_field_attr; pub use docstring::extract_docstring; -pub use lower_cst::{ - lower_file, lower_file_with_path, lower_file_with_path_and_test_owner, - synthesize_llm_builtin_call, synthesize_llm_make_stream_call, -}; -pub use lower_expr_body::EnvVarRef; +pub use lower_cst::{lower_file, lower_file_with_path, lower_file_with_path_and_test_owner}; +pub use lower_expr_body::{EnvVarRef, synthesize_spec_stream_body}; pub use lowering_diagnostic::LoweringDiagnostic; // Re-exported so callers of `TypeExprKind::at(span)` can name the span type // without depending on `text_size` directly. @@ -480,17 +475,9 @@ mod tests { #[test] fn llm_function_user_client_param_is_reserved() { let source = r##" -client GPT4 { - provider "openai" - options { - model "gpt-4o" - api_key "test" - } -} - function Extract(client: string, text: string) -> string { - client GPT4 - prompt #"{{ text }}"# + client "openai/gpt-4o" + prompt `${text} ${ctx.output_format}` } "##; @@ -519,73 +506,49 @@ function Extract(client: string, text: string) -> string { let default_id = function.params[1].default.expect("expected client default"); let default_expr = &function.defaults.exprs.exprs[default_id.expr()]; assert!( - matches!(default_expr, Expr::Path(path) if path.len() == 1 && path[0].as_str() == "GPT4"), - "expected compiler-injected client default to reference GPT4, got {default_expr:#?}" + matches!(default_expr, Expr::Null), + "the injected ai.Client? override defaults to null, got {default_expr:#?}" ); } - /// The synthesized `$new` constructor for `client_name`. - fn client_new_companion(items: Vec, client_name: &str) -> crate::ast::FunctionDef { - let target = format!("{client_name}$new"); - items - .into_iter() - .find_map(|item| match item { - Item::Function(f) if f.name.as_str() == target => Some(f), - _ => None, - }) - .unwrap_or_else(|| panic!("expected synthesized {target} function")) - } - - /// Does `$new`'s body read `env_var` via a soft `baml.env.get`? - fn new_companion_reads_env(function: &crate::ast::FunctionDef, env_var: &str) -> bool { - use baml_base::Name; - let Some(FunctionBodyDef::Expr(body, _)) = &function.body else { - panic!("expected expression body for $new companion"); - }; - body.exprs.iter().any(|(_, expr)| { - let Expr::Call { callee, args, .. } = expr else { - return false; - }; - let Expr::Path(path) = &body.exprs[*callee] else { - return false; - }; - let is_env_get = - path.iter().map(Name::as_str).collect::>() == ["baml", "env", "get"]; - let reads_var = args.first().is_some_and(|arg| { - matches!( - &body.exprs[arg.expr], - Expr::Literal(baml_base::Literal::String(s)) if s == env_var - ) - }); - is_env_get && reads_var - }) + #[test] + fn client_block_is_a_migration_error() { + let source = r#" +client C { + provider openai + options { model "gpt-4o" } +} +"#; + let (items, diags) = parse_and_lower_with_diagnostics(source); + assert!(items.is_empty(), "client blocks lower to no items"); + assert!( + diags.iter().any(|diag| matches!( + diag, + crate::LoweringDiagnostic::ClientBlockRemoved { name, .. } if name == "C" + )), + "expected ClientBlockRemoved, got: {diags:#?}" + ); } #[test] - fn named_clients_do_not_apply_provider_defaults_during_lowering() { - for (provider, model, env_var) in [ - ("openai", "gpt-4o", "OPENAI_API_KEY"), - ("openai-responses", "gpt-4o", "OPENAI_API_KEY"), - ( - "anthropic", - "claude-3-5-sonnet-20241022", - "ANTHROPIC_API_KEY", - ), - ] { - let source = format!( - r#" -client C {{ - provider {provider} - options {{ model "{model}" }} -}} -"# - ); - let new_fn = client_new_companion(parse_and_lower(&source), "C"); - assert!( - !new_companion_reads_env(&new_fn, env_var), - "{provider} defaults must be applied at runtime" - ); - } + fn client_value_decl_lowers_to_client_let() { + use crate::ast::LetOrigin; + let source = r#" +client Fast = openai.OpenAiClient.new(model = "gpt-4o-mini"); +"#; + let items = parse_and_lower(source); + assert_eq!(items.len(), 1, "expected exactly one item"); + let Item::Let(let_def) = &items[0] else { + panic!("expected Item::Let, got {:?}", items[0]); + }; + assert_eq!(let_def.name.as_str(), "Fast"); + assert_eq!(let_def.origin, LetOrigin::Client); + let (body, _) = let_def.initializer.as_ref().expect("expected initializer"); + let root = body.root_expr.expect("expected root expr"); + assert!( + matches!(&body.exprs[root], Expr::Call { .. }), + "initializer is the user's constructor call" + ); } #[test] @@ -1965,112 +1928,24 @@ function f() -> int { #[test] fn retry_policy_produces_let_item_with_retry_policy_origin() { - use crate::ast::{Expr, Item, LetOrigin, Literal}; - + // Renamed behavior: retry_policy blocks are removed; retry composes + // at the client boundary (ai.Retry). let source = r#" retry_policy MyRetry { max_retries 3 - initial_delay_ms 500 - multiplier 2.0 - max_delay_ms 60000 } "#; - let items = parse_and_lower(source); - assert_eq!(items.len(), 1, "expected exactly one item"); - - let let_def = match &items[0] { - Item::Let(ld) => ld, - other => panic!("expected Item::Let, got {other:?}"), - }; - - assert_eq!(let_def.name.as_str(), "MyRetry"); - assert_eq!(let_def.origin, LetOrigin::RetryPolicy); - - let (body, source_map) = let_def.initializer.as_ref().expect("expected initializer"); - - let root_id = body.root_expr.expect("expected root expr"); + let (items, diags) = parse_and_lower_with_diagnostics(source); + assert!(items.is_empty(), "retry_policy lowers to no items"); assert!( - source_map.is_synthetic_expr(root_id), - "retry_policy's class-shaped initializer is compiler-synthesized" - ); - let root_expr = &body.exprs[root_id]; - - let (type_name, fields, _) = match root_expr { - Expr::Object { - type_name, - fields, - spreads, - .. - } => (type_name, fields, spreads), - other => panic!("expected Expr::Object, got {other:?}"), - }; - - assert_eq!( - type_name.to_string(), - "baml.llm.RetryPolicy", - "expected type_name to be baml.llm.RetryPolicy" - ); - - // Check field names - let field_names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect(); - assert_eq!( - field_names, - vec![ - "max_retries", - "initial_delay_ms", - "multiplier", - "max_delay_ms" - ] - ); - - // Check field values - let field_exprs: Vec<&Expr> = fields.iter().map(|(_, id)| &body.exprs[*id]).collect(); - - assert_eq!( - field_exprs[0], - &Expr::Literal(Literal::Int(3)), - "max_retries should be Int(3)" - ); - assert_eq!( - field_exprs[1], - &Expr::Literal(Literal::Int(500)), - "initial_delay_ms should be Int(500)" - ); - assert_eq!( - field_exprs[2], - &Expr::Literal(Literal::Float("2.0".to_string())), - "multiplier should be Float(2.0)" - ); - assert_eq!( - field_exprs[3], - &Expr::Literal(Literal::Int(60000)), - "max_delay_ms should be Int(60000)" + diags.iter().any(|diag| matches!( + diag, + crate::LoweringDiagnostic::RetryPolicyRemoved { name, .. } if name == "MyRetry" + )), + "expected RetryPolicyRemoved, got: {diags:#?}" ); } - #[test] - fn retry_policy_with_defaults_produces_let_item() { - use crate::ast::{Item, LetOrigin}; - - // A retry_policy with only max_retries set; other fields use defaults - let source = r#" -retry_policy Simple { - max_retries 5 -} -"#; - let items = parse_and_lower(source); - assert_eq!(items.len(), 1); - - let let_def = match &items[0] { - Item::Let(ld) => ld, - other => panic!("expected Item::Let, got {other:?}"), - }; - - assert_eq!(let_def.name.as_str(), "Simple"); - assert_eq!(let_def.origin, LetOrigin::RetryPolicy); - assert!(let_def.initializer.is_some(), "expected an initializer"); - } - #[test] fn quoted_string_literals_decode_escape_sequences() { let source = r#" diff --git a/baml_language/crates/baml_compiler2_ast/src/lower_config_item.rs b/baml_language/crates/baml_compiler2_ast/src/lower_config_item.rs deleted file mode 100644 index 0df00eaaa61..00000000000 --- a/baml_language/crates/baml_compiler2_ast/src/lower_config_item.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! CST `ConfigItem` → `Expr`. -//! -//! Generic lowering of CST config values into AST expressions. -//! Used by retry policy, client, and other config-block syntheses -//! so that each synthesis site only needs to wrap the results in -//! a typed `Expr::Object` rather than hand-parsing each field. - -use baml_base::{Literal, Name}; -use baml_compiler_syntax::{SyntaxKind, SyntaxNode, SyntaxNodeExt, ast as cst}; -use rowan::ast::AstNode; - -use crate::{ - ast::{CallArg, Expr, ExprId}, - lower_expr_body::EnvVarRef, -}; - -/// How an `env.VAR` reference in a config value is lowered. -/// -/// Most config values (retry policies, etc.) read env vars strictly: -/// `env.X` → `baml.env.get_or_panic("X")`, which panics if the variable is -/// unset. A generated client constructor instead threads its `lenient` -/// parameter here so that offline prompt rendering can build the client -/// without credentials: `env.X` → `baml.env.get_or_panic_lenient("X", lenient)`, -/// which yields "" instead of panicking when `lenient` is true. -#[derive(Clone, Copy)] -pub(crate) enum EnvReadMode<'a> { - /// `env.X` → `baml.env.get_or_panic("X")` (panics if unset). - Strict, - /// `env.X` → `baml.env.get_or_panic_lenient("X", )`, gated on the - /// enclosing function's `bool` parameter named ``. - Lenient(&'a Name), -} - -/// Lower a config item's value to an `Expr`, allocating into the caller's arena. -/// -/// Handles: integers, floats, quoted strings, `env.VAR` references, -/// bare words (as strings), nested blocks (as untyped objects), -/// and array literals. -/// -/// Also collects env var references (`env.X`) into `env_var_refs`. -pub(crate) fn lower_config_value_with_env_refs( - item: &cst::ConfigItem, - alloc: &mut impl FnMut(Expr) -> ExprId, - env_var_refs: &mut Vec, - env_mode: EnvReadMode<'_>, -) -> ExprId { - // 1. Nested block → Expr::Map (untyped) with recursively lowered children - if let Some(nested) = item.nested_block() { - return lower_config_block_to_map_with_env_refs(&nested, alloc, env_var_refs, env_mode); - } - - // 2. Value node — array literal (recursing per element) or scalar. - let Some(cv_node) = item.config_value_node() else { - return alloc(Expr::Null); - }; - lower_config_value_node( - &cv_node, - cv_node.span_range(), - alloc, - env_var_refs, - env_mode, - ) -} - -/// Lower a `CONFIG_VALUE` node into an `Expr`. -/// -/// Handles array literals (recursing per element) and scalars: integers, -/// floats, `env.VAR` references, bool literals, and quoted / bare-word strings. -/// Shared by a config item's value and every array element so that a non-string -/// element — e.g. the `123` in `allowed_roles ["user", 123]` — keeps its real -/// type instead of being erased to `Expr::Null` (which would surface to the user -/// as a spurious `got null` type error). -/// -/// `env_range` is the span recorded for an `env.VAR` reference found directly at -/// this node; array elements pass their own element span. -fn lower_config_value_node( - cv_node: &SyntaxNode, - env_range: rowan::TextRange, - alloc: &mut impl FnMut(Expr) -> ExprId, - env_var_refs: &mut Vec, - env_mode: EnvReadMode<'_>, -) -> ExprId { - // Array literal → recurse per element so each keeps its real type. - if let Some(array_literal) = cv_node - .children() - .find(|child| child.kind() == SyntaxKind::ARRAY_LITERAL) - { - let elements: Vec = array_literal - .children() - .filter_map(|element| match element.kind() { - SyntaxKind::CONFIG_VALUE => { - let element_range = element.span_range(); - Some(lower_config_value_node( - &element, - element_range, - alloc, - env_var_refs, - env_mode, - )) - } - // A `{ … }` array element (e.g. `tools [{ url "…" }]`) is a bare - // `CONFIG_BLOCK`; lower it to an untyped map like a nested block - // value, instead of silently dropping it. - SyntaxKind::CONFIG_BLOCK => cst::ConfigBlock::cast(element).map(|block| { - lower_config_block_to_map_with_env_refs(&block, alloc, env_var_refs, env_mode) - }), - _ => None, - }) - .collect(); - return alloc(Expr::Array { elements }); - } - - // Scalar — classify by token kind. Using `.all()` ensures mixed-token values - // like `"anthropic.claude-3-haiku-20240307-v1:0"` (which contains an - // INTEGER_LITERAL `0` among other tokens) are NOT misclassified as numbers. - // - // BUG: a few scalar forms still lower to a wrong-typed string rather than - // their literal because the config grammar/lexer doesn't surface them here: - // a negative number (`-5`) tokenizes as `MINUS` + `INTEGER_LITERAL`, so - // `is_int` is false and it becomes `String("-5")`; a bigint literal (`42n`) - // and an out-of-`i64`-range integer also fall through to a string; and an - // `env.X` reference *inside an array* mis-parses upstream. Fixing these - // belongs in the parser/lexer, not this lowering. - let non_trivia = || { - cv_node - .descendants_with_tokens() - .filter_map(rowan::NodeOrToken::into_token) - .filter(|token| !token.kind().is_trivia()) - }; - let is_int = non_trivia().all(|token| token.kind() == SyntaxKind::INTEGER_LITERAL); - let is_float = non_trivia().all(|token| token.kind() == SyntaxKind::FLOAT_LITERAL); - let scalar_text = cst::ConfigValue::cast(cv_node.clone()).and_then(|cv| cv.scalar_text()); - - if is_float && let Some(text) = &scalar_text { - return alloc(Expr::Literal(Literal::Float( - baml_base::num_lit::normalize_float_literal(text), - ))); - } - - if is_int - && let Some(value) = non_trivia() - .find(|token| token.kind() == SyntaxKind::INTEGER_LITERAL) - .and_then(|token| baml_base::num_lit::parse_int_literal(token.text()).ok()) - { - return alloc(Expr::Literal(Literal::Int(value))); - } - - // Fall through to text-based analysis. - let text = scalar_text.unwrap_or_default(); - - // env.VAR_NAME → baml.env.get_or_panic("VAR_NAME") - // (or, in a lenient client constructor: - // baml.env.get_or_panic_lenient("VAR_NAME", )) - if let Some(var_name) = text.strip_prefix("env.") { - env_var_refs.push(EnvVarRef { - name: var_name.to_string(), - range: env_range, - }); - let arg = alloc(Expr::Literal(Literal::String(var_name.to_string()))); - return match env_mode { - EnvReadMode::Strict => { - let callee = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("env"), - Name::new("get_or_panic"), - ])); - alloc(Expr::Call { - callee, - type_args: vec![], - args: vec![CallArg::positional(arg)], - }) - } - EnvReadMode::Lenient(lenient_param) => { - let callee = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("env"), - Name::new("get_or_panic_lenient"), - ])); - let lenient = alloc(Expr::Path(vec![lenient_param.clone()])); - alloc(Expr::Call { - callee, - type_args: vec![], - args: vec![CallArg::positional(arg), CallArg::positional(lenient)], - }) - } - }; - } - - // Bool literals - if text == "true" { - return alloc(Expr::Literal(Literal::Bool(true))); - } - if text == "false" { - return alloc(Expr::Literal(Literal::Bool(false))); - } - - // Quoted string or bare word → String literal - let cleaned = text.trim_matches('"'); - alloc(Expr::Literal(Literal::String(cleaned.to_string()))) -} - -/// Lower a nested `ConfigBlock` into an `Expr::Map`. -/// -/// Nested config blocks are untyped key-value structures (not class instances), -/// so they are lowered as maps. This ensures they work correctly when stored in -/// `map` fields like `request_body`. -fn lower_config_block_to_map_with_env_refs( - block: &cst::ConfigBlock, - alloc: &mut impl FnMut(Expr) -> ExprId, - env_var_refs: &mut Vec, - env_mode: EnvReadMode<'_>, -) -> ExprId { - let entries: Vec<(ExprId, ExprId)> = block - .items() - .filter_map(|item| { - let key = item.key()?; - let k = alloc(Expr::Literal(Literal::String(key.text().to_string()))); - let v = lower_config_value_with_env_refs(&item, alloc, env_var_refs, env_mode); - Some((k, v)) - }) - .collect(); - - alloc(Expr::Map { entries }) -} - -#[cfg(test)] -mod tests { - use baml_base::FileId; - use baml_compiler_lexer::lex_lossless; - use baml_compiler_parser::parse_file; - use baml_compiler_syntax::{SyntaxNode, ast as cst}; - use rowan::ast::AstNode; - - use super::*; - - fn parse(source: &str) -> SyntaxNode { - let tokens = lex_lossless(source, FileId::new(0)); - let (green, errors) = parse_file(&tokens); - assert!( - errors.is_empty(), - "expected no parse errors, got: {errors:#?}" - ); - SyntaxNode::new_root(green) - } - - /// Find the first `ConfigItem` whose key matches `target_key` inside a - /// client definition's config block. - fn find_config_item(root: &SyntaxNode, target_key: &str) -> cst::ConfigItem { - root.descendants() - .filter_map(cst::ConfigItem::cast) - .find(|item| item.key().map(|k| k.text().to_string()) == Some(target_key.to_string())) - .expect("config item not found") - } - - #[test] - fn nested_block_lowers_to_map() { - let source = r#" -client MyClient { - provider openai - options { - model "gpt-4" - temperature 0.7 - } -} -"#; - let root = parse(source); - let options_item = find_config_item(&root, "options"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { exprs.alloc(expr) }; - - let result_id = lower_config_value_with_env_refs( - &options_item, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - let result_expr = &exprs[result_id]; - - match result_expr { - Expr::Map { entries } => { - assert_eq!(entries.len(), 2); - - // First entry: "model" => "gpt-4" - assert_eq!( - exprs[entries[0].0], - Expr::Literal(Literal::String("model".to_string())) - ); - assert_eq!( - exprs[entries[0].1], - Expr::Literal(Literal::String("gpt-4".to_string())) - ); - - // Second entry: "temperature" => 0.7 - assert_eq!( - exprs[entries[1].0], - Expr::Literal(Literal::String("temperature".to_string())) - ); - assert_eq!( - exprs[entries[1].1], - Expr::Literal(Literal::Float("0.7".to_string())) - ); - } - other => panic!("expected Expr::Map, got {other:?}"), - } - } - - #[test] - fn doubly_nested_block_lowers_to_nested_maps() { - let source = r#" -client MyClient { - provider openai - options { - headers { - x-api-key "secret" - } - } -} -"#; - let root = parse(source); - let options_item = find_config_item(&root, "options"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { exprs.alloc(expr) }; - - let result_id = lower_config_value_with_env_refs( - &options_item, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - let outer_map = &exprs[result_id]; - - let entries = match outer_map { - Expr::Map { entries } => entries.clone(), - other => panic!("expected Expr::Map, got {other:?}"), - }; - assert_eq!(entries.len(), 1); - - assert_eq!( - exprs[entries[0].0], - Expr::Literal(Literal::String("headers".to_string())) - ); - - // The value should itself be a Map - let inner_map = &exprs[entries[0].1]; - let inner_entries = match inner_map { - Expr::Map { entries } => entries, - other => panic!("expected nested Expr::Map, got {other:?}"), - }; - assert_eq!(inner_entries.len(), 1); - assert_eq!( - exprs[inner_entries[0].0], - Expr::Literal(Literal::String("x-api-key".to_string())) - ); - assert_eq!( - exprs[inner_entries[0].1], - Expr::Literal(Literal::String("secret".to_string())) - ); - } - - #[test] - fn commented_numeric_config_value_lowers_to_int() { - let source = r#" -client MyClient { - provider openai - options { - max_retries // key trailing - // value leading - 3 // value trailing - max_delay_ms /* key trailing */ - /* value leading */ - 1000 /* value trailing */ - } -} -"#; - let root = parse(source); - let max_retries = find_config_item(&root, "max_retries"); - let max_delay_ms = find_config_item(&root, "max_delay_ms"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { exprs.alloc(expr) }; - - let max_retries_id = lower_config_value_with_env_refs( - &max_retries, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - let max_delay_ms_id = lower_config_value_with_env_refs( - &max_delay_ms, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - - assert_eq!(exprs[max_retries_id], Expr::Literal(Literal::Int(3))); - assert_eq!(exprs[max_delay_ms_id], Expr::Literal(Literal::Int(1000))); - } - - #[test] - fn array_keeps_non_string_element_types() { - // Regression: a non-string array element (`123`) must lower to its real - // literal, not `Expr::Null`. Erasing it to null makes the type checker - // report a bogus `got null` rather than `got 123` for the bad value. - let source = r#" -client MyClient { - provider openai - options { - allowed_roles ["user", 123, "assistant"] - } -} -"#; - let root = parse(source); - let allowed_roles = find_config_item(&root, "allowed_roles"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { exprs.alloc(expr) }; - - let result_id = lower_config_value_with_env_refs( - &allowed_roles, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - - let elements = match &exprs[result_id] { - Expr::Array { elements } => elements.clone(), - other => panic!("expected Expr::Array, got {other:?}"), - }; - assert_eq!(elements.len(), 3); - assert_eq!( - exprs[elements[0]], - Expr::Literal(Literal::String("user".to_string())) - ); - assert_eq!(exprs[elements[1]], Expr::Literal(Literal::Int(123))); - assert_eq!( - exprs[elements[2]], - Expr::Literal(Literal::String("assistant".to_string())) - ); - } - - #[test] - fn array_keeps_config_block_elements() { - // Regression (M7): a `{ … }` array element is a bare CONFIG_BLOCK; it must - // lower to an untyped map, not be silently dropped from the array. - let source = r#" -client MyClient { - provider openai - options { - tools [{ url "https://example.com" }, "plain"] - } -} -"#; - let root = parse(source); - let tools = find_config_item(&root, "tools"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { exprs.alloc(expr) }; - - let result_id = lower_config_value_with_env_refs( - &tools, - &mut alloc, - &mut Vec::new(), - EnvReadMode::Strict, - ); - - let elements = match &exprs[result_id] { - Expr::Array { elements } => elements.clone(), - other => panic!("expected Expr::Array, got {other:?}"), - }; - assert_eq!(elements.len(), 2, "the block element must not be dropped"); - assert!( - matches!(exprs[elements[0]], Expr::Map { .. }), - "block array element should lower to a map, got {:?}", - exprs[elements[0]] - ); - assert_eq!( - exprs[elements[1]], - Expr::Literal(Literal::String("plain".to_string())) - ); - } -} diff --git a/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs b/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs index d3880b9793a..95c9ab5d8fa 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs @@ -8,19 +8,18 @@ //! No LLM function expansion, no attribute validation, no duplicate detection — //! all of that moves downstream. -use baml_base::{ClientOptionsPresence, Name, TypePath}; +use baml_base::Name; use baml_compiler_syntax::{SyntaxKind, SyntaxNode, SyntaxNodeExt, ast}; use rowan::ast::AstNode; use crate::{ DeclarativeMeta, LoweringDiagnostic, ast::{ - AssociatedTypeBindingDef, AssociatedTypeDef, AstSourceMap, BuiltinKind, CallArg, EnumDef, - Expr, ExprBody, ExprId, FieldDef, FunctionBodyDef, FunctionDef, FunctionDefaults, - ImplementsBlockDef, ImplementsForDef, InterfaceDef, InterfaceFieldLinkDef, Interpolation, - Item, LambdaDef, LambdaKind, LetDef, LetOrigin, LlmBodyDef, MethodSigDef, Param, - RawAttribute, RawAttributeArg, RawPrompt, TemplateStringDef, TestArgValue, TestDef, - TypeAliasDef, TypeExpr, TypeExprKind, VariantDef, + AssociatedTypeBindingDef, AssociatedTypeDef, BuiltinKind, CallArg, EnumDef, Expr, ExprId, + FieldDef, FunctionBodyDef, FunctionDef, FunctionDefaults, ImplementsBlockDef, + ImplementsForDef, InterfaceDef, InterfaceFieldLinkDef, Interpolation, Item, LambdaDef, + LambdaKind, LlmBodyDef, MethodSigDef, Param, RawAttribute, RawAttributeArg, RawPrompt, + TemplateStringDef, TestArgValue, TestDef, TypeAliasDef, TypeExpr, TypeExprKind, VariantDef, }, companions::expand_companions, lower_expr_body, lower_type_expr, @@ -119,16 +118,25 @@ pub fn lower_file_with_path_and_test_owner( items.push(Item::TypeAlias(ta)); } } - baml_compiler_syntax::SyntaxKind::CLIENT_DEF => { - if let Some((let_item, companion)) = - synthesize_client_items(&child, &mut diags, &mut env_var_refs) + baml_compiler_syntax::SyntaxKind::CLIENT_VALUE_DEF => { + if let Some(let_item) = + lower_client_value_def(&child, &mut diags, &mut env_var_refs) { items.push(let_item); - if let Some(func) = companion { - items.push(Item::Function(func)); - } } } + baml_compiler_syntax::SyntaxKind::CLIENT_DEF => { + // Legacy `client Name { ... }` config block: removed in + // the single-path world. Parse succeeded so the error is one + // targeted diagnostic, not a cascade. + let name = ast::ClientDef::cast(child.clone()) + .and_then(|c| c.name()) + .map_or_else(|| "MyClient".to_string(), |t| t.text().to_string()); + diags.push(LoweringDiagnostic::ClientBlockRemoved { + name, + span: child.span_range(), + }); + } baml_compiler_syntax::SyntaxKind::TEST_DEF => { if let Some(t) = lower_test(&child, &mut diags) { items.push(Item::Test(t)); @@ -153,11 +161,15 @@ pub fn lower_file_with_path_and_test_owner( } } baml_compiler_syntax::SyntaxKind::RETRY_POLICY_DEF => { - if let Some(let_item) = - synthesize_retry_policy_let(&child, &mut diags, &mut env_var_refs) - { - items.push(let_item); - } + // Legacy `retry_policy` block: retry composes at the client + // boundary now (ai.Retry). + let name = ast::RetryPolicyDef::cast(child.clone()) + .and_then(|r| r.name()) + .map_or_else(|| "policy".to_string(), |t| t.text().to_string()); + diags.push(LoweringDiagnostic::RetryPolicyRemoved { + name, + span: child.span_range(), + }); } baml_compiler_syntax::SyntaxKind::IMPLEMENTS_FOR => { if let Some(imp) = lower_implements_for(&child, &mut diags, &mut env_var_refs) { @@ -327,97 +339,75 @@ fn lower_function( let (body, declarative_meta) = if let Some(llm) = func.llm_body() { let mut llm_body_def = lower_llm_body(&llm); reject_reserved_llm_client_params(&mut params, name.as_str(), diags); - let client_name = llm_body_def.client.as_ref().map(|n| n.as_str().to_string()); - if let Some(client_name) = client_name.as_deref() { - append_default_client_param(&mut params, &mut defaults, client_name, llm_body_def.span); + let prompt_backtick = llm.prompt_field().and_then(|pf| pf.backtick_string()); + + // Jinja prompts are removed: the single-path world renders prompts as + // plain backtick templates through the spec. + if llm.prompt_field().and_then(|pf| pf.raw_string()).is_some() { + diags.push(LoweringDiagnostic::LlmJinjaPromptRemoved { + function_name: name.as_str().to_string(), + span: llm_body_def.span, + }); } + + // Resolve the client: a quoted "provider/model" string maps at + // compile time to a provider constructor; anything else is an + // expression evaluating to ai.Client. + let client_value = llm.client_field().and_then(|cf| cf.value_element()); + let client_spec = resolve_llm_client(name.as_str(), client_value, llm_body_def.span, diags); + + // The function's real parameters — the injected `client` override is + // added below and is never part of the spec's bound arguments. let param_names: Vec = params .iter() .filter(|p| p.name.as_str() != "client") .map(|p| p.name.clone()) .collect(); - let client_arg_name = client_name.as_ref().map(|_| "client"); - // Pass the LLM function's declared return type as the explicit `` - // type argument to `baml.llm.call_llm_function`. This is required - // for the runtime type-arg threading: without it, `T` falls back to - // inferred-only and resolves to BuiltinUnknown inside the stdlib's - // `primitive.parse(body)` call, surfacing as a "Non-parsable type: - // BuiltinUnknown" error from the LLM client. - let call_type_args: Vec = return_type - .as_ref() - .map(|rt| vec![rt.clone()]) - .unwrap_or_default(); - // New-mode (BEP-049 M5f): a backtick prompt compiles to a `prompt`…`` - // closure passed as the 4th arg to `call_llm_function`; the orchestrator - // invokes it per attempt. Legacy `#"..."#` Jinja prompts keep the 3-arg - // path (the closure defaults to `null`, so the Jinja render runs). - let prompt_backtick = llm.prompt_field().and_then(|pf| pf.backtick_string()); - let (expr_body, source_map) = if let Some(backtick) = &prompt_backtick { - let (body, sm, mut closure_diags, mut closure_env_refs) = - lower_expr_body::synthesize_llm_call_with_prompt( - "call_llm_function", - name.as_str(), - ¶m_names, - client_arg_name, - call_type_args, - backtick, - llm_body_def.span, - ); - diags.append(&mut closure_diags); - env_var_refs.append(&mut closure_env_refs); - // BEP-049 M5e: pre-build the streaming companion's body from the - // same backtick now, while the CST is in hand, and stash it for - // PPIR (which materializes the `$stream` companion but no longer has - // the CST). The closure captures this function's params, so it's a - // separate arena from the oneshot body above. Its prompt diagnostics - // / `env.X` refs duplicate the oneshot body's — drop them. - let (stream_body, stream_sm, _diags, _env_refs) = - lower_expr_body::synthesize_llm_call_with_prompt( - "stream_llm_function", + + // Build and stash the `$spec` companion body while the CST backtick + // is in hand (read back by `companions::llm_spec`). Skipped when the + // prompt or client is unusable — the migration diagnostics above are + // the authoritative errors then. + if let (Some(backtick), Some(client_spec)) = (&prompt_backtick, client_spec) { + let tools_value = tools_value_element(&llm); + let (spec_body, spec_sm, mut spec_diags, mut spec_env_refs) = + lower_expr_body::synthesize_llm_spec_body( name.as_str(), ¶m_names, - client_arg_name, - Vec::new(), + &client_spec, + return_type.clone(), + tools_value.as_ref(), backtick, llm_body_def.span, ); - llm_body_def.stream_body = Some((stream_body, stream_sm)); - // BEP-049 M5: pre-build the render_prompt / build_request / - // build_request_stream companion bodies from the same backtick, each - // carrying the prompt closure, so the playground preview/cURL render - // through the closure exactly like execution. Built here while the CST - // is in hand; read back by `make_llm_companion`. Their prompt diags / - // `env.X` refs duplicate the oneshot body's — drop them. - for target in ["render_prompt", "build_request", "build_request_stream"] { - let (c_body, c_sm, _diags, _env_refs) = - lower_expr_body::synthesize_llm_call_with_prompt( - target, - name.as_str(), - ¶m_names, - client_arg_name, - Vec::new(), - backtick, - llm_body_def.span, - ); - llm_body_def - .companion_bodies - .push((target.to_string(), (c_body, c_sm))); - } - (body, sm) - } else { - synthesize_llm_builtin_call( - "call_llm_function", + diags.append(&mut spec_diags); + env_var_refs.append(&mut spec_env_refs); + llm_body_def + .companion_bodies + .push(("spec".to_string(), (spec_body, spec_sm))); + } + + // Every LLM function runs the ai Agent loop; `client: ai.Client? = + // null` is the compiler-injected per-call override. When the spec + // could not be synthesized (migration diagnostics fired), the body is + // omitted so the missing `$spec` reference never cascades. + append_spec_client_param(&mut params, &mut defaults, llm_body_def.span); + let body = if llm_body_def + .companion_bodies + .iter() + .any(|(t, _)| t == "spec") + { + let (expr_body, source_map) = lower_expr_body::synthesize_spec_agent_run_body( name.as_str(), ¶m_names, - client_arg_name, - call_type_args, + return_type.clone(), llm_body_def.span, - ) + ); + Some(FunctionBodyDef::Expr(expr_body, source_map)) + } else { + None }; - ( - Some(FunctionBodyDef::Expr(expr_body, source_map)), - Some(DeclarativeMeta::Llm(llm_body_def)), - ) + (body, Some(DeclarativeMeta::Llm(llm_body_def))) } else if let Some(expr) = func.expr_body() { // Check if the body is `$rust_function` or `$rust_io_function` before lowering if let Some(builtin_kind) = check_builtin_body(expr.syntax()) { @@ -603,25 +593,69 @@ pub(crate) fn lower_param( }) } -pub(crate) fn append_default_client_param( +/// Lower `client Name = ;` to a top-level client let binding — the +/// same [`LetOrigin::Client`] item the legacy config blocks produced, so the +/// name resolves from every function body, but the initializer is the user's +/// own expression (evaluated once at `$init`; client construction is pure). +fn lower_client_value_def( + node: &SyntaxNode, + diags: &mut Vec, + env_var_refs: &mut Vec, +) -> Option { + let def = ast::ClientValueDef::cast(node.clone())?; + let Some(name_token) = def.name() else { + diags.push(LoweringDiagnostic::MissingItemName { + item_kind: "client", + span: node.span_range(), + }); + return None; + }; + let (body, source_map) = lower_expr_body::lower_client_initializer( + def.value_element().as_ref(), + node.span_range(), + diags, + env_var_refs, + ); + Some(Item::Let(crate::ast::LetDef { + name: Name::new(name_token.text()), + initializer: Some((body, source_map)), + origin: crate::ast::LetOrigin::Client, + span: node.span_range(), + name_span: name_token.text_range(), + })) +} + +/// Append the spec-mode client override parameter: `client: ai.Client? = null`. +/// +/// The spec-mode direct-call body passes it to `ai.Agent.new(client = client)`; +/// a null override makes the runner fall back to the spec's default client. +pub(crate) fn append_spec_client_param( params: &mut Vec, defaults: &mut FunctionDefaults, - client_name: &str, span: text_size::TextRange, ) { - let default_expr = alloc_client_override_default_expr(defaults, client_name, span); + let null_default = { + let id = defaults.exprs.exprs.alloc(Expr::Null); + defaults.source_map.expr_spans.alloc(span); + id + }; + let client_ty = TypeExprKind::Path { + segments: vec![Name::new("ai"), Name::new("Client")], + generic_args: vec![], + associated_type_bindings: vec![], + attrs: vec![], + } + .at(span); params.push(Param { name: Name::new("client"), type_expr: Some( - TypeExprKind::Path { - segments: vec![Name::new("baml"), Name::new("llm"), Name::new("Client")], - generic_args: vec![], - associated_type_bindings: vec![], + TypeExprKind::Optional { + inner: Box::new(client_ty), attrs: vec![], } .at(span), ), - default: Some(crate::ast::DefaultExprId::new(default_expr)), + default: Some(crate::ast::DefaultExprId::new(null_default)), span, name_span: span, }); @@ -651,60 +685,6 @@ fn reject_reserved_llm_client_params( } } -fn alloc_client_override_default_expr( - defaults: &mut FunctionDefaults, - client_name: &str, - span: text_size::TextRange, -) -> ExprId { - fn alloc(defaults: &mut FunctionDefaults, expr: Expr, span: text_size::TextRange) -> ExprId { - let id = defaults.exprs.exprs.alloc(expr); - defaults.source_map.expr_spans.alloc(span); - id - } - - if client_name.contains('/') { - // Shorthand clients are not backed by a synthesized top-level let binding, - // so defaults have to carry the inline Client value. - let name_lit = alloc( - defaults, - Expr::Literal(crate::ast::Literal::String(client_name.to_string())), - span, - ); - let client_type = alloc( - defaults, - Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("ClientType"), - Name::new("Primitive"), - ]), - span, - ); - let sub_clients = alloc(defaults, Expr::Array { elements: vec![] }, span); - let retry = alloc(defaults, Expr::Null, span); - let counter = alloc(defaults, Expr::Literal(crate::ast::Literal::Int(0)), span); - alloc( - defaults, - Expr::Object { - type_name: TypePath::from_dotted("baml.llm.Client"), - type_args: vec![], - fields: vec![ - (Name::new("name"), name_lit), - (Name::new("client_type"), client_type), - (Name::new("sub_clients"), sub_clients), - (Name::new("retry"), retry), - (Name::new("counter"), counter), - ], - spreads: vec![], - }, - span, - ) - } else { - // Named client declarations already lower to a top-level client let binding. - alloc(defaults, Expr::Path(vec![Name::new(client_name)]), span) - } -} - fn lower_llm_body(llm_body: &ast::LlmFunctionBody) -> LlmBodyDef { let span = llm_body.syntax().span_range(); @@ -722,279 +702,129 @@ fn lower_llm_body(llm_body: &ast::LlmFunctionBody) -> LlmBodyDef { client, prompt, // Filled in by the LLM-function branch once param names are known. - stream_body: None, companion_bodies: Vec::new(), + has_tools: llm_tools_present(llm_body), span, } } -/// Build a synthetic expression body equivalent to: -/// `baml.llm.(client, "FunctionName", { "param1": param1, "param2": param2 })` -/// -/// When `client_name` is `Some("MyClient")`, the first argument is `Expr::Path(["MyClient"])`. -/// When `client_name` is `Some("openai/gpt-4o")` (a shorthand with `/`), the first argument is -/// an inline `Client { name, client_type: ClientType.Primitive, sub_clients: [], retry: null, counter: 0 }`. -/// When `client_name` is `None`, `Expr::Null` is used as a fallback. -/// -/// LLM functions and request/render/stream companions pass a client as the first argument. -/// After default-client parameter synthesis, call sites pass `client_name = Some("client")` -/// so explicit `client=` overrides flow through the generated body. -/// -/// All synthetic spans point to `span`. -pub fn synthesize_llm_builtin_call( - builtin_name: &str, - function_name: &str, - param_names: &[Name], - client_name: Option<&str>, - type_args: Vec, - span: text_size::TextRange, -) -> (crate::ast::ExprBody, crate::ast::AstSourceMap) { - use la_arena::Arena; - - use crate::ast::{AstSourceMap, Expr, ExprBody, Literal}; - - let mut exprs = Arena::new(); - let mut expr_spans = Arena::new(); - - // Helper: allocate an expr + its span - let mut alloc = |expr: Expr| -> crate::ast::ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - // 1. Function name literal: "FunctionName" - let fn_name_expr = alloc(Expr::Literal(Literal::String(function_name.to_string()))); - - // 2. Map entries: { "param1": param1, "param2": param2 } - let entries: Vec<(crate::ast::ExprId, crate::ast::ExprId)> = param_names - .iter() - .map(|name| { - let key = alloc(Expr::Literal(Literal::String(name.as_str().to_string()))); - let value = alloc(Expr::Path(vec![name.clone()])); - (key, value) - }) - .collect(); - let args_map = alloc(Expr::Map { entries }); - - // Callee: baml.llm. as a multi-segment Path - let callee = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new(builtin_name), - ])); - - // Build the client expression from the client name. - // All LLM builtins (call_llm_function, render_prompt, build_request) take - // a Client as the first argument. - let client_arg = match client_name { - Some(name) if name.contains('/') => { - // Shorthand client (e.g. "openai/gpt-4o"): build an inline Client object. - let name_lit = alloc(Expr::Literal(Literal::String(name.to_string()))); - let ct_variant = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("ClientType"), - Name::new("Primitive"), - ])); - let sub = alloc(Expr::Array { elements: vec![] }); - let retry = alloc(Expr::Null); - let counter = alloc(Expr::Literal(Literal::Int(0))); - alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.Client"), - type_args: vec![], - fields: vec![ - (Name::new("name"), name_lit), - (Name::new("client_type"), ct_variant), - (Name::new("sub_clients"), sub), - (Name::new("retry"), retry), - (Name::new("counter"), counter), - ], - spreads: vec![], - }) - } - Some(name) => { - // Named client: Expr::Path(["MyClient"]) — TIR resolves to the let binding. - alloc(Expr::Path(vec![Name::new(name)])) +/// Whether the `tools` field can hold tools at runtime. An absent field and +/// the literal empty list (`tools []`) are tool-less; a non-empty literal or +/// any other expression is conservatively tools-bearing (an arbitrary +/// expression may evaluate empty, but that is only known at runtime). +fn llm_tools_present(llm_body: &ast::LlmFunctionBody) -> bool { + match tools_value_element(llm_body) { + None => false, + Some(rowan::NodeOrToken::Node(node)) if node.kind() == SyntaxKind::ARRAY_LITERAL => { + // Elements are expression nodes or bare-identifier tokens. + node.children().next().is_some() + || node + .children_with_tokens() + .filter_map(rowan::NodeOrToken::into_token) + .any(|t| lower_expr_body::is_ident_token(t.kind())) } - None => { - // No client specified (e.g. missing `client` field) — use null as fallback. - alloc(Expr::Null) - } - }; - let call = alloc(Expr::Call { - callee, - type_args, - args: vec![ - CallArg::positional(client_arg), - CallArg::positional(fn_name_expr), - CallArg::positional(args_map), - ], - }); - - let body = ExprBody { - exprs, - stmts: Arena::new(), - patterns: Arena::new(), - match_arms: Arena::new(), - catch_arms: Arena::new(), - type_annotations: Arena::new(), - root_expr: Some(call), - }; - - let source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; - - (body, source_map) + Some(_) => true, + } } -/// Synthesize a `baml.llm.parse(json)` call. +/// The provider package + client class the `@spec` desugar constructs for a +/// `"prefix/model"` client string, or `None` for an unknown prefix. /// -/// Unlike `synthesize_llm_builtin_call`, there is no client argument and -/// the only argument is a single `json` identifier (a path expression) -/// rather than a map of parent params. The explicit type args carry the -/// stream-expanded and original return types so the stdlib `parse` can -/// reify them via `reflect.type_of` instead of a name-keyed registry -/// lookup (same threading as `call_llm_function`). -pub(crate) fn synthesize_llm_parse_call( - type_args: Vec, - span: text_size::TextRange, -) -> (crate::ast::ExprBody, crate::ast::AstSourceMap) { - use la_arena::Arena; - - use crate::ast::{AstSourceMap, Expr, ExprBody}; - - let mut exprs = Arena::new(); - let mut expr_spans = Arena::new(); - - let mut alloc = |expr: Expr| -> crate::ast::ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - // 1. `json` parameter reference - let json_expr = alloc(Expr::Path(vec![Name::new("json")])); - - // 2. Callee: baml.llm.parse - let callee = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("parse"), - ])); - - let call = alloc(Expr::Call { - callee, - type_args, - args: vec![CallArg::positional(json_expr)], - }); - - let body = ExprBody { - exprs, - stmts: Arena::new(), - patterns: Arena::new(), - match_arms: Arena::new(), - catch_arms: Arena::new(), - type_annotations: Arena::new(), - root_expr: Some(call), - }; - - let source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; +/// Kept in sync with the builtin provider packages (`baml_std/openai` etc.) +/// and the user-land `resolve()` convention. +pub(crate) fn spec_client_provider(client: &str) -> Option<(&'static str, &'static str)> { + let (prefix, _model) = client.split_once('/')?; + match prefix { + "openai" => Some(("openai", "OpenAiClient")), + "anthropic" => Some(("anthropic", "AnthropicClient")), + "google" => Some(("google", "GoogleClient")), + "claude-code" => Some(("claude_code", "ClaudeCodeClient")), + _ => None, + } +} - (body, source_map) +/// How an LLM function's `client:` value lowers into the spec's +/// `default_client`. +pub(crate) enum LlmClientSpec { + /// A `"provider/model"` string, mapped at compile time to a builtin + /// provider constructor. + Provider { + pkg: &'static str, + class: &'static str, + model: String, + }, + /// An arbitrary expression evaluating to `ai.Client` (a declared client + /// name, a constructor call, a wrapper, ...). + Expr(rowan::NodeOrToken), } -/// Synthesize a `CLIENT.__make_stream(sse)` method call. -/// -/// Used by the PPIR to generate `$parse_stream` companion function bodies. -/// The explicit type args carry the stream-expanded and original return -/// types into `__make_stream`'s frame, where `reflect.type_of` reifies them -/// for `StreamCache.new`. -pub fn synthesize_llm_make_stream_call( - type_args: Vec, - client_name: &str, +/// Classify the client field's value element, emitting migration diagnostics +/// for the unusable shapes. `None` means an authoritative error was emitted +/// (or the parser already reported a missing value). +fn resolve_llm_client( + function_name: &str, + value: Option>, span: text_size::TextRange, -) -> (crate::ast::ExprBody, crate::ast::AstSourceMap) { - use la_arena::Arena; - - use crate::ast::{AstSourceMap, Expr, ExprBody, Literal}; - - let mut exprs = Arena::new(); - let mut expr_spans = Arena::new(); - - let mut alloc = |expr: Expr| -> crate::ast::ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - // 1. `sse` parameter reference - let sse_expr = alloc(Expr::Path(vec![Name::new("sse")])); - - // 2. Client argument (same logic as synthesize_llm_builtin_call) - let client_arg = if client_name.contains('/') { - let name_lit = alloc(Expr::Literal(Literal::String(client_name.to_string()))); - let ct_path = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("ClientType"), - ])); - let ct_variant = alloc(Expr::MemberAccess { - base: ct_path, - member: Name::new("Primitive"), - }); - let sub = alloc(Expr::Array { elements: vec![] }); - let retry = alloc(Expr::Null); - let counter = alloc(Expr::Literal(Literal::Int(0))); - alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.Client"), - type_args: vec![], - fields: vec![ - (Name::new("name"), name_lit), - (Name::new("client_type"), ct_variant), - (Name::new("sub_clients"), sub), - (Name::new("retry"), retry), - (Name::new("counter"), counter), - ], - spreads: vec![], - }) - } else { - alloc(Expr::Path(vec![Name::new(client_name)])) - }; - - // 3. Callee: CLIENT.__make_stream (method call on the client) - let callee = alloc(Expr::MemberAccess { - base: client_arg, - member: Name::new("__make_stream"), - }); - - let call = alloc(Expr::Call { - callee, - type_args, - args: vec![CallArg::positional(sse_expr)], - }); - - let body = ExprBody { - exprs, - stmts: Arena::new(), - patterns: Arena::new(), - match_arms: Arena::new(), - catch_arms: Arena::new(), - type_annotations: Arena::new(), - root_expr: Some(call), - }; - - let source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; + diags: &mut Vec, +) -> Option { + use baml_compiler_syntax::SyntaxKind; + let value = value?; + if let rowan::NodeOrToken::Node(node) = &value { + if node.kind() == SyntaxKind::STRING_LITERAL { + let text = ast::StringLiteral::cast(node.clone()) + .map(|s| s.value()) + .unwrap_or_default(); + let Some((prefix, model)) = text.split_once('/') else { + diags.push(LoweringDiagnostic::InvalidLlmClient { + function_name: function_name.to_string(), + reason: format!("\"{text}\" is not a \"provider/model\" string (no `/`)"), + span, + }); + return None; + }; + let Some((pkg, class)) = spec_client_provider(&text) else { + diags.push(LoweringDiagnostic::InvalidLlmClient { + function_name: function_name.to_string(), + reason: format!( + "no builtin provider for prefix \"{prefix}\"; construct a client \ + value instead (OpenAI-compatible endpoints: \ + openai.OpenAiClient.new(base_url = ..., model = \"{model}\"))" + ), + span, + }); + return None; + }; + return Some(LlmClientSpec::Provider { + pkg, + class, + model: model.to_string(), + }); + } + // The removed unquoted shorthand (`client openai/gpt-4o-mini`) parses + // as a whitespace-free division chain; catch it before it cascades + // into unresolved-name errors. + if node.kind() == SyntaxKind::BINARY_EXPR { + let text = node.text().to_string(); + if text.contains('/') && !text.contains(char::is_whitespace) { + diags.push(LoweringDiagnostic::InvalidLlmClient { + function_name: function_name.to_string(), + reason: format!("quote the model string: client \"{text}\""), + span, + }); + return None; + } + } + } + Some(LlmClientSpec::Expr(value)) +} - (body, source_map) +/// The `tools` field's value element, if the function declares one. +fn tools_value_element( + llm: &ast::LlmFunctionBody, +) -> Option> { + llm.tools_field() + .as_ref() + .and_then(ast::ToolsField::value_element) } fn lower_raw_prompt(raw_string: &ast::RawStringLiteral) -> RawPrompt { @@ -2366,682 +2196,12 @@ fn lower_template_string( }) } -/// Synthesize an `Item::Let` for a `retry_policy` declaration. -/// -/// Produces: `baml.llm.RetryPolicy { max_retries: N, initial_delay_ms: N, multiplier: F, max_delay_ms: N }` -/// -/// Each config field is lowered generically via `lower_config_item::lower_config_value`, -/// then wrapped in a typed `Expr::Object`. -fn synthesize_retry_policy_let( - node: &SyntaxNode, - diags: &mut Vec, - env_var_refs: &mut Vec, -) -> Option { - let rp = ast::RetryPolicyDef::cast(node.clone())?; - let Some(name_token) = rp.name() else { - diags.push(LoweringDiagnostic::MissingItemName { - item_kind: "retry_policy", - span: node.span_range(), - }); - return None; - }; - let span = node.span_range(); - let rp_name = name_token.text().to_string(); - let Some(config_block) = rp.config_block() else { - diags.push(LoweringDiagnostic::MissingConfigBlock { - block_kind: "retry_policy", - block_name: rp_name, - span, - }); - return None; - }; - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut expr_spans: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - // Lower each config item generically - let fields: Vec<(Name, ExprId)> = config_block - .items() - .filter_map(|item| { - let Some(key) = item.key() else { - diags.push(LoweringDiagnostic::MissingConfigKey { - block_kind: "retry_policy", - block_name: rp_name.clone(), - span: item.syntax().span_range(), - }); - return None; - }; - let value = crate::lower_config_item::lower_config_value_with_env_refs( - &item, - &mut alloc, - env_var_refs, - crate::lower_config_item::EnvReadMode::Strict, - ); - Some((Name::new(key.text()), value)) - }) - .collect(); - - let root = alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.RetryPolicy"), - type_args: vec![], - fields, - spreads: vec![], - }); - - let body = ExprBody { - exprs, - stmts: la_arena::Arena::new(), - patterns: la_arena::Arena::new(), - match_arms: la_arena::Arena::new(), - catch_arms: la_arena::Arena::new(), - type_annotations: la_arena::Arena::new(), - root_expr: Some(root), - }; - let mut source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; - // The object constructor is an implementation detail of `retry_policy` - // config lowering, not a user-written class literal. TIR uses this marker - // to avoid applying ordinary object-literal field diagnostics to config - // keys such as the legacy `strategy` block. - source_map.synthetic_exprs.insert(root); - - Some(Item::Let(LetDef { - name: Name::new(name_token.text()), - initializer: Some((body, source_map)), - origin: LetOrigin::RetryPolicy, - span, - name_span: name_token.text_range(), - })) -} - -/// Synthesize `Item::Let` + optional `Item::Function` from a `CLIENT_DEF` CST node. -/// -/// - Every client produces an `Item::Let("ClientName", LetOrigin::Client)` whose initializer -/// constructs `Client { name, client_type, sub_clients: [], retry: null }`. -/// - Primitive clients also produce an `Item::Function("ClientName$new")` whose body -/// constructs `PrimitiveClient { name, provider, options }`. -fn synthesize_client_items( - node: &SyntaxNode, - diags: &mut Vec, - env_var_refs: &mut Vec, -) -> Option<(Item, Option)> { - let client = ast::ClientDef::cast(node.clone())?; - let Some(name_token) = client.name() else { - diags.push(LoweringDiagnostic::MissingItemName { - item_kind: "client", - span: node.span_range(), - }); - return None; - }; - let client_name = name_token.text().to_string(); - let span = node.span_range(); - let Some(config_block) = client.config_block() else { - diags.push(LoweringDiagnostic::MissingConfigBlock { - block_kind: "client", - block_name: client_name, - span, - }); - return None; - }; - - // Determine provider - let provider: Option = config_block.items().find_map(|item| { - let key = item.key()?; - if key.text() != "provider" { - return None; - } - item.value_word().map(|w| w.text().to_string()).or_else(|| { - item.config_value() - .and_then(|cv| cv.scalar_text()) - .map(|t| t.trim().trim_matches('"').to_string()) - }) - }); - - // Validate provider name. - if let Some(p) = &provider { - if !is_valid_provider(p.as_str()) { - diags.push(LoweringDiagnostic::UnknownProvider { - client_name: client_name.clone(), - provider: p.clone(), - span, - }); - } - } - - let is_fallback = provider.as_deref() == Some("fallback"); - let is_round_robin = provider.as_deref() == Some("round-robin"); - let is_composite = is_fallback || is_round_robin; - - // Build the Client let binding - let let_item = synthesize_client_let( - &client_name, - span, - &name_token, - is_fallback, - is_round_robin, - &config_block, - ); - - // Build the $new companion for primitive clients only - let companion = if !is_composite { - Some(synthesize_client_new_companion( - &client_name, - span, - &name_token, - &config_block, - provider.as_ref(), - diags, - env_var_refs, - )) - } else { - None - }; - - Some((let_item, companion)) -} - -/// Build the `Client` identity let binding. -/// -/// Produces: `baml.llm.Client { name, client_type, sub_clients, retry, counter }` -/// -/// - Composite clients (fallback/round-robin) get sub-client `Expr::Path` references -/// from `options { strategy [A, B] }`, enabling TIR name validation and -/// `topological_sort_lets` dependency ordering. -/// - All clients get `retry` wired as `Expr::Path("RetryPolicyName")` or `null`. -/// - Round-robin clients get `counter` from `options { start N }`, others get 0. -fn synthesize_client_let( - client_name: &str, - span: text_size::TextRange, - name_token: &rowan::SyntaxToken, - is_fallback: bool, - is_round_robin: bool, - config_block: &ast::ConfigBlock, -) -> Item { - use baml_base::Literal; - - let is_composite = is_fallback || is_round_robin; - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut expr_spans: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - // Extract retry_policy reference and composite sub-client/start from config block - let mut retry_policy_name: Option = None; - let mut sub_client_exprs: Vec = vec![]; - let mut round_robin_start: i64 = 0; - - for item in config_block.items() { - let Some(key) = item.key() else { continue }; - match key.text() { - "retry_policy" => { - // retry_policy MyRetry → Expr::Path(["MyRetry"]) - if let Some(word) = item.value_word() { - retry_policy_name = Some(word.text().to_string()); - } else if let Some(cv) = item.config_value() { - if let Some(text) = cv.scalar_text() { - let cleaned = text.trim().trim_matches('"'); - if !cleaned.is_empty() { - retry_policy_name = Some(cleaned.to_string()); - } - } - } - } - "options" if is_composite => { - if let Some(nested) = item.nested_block() { - for opt_item in nested.items() { - let Some(opt_key) = opt_item.key() else { - continue; - }; - match opt_key.text() { - "strategy" => { - if let Some(elements) = opt_item.array_string_elements() { - for (maybe_name, _range) in &elements { - if let Some(name) = maybe_name { - let expr = alloc(Expr::Path(vec![Name::new(name)])); - sub_client_exprs.push(expr); - } - } - } - } - "start" => { - if let Some(v) = opt_item.value_int() { - round_robin_start = v; - } - } - _ => {} - } - } - } - } - _ => {} - } - } - - // name: "MyClient" - let name_expr = alloc(Expr::Literal(Literal::String(client_name.to_string()))); - - // client_type: baml.llm.ClientType.Primitive (or Fallback / RoundRobin) - let variant_name = if is_fallback { - "Fallback" - } else if is_round_robin { - "RoundRobin" - } else { - "Primitive" - }; - let client_type_expr = alloc(Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("ClientType"), - Name::new(variant_name), - ])); - - // sub_clients: [A, B, ...] for composites, [] for primitive - let sub_clients_expr = alloc(Expr::Array { - elements: sub_client_exprs, - }); - - // retry: MyRetry (path reference) or null - let retry_expr = if let Some(rp_name) = retry_policy_name { - alloc(Expr::Path(vec![Name::new(&rp_name)])) - } else { - alloc(Expr::Null) - }; - - // counter: round_robin_start for RR clients, 0 otherwise - let counter_val = if is_round_robin { round_robin_start } else { 0 }; - let counter_expr = alloc(Expr::Literal(Literal::Int(counter_val))); - - // baml.llm.Client { name, client_type, sub_clients, retry, counter } - let root = alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.Client"), - type_args: vec![], - fields: vec![ - (Name::new("name"), name_expr), - (Name::new("client_type"), client_type_expr), - (Name::new("sub_clients"), sub_clients_expr), - (Name::new("retry"), retry_expr), - (Name::new("counter"), counter_expr), - ], - spreads: vec![], - }); - - let body = ExprBody { - exprs, - stmts: la_arena::Arena::new(), - patterns: la_arena::Arena::new(), - match_arms: la_arena::Arena::new(), - catch_arms: la_arena::Arena::new(), - type_annotations: la_arena::Arena::new(), - root_expr: Some(root), - }; - let source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; - - Item::Let(LetDef { - name: Name::new(client_name), - initializer: Some((body, source_map)), - origin: LetOrigin::Client, - span, - name_span: name_token.text_range(), - }) -} - -/// Build the `ClientName$new` companion function for primitive clients. -/// -/// Body constructs: -/// ```text -/// PrimitiveClient { -/// name: "X", provider: "openai", -/// options: PrimitiveClientOptions { -/// base_url, default_role, api_key, allowed_roles, remap_roles, -/// provider_options, headers, query_params, request_body -/// } -/// } -/// ``` -/// -/// Option keys are routed to match `PrimitiveClientOptions`: -/// - Known scalar fields (`base_url`, `default_role`, `api_key`, `allowed_roles`, `remap_roles`) -/// → named fields, default null -/// - `headers`, `query_params` → `Expr::Map`, default empty -/// - Provider-specific (`anthropic_version` → `AnthropicOptions`, -/// `resource_name`+`api_version` → `AzureOpenAiOptions`) → `provider_options` -/// - Unknown keys → `request_body` map entries -fn synthesize_client_new_companion( - client_name: &str, - span: text_size::TextRange, - name_token: &rowan::SyntaxToken, - config_block: &ast::ConfigBlock, - provider: Option<&String>, - diags: &mut Vec, - env_var_refs: &mut Vec, -) -> FunctionDef { - use baml_base::Literal; - - // The constructor's `lenient: bool` parameter. `env.X` option reads are - // lowered to `baml.env.get_or_panic_lenient("X", lenient)` so the offline - // `render_prompt` path (which calls the constructor with `lenient = true`) - // can build the client for its metadata without a credential env var set, - // while the network paths keep `lenient = false` and still panic if unset. - let lenient_param_name = Name::new("lenient"); - - let mut exprs: la_arena::Arena = la_arena::Arena::new(); - let mut expr_spans: la_arena::Arena = la_arena::Arena::new(); - let mut alloc = |expr: Expr| -> ExprId { - let id = exprs.alloc(expr); - expr_spans.alloc(span); - id - }; - - let config = provider - .map(String::as_str) - .filter(|p| is_valid_provider(p)) - .and_then(provider_config_for); - - // ── 1. Seed known fields as null/empty ───────────────────── - // - // All known fields go into one map, seeded as Null (or EmptyMap for - // map-typed fields). No provider-specific defaults are injected -- - // those are applied at runtime in sys_llm. - - let mut values: std::collections::HashMap = std::collections::HashMap::new(); - // Provider-specific field names (from the generated config). - let provider_field_set: std::collections::HashSet<&str> = config - .map(|c| c.fields.iter().copied().collect()) - .unwrap_or_default(); - - // Seed top-level fields as null / empty map. - // Skip provider_options and request_body -- they're assembled separately. - for &name in CLIENT_OPTION_FIELDS { - if SEPARATELY_ASSEMBLED_FIELDS.contains(&name) { - continue; - } - let expr = if EMPTY_MAP_FIELDS.contains(&name) { - alloc(Expr::Map { entries: vec![] }) - } else { - alloc(Expr::Null) - }; - values.insert(name.to_string(), expr); - } - - // Seed provider-specific fields as null (so they're recognized as known). - for &name in &provider_field_set { - values - .entry(name.to_string()) - .or_insert_with(|| alloc(Expr::Null)); - } - - // ── 2. Override from user config ──────────────────────────── - // - // Every option the user writes goes into the same map. Provider fields - // skip null (so writing `field null` preserves the default rather than - // overwriting it). Unknown fields go to request_body. - - let options_span = config_block - .items() - .find(|item| item.matches_key("options")) - .map(|item| item.syntax().span_range()) - .unwrap_or(span); - - let mut has_base_url = false; - let mut request_body_entries: Vec<(ExprId, ExprId)> = vec![]; - // Track which provider fields have been set to non-null values by the user. - // No compile-time defaults, so this starts empty. - let mut provider_fields_set: std::collections::HashSet = - std::collections::HashSet::new(); - - if let Some(options_item) = config_block - .items() - .find(|item| item.matches_key("options")) - { - if let Some(nested) = options_item.nested_block() { - for opt_item in nested.items() { - let Some(opt_key) = opt_item.key() else { - continue; - }; - let k = opt_key.text(); - let val = crate::lower_config_item::lower_config_value_with_env_refs( - &opt_item, - &mut alloc, - env_var_refs, - crate::lower_config_item::EnvReadMode::Lenient(&lenient_param_name), - ); - let is_null = opt_item.value_str().as_deref() == Some("null"); - - if values.contains_key(k) || provider_field_set.contains(k) { - // Known field. Provider fields skip null to preserve defaults. - if !provider_field_set.contains(k) || !is_null { - values.insert(k.to_string(), val); - } - if provider_field_set.contains(k) && !is_null { - provider_fields_set.insert(k.to_string()); - } - } else { - // Unknown field -> request_body (preserves source order). - let kx = alloc(Expr::Literal(Literal::String(k.to_string()))); - request_body_entries.push((kx, val)); - } - if k == "base_url" { - has_base_url = !is_null; - } - } - } - } - - // ── 3. Validate ───────────────────────────────────────────── - - if let Some(provider_str) = provider.map(String::as_str) { - report_client_options_validation( - provider_str, - client_name, - has_base_url, - &provider_fields_set, - options_span, - diags, - ); - } - - // ── 4. Assemble ───────────────────────────────────────────── - // - // Extract provider fields -> build typed sub-object. - // Then take() known top-level fields -> leftover = request_body. - - // Build provider options sub-object from provider-specific fields. - let null_expr = alloc(Expr::Null); - let provider_options = if let Some(type_name) = config.and_then(|c| c.options_type) { - let prov_fields: Vec<(Name, ExprId)> = config - .unwrap() - .fields - .iter() - .map(|&name| { - let val = values.remove(name).unwrap_or_else(|| alloc(Expr::Null)); - (Name::new(name), val) - }) - .collect(); - if !provider_fields_set.is_empty() { - alloc(Expr::Object { - type_name: TypePath::from_dotted(type_name), - type_args: vec![], - fields: prov_fields, - spreads: vec![], - }) - } else { - null_expr - } - } else { - null_expr - }; - - // Extract top-level fields in class-definition order. - // Skip provider_options and request_body -- they're inserted separately below. - let mut options_fields: Vec<(Name, ExprId)> = CLIENT_OPTION_FIELDS - .iter() - .filter(|name| !SEPARATELY_ASSEMBLED_FIELDS.contains(name)) - .map(|&name| { - let val = values.remove(name).unwrap_or_else(|| alloc(Expr::Null)); - (Name::new(name), val) - }) - .collect(); - - let request_body = alloc(Expr::Map { - entries: request_body_entries, - }); - - // Insert provider_options before "media_url_handler" to match PrimitiveClientOptions - // class-definition order, then append request_body at the end. - let insert_pos = options_fields - .iter() - .position(|(n, _)| n.as_str() == "media_url_handler") - .unwrap_or_else(|| { - options_fields - .iter() - .position(|(n, _)| n.as_str() == "headers") - .unwrap_or(options_fields.len()) - }); - options_fields.insert( - insert_pos, - (Name::new("provider_options"), provider_options), - ); - options_fields.push((Name::new("request_body"), request_body)); - - let options_expr = alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.PrimitiveClientOptions"), - type_args: vec![], - fields: options_fields, - spreads: vec![], - }); - - // PrimitiveClient { name, provider, options } - let name_lit = alloc(Expr::Literal(Literal::String(client_name.to_string()))); - let provider_lit = alloc(Expr::Literal(Literal::String( - provider.map_or("unknown", |s| s.as_str()).to_string(), - ))); - let root = alloc(Expr::Object { - type_name: TypePath::from_dotted("baml.llm.PrimitiveClient"), - type_args: vec![], - fields: vec![ - (Name::new("name"), name_lit), - (Name::new("provider"), provider_lit), - (Name::new("options"), options_expr), - ], - spreads: vec![], - }); - - let body = ExprBody { - exprs, - stmts: la_arena::Arena::new(), - patterns: la_arena::Arena::new(), - match_arms: la_arena::Arena::new(), - catch_arms: la_arena::Arena::new(), - type_annotations: la_arena::Arena::new(), - root_expr: Some(root), - }; - let source_map = AstSourceMap { - expr_spans, - ..Default::default() - }; - - let func_name = format!("{client_name}$new"); - let lenient_param = Param { - name: lenient_param_name, - type_expr: Some((TypeExprKind::Bool { attrs: vec![] }).at(span)), - default: None, - span, - name_span: name_token.text_range(), - }; - FunctionDef { - name: Name::new(&func_name), - generic_params: vec![], - params: vec![lenient_param], - defaults: FunctionDefaults::empty(), - return_type: None, - throws: None, - body: Some(FunctionBodyDef::Expr(body, source_map)), - declarative_meta: None, - metadata: crate::ast::FunctionMetadata::language_internal( - crate::ast::FunctionOrigin::Internal, - ), - attributes: vec![], - docstring: None, - is_tagged_template_tag: false, - span, - name_span: name_token.text_range(), - } -} - -// ── Generated field lists & provider config ──────────────────── -// -// Extracted from llm_types.baml by build.rs. To add a new field or provider, -// edit the BAML file (and its @providers annotations) -- the compiler picks it -// up automatically. - -#[allow(dead_code)] -mod generated_client_fields { - include!(concat!(env!("OUT_DIR"), "/client_fields_generated.rs")); -} -use generated_client_fields::{CLIENT_OPTION_FIELDS, PROVIDER_CONFIGS, ProviderFieldConfig}; - -/// Map fields that should be seeded as empty maps rather than null. -const EMPTY_MAP_FIELDS: &[&str] = &["headers", "query_params"]; - -/// Fields that are assembled separately (not seeded/extracted with normal top-level fields). -const SEPARATELY_ASSEMBLED_FIELDS: &[&str] = &["provider_options", "request_body"]; - -/// Look up the provider config for a given provider name. -fn provider_config_for(provider: &str) -> Option<&'static ProviderFieldConfig> { - PROVIDER_CONFIGS - .iter() - .find(|c| c.providers.contains(&provider)) -} - -/// Check whether a provider name is known. -fn is_valid_provider(provider: &str) -> bool { - PROVIDER_CONFIGS - .iter() - .any(|c| c.providers.contains(&provider)) -} - -/// Attach compile-time source context to shared client option validation errors. -fn report_client_options_validation( - provider: &str, - client_name: &str, - has_base_url: bool, - provider_fields_set: &std::collections::HashSet, - span: text_size::TextRange, - diags: &mut Vec, -) { - let options = ClientOptionsPresence { - provider, - base_url: has_base_url, - resource_name: provider_fields_set.contains("resource_name"), - deployment_id: provider_fields_set.contains("deployment_id"), - }; - if let Err(error) = baml_base::validate_client_options(options) { - diags.push(LoweringDiagnostic::MissingClientOptions { - client_name: client_name.to_string(), - error, - span, - }); - } - - // vertex-ai deliberately has no compile-time location requirement: - // `location` (like `project_id`) can come from the GOOGLE_CLOUD_LOCATION - // env var at request time, which the compiler cannot see. A client with - // neither base_url, location, nor the env var fails at $build_request - // with an actionable error (see sys_llm auth_request/vertex.rs). -} +// NOTE: the legacy `client` / `retry_policy` config-block synthesis +// (client identity lets, `$new` companions, provider option tables, +// retry-policy lets) lived here. Both blocks are removed language surface: +// clients are plain values (`client Name = ;`) and retry composes at +// the client boundary via `ai.Retry`. Their CST nodes now lower to a single +// migration diagnostic each. /// Lower variant-level attributes from an `EnumVariant` node. fn lower_variant_attributes(variant: &ast::EnumVariant) -> Vec { diff --git a/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs b/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs index c2ed75fb846..023d1f16834 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs @@ -42,7 +42,7 @@ pub struct EnvVarRef { /// `dog_t.implements(animal_t)` on the reflection `type` value. This must /// match exactly what `parse_path_or_ident` / `at_member_name` accept in the /// parser; adding a new keyword there requires adding it here too. -fn is_ident_token(kind: SyntaxKind) -> bool { +pub(crate) fn is_ident_token(kind: SyntaxKind) -> bool { matches!( kind, SyntaxKind::WORD @@ -253,21 +253,63 @@ impl InitTestContext { } } -/// BEP-049 §10 (M5f). Synthesize a NEW-MODE (backtick) LLM function body: -/// `baml.llm.(, "Fn", {params}, )`. -/// Identical to `lower_cst::synthesize_llm_builtin_call` except it appends the -/// synthesized prompt-tag closure as a 4th argument (the orchestrator invokes it -/// per attempt; legacy Jinja prompts pass 3 args and a `null` closure). Built in one -/// `LoweringContext` so the closure shares the call's arena; the closure's -/// `${…}` interps keep their real source spans, so interp diagnostics point at -/// the user's prompt. Lowering diagnostics / `env.X` refs from the prompt are -/// returned for the caller to merge. -pub(crate) fn synthesize_llm_call_with_prompt( - builtin_name: &str, +/// Lower a `client Name = ;` initializer into its own `ExprBody`. +/// The value may be a node or a bare identifier/literal token. +pub(crate) fn lower_client_initializer( + value: Option<&rowan::NodeOrToken>, + span: TextRange, + diags: &mut Vec, + env_var_refs: &mut Vec, +) -> (ExprBody, AstSourceMap) { + let mut ctx = LoweringContext::new(); + let root = match value { + Some(rowan::NodeOrToken::Node(node)) => ctx.lower_expr(node), + Some(rowan::NodeOrToken::Token(token)) => ctx + .try_lower_bare_token(token) + .unwrap_or_else(|| ctx.alloc_expr(Expr::Missing, span)), + None => ctx.alloc_expr(Expr::Missing, span), + }; + let (body, source_map, ctx_diags, ctx_env_refs) = ctx.finish(Some(root)); + diags.extend(ctx_diags); + env_var_refs.extend(ctx_env_refs); + (body, source_map) +} + +/// BEP `@spec`: synthesize the body of the `$spec` companion — an +/// `ai.FunctionSpec` literal binding the function's arguments: +/// +/// ```baml +/// ai.FunctionSpec { +/// spec_name: "Fn", +/// args: { "p": p, ... }, +/// prompt_template: (output_format: string) -> { +/// // the parameter's real name is ` __spec_output_format` (leading +/// // space) so it can never shadow a user identifier +/// let ctx = ai.internal.SpecCtx { output_format: output_format }; +/// let tagged = baml.TaggedString { ...the function's prompt... }; +/// ai.internal.assemble_prompt(tagged.parts, tagged.values) +/// }, +/// toolbox: ai.Toolbox.new([ai.tool(a), ...]), +/// default_client: openai.OpenAiClient.new(model = "gpt-4o-mini"), +/// } +/// ``` +/// +/// The prompt closure uses the same structural parts/values representation as +/// the built-in `prompt` tag. `${role(...)}` values become prompt messages +/// and media remains structural. `ctx` is bound to an `ai.internal.SpecCtx`, +/// so `${ctx.output_format}` resolves to the closure's parameter and every +/// other interpolation captures the enclosing function's parameters. Provider +/// construction is pure, so the eager default client never touches credentials. +/// +/// In the `tools` list, a bare function reference is wrapped in `ai.tool(...)`; +/// any other element expression must already produce an `ai.Tool`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn synthesize_llm_spec_body( function_name: &str, param_names: &[Name], - client_name: Option<&str>, - type_args: Vec, + client_spec: &crate::lower_cst::LlmClientSpec, + out_type: Option, + tools_value: Option<&rowan::NodeOrToken>, prompt_backtick: &baml_compiler_syntax::BacktickStringLiteral, span: TextRange, ) -> ( @@ -280,11 +322,13 @@ pub(crate) fn synthesize_llm_call_with_prompt( let mut ctx = LoweringContext::new(); - let fn_name_expr = ctx.alloc_expr( + // spec_name: "Fn" + let name_lit = ctx.alloc_expr( Expr::Literal(Literal::String(function_name.to_string())), span, ); + // args: { "p": p, ... } let entries: Vec<(ExprId, ExprId)> = param_names .iter() .map(|name| { @@ -298,68 +342,541 @@ pub(crate) fn synthesize_llm_call_with_prompt( .collect(); let args_map = ctx.alloc_expr(Expr::Map { entries }, span); - let callee = ctx.alloc_expr( - Expr::Path(vec![ + // prompt_template: ( __spec_output_format: string) -> ai.Prompt { ... } + // + // The lambda parameter carries a leading-space name so it can never + // shadow a user identifier: a function parameter named `output_format` + // must stay visible to `${output_format}` in the template (the parameter + // is only the render calling convention; `ctx.output_format` is the + // documented way to reach the rendered schema). Mirrors the `__tt_*` + // accumulator naming in `elaborate_tagged_body`. + // + // Binding references are span-position-checked against their `let`'s + // visibility window, and the template's `${ctx.…}` interps keep their real + // source ranges inside the backtick — so the synthesized `let ctx` must + // sit at an empty range at the backtick's *start*, before every reference + // (mirrors the accumulator lets in `elaborate_tagged_body`). + let of_param_name = Name::new(" __spec_output_format"); + let prompt_lambda_span = prompt_backtick.syntax().span_range(); + let prompt_start = TextRange::empty(prompt_lambda_span.start()); + let of_ref = ctx.alloc_expr(Expr::Path(vec![of_param_name.clone()]), prompt_start); + let spec_ctx_obj = ctx.alloc_expr( + Expr::Object { + type_name: baml_base::TypePath::from_dotted("ai.internal.SpecCtx"), + type_args: vec![], + fields: vec![(Name::new("output_format"), of_ref)], + spreads: vec![], + }, + prompt_start, + ); + let ctx_pat = ctx.alloc_pattern( + Pattern::Bind { + name: Name::new("ctx"), + subpat: None, + }, + prompt_start, + ); + let let_ctx = ctx.alloc_stmt( + Stmt::Let { + pattern: ctx_pat, + initializer: Some(spec_ctx_obj), + origin: LetOrigin::Source, + else_branch: None, + }, + prompt_start, + ); + // Flatten the template exactly like the public `prompt` tag: values stay + // structural until the Rust prompt assembler sees them. Rewrite the + // prompt-local role constructor before name resolution; it is the same + // binding that the public tag supplies to its body lambda. + let segments = ctx.lower_template_segments_checked(prompt_backtick); + let role_callees: Vec = ctx + .exprs + .iter() + .filter_map(|(_, expr)| match expr { + Expr::Call { callee, .. } + if matches!(&ctx.exprs[*callee], Expr::Path(path) if path.len() == 1 && path[0].as_str() == "role") => + { + Some(*callee) + } + _ => None, + }) + .collect(); + for callee in role_callees { + ctx.exprs[callee] = Expr::Path(vec![ Name::new("baml"), - Name::new("llm"), - Name::new(builtin_name), + Name::new("prompt"), + Name::new("make_role"), + ]); + } + + let prev_synth = std::mem::replace(&mut ctx.synthesizing, true); + let tagged_expr = ctx.elaborate_tagged_body(&segments, prompt_lambda_span); + ctx.synthesizing = prev_synth; + + // Evaluate the flattened template once before reading its two arrays. + let tagged_name = Name::new(" __spec_tagged_prompt"); + let tagged_pat = ctx.alloc_pattern( + Pattern::Bind { + name: tagged_name.clone(), + subpat: None, + }, + prompt_start, + ); + let let_tagged = ctx.alloc_stmt( + Stmt::Let { + pattern: tagged_pat, + initializer: Some(tagged_expr), + origin: LetOrigin::Source, + else_branch: None, + }, + prompt_start, + ); + let parts_base = ctx.alloc_expr(Expr::Path(vec![tagged_name.clone()]), prompt_start); + let parts = ctx.alloc_expr( + Expr::MemberAccess { + base: parts_base, + member: Name::new("parts"), + }, + prompt_start, + ); + let values_base = ctx.alloc_expr(Expr::Path(vec![tagged_name]), prompt_start); + let values = ctx.alloc_expr( + Expr::MemberAccess { + base: values_base, + member: Name::new("values"), + }, + prompt_start, + ); + let assemble_callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("ai"), + Name::new("internal"), + Name::new("assemble_prompt"), + ]), + prompt_start, + ); + let prompt_ast = ctx.alloc_expr( + Expr::Call { + callee: assemble_callee, + type_args: vec![], + args: vec![CallArg::positional(parts), CallArg::positional(values)], + }, + prompt_start, + ); + let lambda_body = ctx.alloc_expr( + Expr::Block { + stmts: vec![let_ctx, let_tagged], + tail_expr: Some(prompt_ast), + }, + prompt_lambda_span, + ); + let of_param = Param { + name: of_param_name, + type_expr: Some((TypeExprKind::String { attrs: vec![] }).at(span)), + default: None, + span, + name_span: span, + }; + // The prompt lambda carries the backtick's own range: lambda scopes are + // located by exact span within their owner (semantic index + // `lambda_scope_for_within`), so the two lambdas synthesized into this + // body must not share a range — the default-client thunk keeps the block + // span. + let prompt_lambda = ctx.alloc_expr( + Expr::Lambda(Box::new(LambdaDef { + kind: LambdaKind::Anonymous, + params: vec![of_param], + defaults: FunctionDefaults::empty(), + return_type: None, + throws: None, + body: Some(lambda_body), + span: prompt_lambda_span, + })), + prompt_lambda_span, + ); + // toolbox: ai.Toolbox.new([...]) + let tool_list = match tools_value { + Some(rowan::NodeOrToken::Node(node)) if node.kind() == SyntaxKind::ARRAY_LITERAL => { + let mut elements = Vec::new(); + for elem in node.children_with_tokens() { + let lowered = match &elem { + rowan::NodeOrToken::Node(child) => { + Some((child.kind() == SyntaxKind::PATH_EXPR, ctx.lower_expr(child))) + } + rowan::NodeOrToken::Token(token) => { + let is_bare_ref = is_ident_token(token.kind()); + ctx.try_lower_bare_token(token).map(|id| (is_bare_ref, id)) + } + }; + if let Some((is_bare_ref, id)) = lowered { + let id = if is_bare_ref { + // A bare function reference: normalize through ai.tool(). + let tool_callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("ai"), + Name::new("tools"), + Name::new("tool"), + ]), + span, + ); + ctx.alloc_expr( + Expr::Call { + callee: tool_callee, + type_args: vec![], + args: vec![CallArg::positional(id)], + }, + span, + ) + } else { + id + }; + elements.push(id); + } + } + ctx.alloc_expr(Expr::Array { elements }, span) + } + // A non-list expression must already produce an `ai.Tool[]`. + Some(rowan::NodeOrToken::Node(node)) => ctx.lower_expr(node), + // A bare dot-free identifier is a token, not a node: a variable + // holding the `ai.Tool[]` value. + Some(rowan::NodeOrToken::Token(token)) => ctx + .try_lower_bare_token(token) + .unwrap_or_else(|| ctx.alloc_expr(Expr::Missing, span)), + None => ctx.alloc_expr(Expr::Array { elements: vec![] }, span), + }; + let toolbox_callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("ai"), + Name::new("tools"), + Name::new("Toolbox"), + Name::new("new"), ]), span, ); + let toolbox = ctx.alloc_expr( + Expr::Call { + callee: toolbox_callee, + type_args: vec![], + args: vec![CallArg::positional(tool_list)], + }, + span, + ); - let client_arg = match client_name { - Some(name) if name.contains('/') => { - let name_lit = ctx.alloc_expr(Expr::Literal(Literal::String(name.to_string())), span); - let ct_variant = ctx.alloc_expr( - Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("ClientType"), - Name::new("Primitive"), - ]), + // default_client — an eager value; provider construction is pure + // (credentials resolve from the environment at request time), so + // building the spec never touches env. Either the compile-time-mapped + // provider constructor for a "provider/model" string, or the user's own + // client expression lowered in place. + let default_client = match client_spec { + crate::lower_cst::LlmClientSpec::Provider { pkg, class, model } => { + let model_lit = ctx.alloc_expr(Expr::Literal(Literal::String(model.clone())), span); + let ctor_callee = ctx.alloc_expr( + Expr::Path(vec![Name::new(*pkg), Name::new(*class), Name::new("new")]), span, ); - let sub = ctx.alloc_expr(Expr::Array { elements: vec![] }, span); - let retry = ctx.alloc_expr(Expr::Null, span); - let counter = ctx.alloc_expr(Expr::Literal(Literal::Int(0)), span); ctx.alloc_expr( - Expr::Object { - type_name: baml_base::TypePath::from_dotted("baml.llm.Client"), + Expr::Call { + callee: ctor_callee, type_args: vec![], - fields: vec![ - (Name::new("name"), name_lit), - (Name::new("client_type"), ct_variant), - (Name::new("sub_clients"), sub), - (Name::new("retry"), retry), - (Name::new("counter"), counter), - ], - spreads: vec![], + args: vec![CallArg::named("model", model_lit)], }, span, ) } - Some(name) => ctx.alloc_expr(Expr::Path(vec![Name::new(name)]), span), - None => ctx.alloc_expr(Expr::Null, span), + crate::lower_cst::LlmClientSpec::Expr(rowan::NodeOrToken::Node(node)) => { + ctx.lower_expr(node) + } + crate::lower_cst::LlmClientSpec::Expr(rowan::NodeOrToken::Token(token)) => ctx + .try_lower_bare_token(token) + .unwrap_or_else(|| ctx.alloc_expr(Expr::Missing, span)), }; - let prompt_closure = ctx.build_prompt_tag_closure(prompt_backtick, span); + let type_args = out_type.map(|t| vec![t]).unwrap_or_default(); + let spec_obj = ctx.alloc_expr( + Expr::Object { + type_name: baml_base::TypePath::from_dotted("ai.FunctionSpec"), + type_args, + fields: vec![ + (Name::new("spec_name"), name_lit), + (Name::new("args"), args_map), + (Name::new("prompt_template"), prompt_lambda), + (Name::new("toolbox"), toolbox), + (Name::new("default_client"), default_client), + ], + spreads: vec![], + }, + span, + ); + + ctx.finish(Some(spec_obj)) +} + +/// Synthesize the `$render_prompt` companion body: render the spec's prompt +/// with the return type's output-format text — +/// `Fn$spec(p...).prompt(ai.wire.render_output_format(reflect.type_of()))`. +pub(crate) fn synthesize_spec_render_prompt_body( + function_name: &str, + param_names: &[Name], + out_type: Option, + span: TextRange, +) -> (ExprBody, AstSourceMap) { + use crate::ast::CallArg; + + let mut ctx = LoweringContext::new(); + + let spec_callee = ctx.alloc_expr( + Expr::Path(vec![Name::new(format!("{function_name}$spec"))]), + span, + ); + let spec_args: Vec = param_names + .iter() + .map(|n| CallArg::positional(ctx.alloc_expr(Expr::Path(vec![n.clone()]), span))) + .collect(); + let spec_call = ctx.alloc_expr( + Expr::Call { + callee: spec_callee, + type_args: vec![], + args: spec_args, + }, + span, + ); + let prompt_callee = ctx.alloc_expr( + Expr::MemberAccess { + base: spec_call, + member: Name::new("prompt"), + }, + span, + ); + let type_of_callee = ctx.alloc_expr( + Expr::Path(vec![Name::new("reflect"), Name::new("type_of")]), + span, + ); + let type_of_call = ctx.alloc_expr( + Expr::Call { + callee: type_of_callee, + type_args: out_type.map(|t| vec![t]).unwrap_or_default(), + args: vec![], + }, + span, + ); + let rof_callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("ai"), + Name::new("wire"), + Name::new("render_output_format"), + ]), + span, + ); + let rof_call = ctx.alloc_expr( + Expr::Call { + callee: rof_callee, + type_args: vec![], + args: vec![CallArg::positional(type_of_call)], + }, + span, + ); + let render_call = ctx.alloc_expr( + Expr::Call { + callee: prompt_callee, + type_args: vec![], + args: vec![CallArg::named("output_format", rof_call)], + }, + span, + ); + + let (body, source_map, _diags, _env_refs) = ctx.finish(Some(render_call)); + (body, source_map) +} + +/// Synthesize the `$parse` companion body: a network-free parse of an +/// existing JSON/SAP string into the function's return type — +/// `baml.sap.parse(json)`. +pub(crate) fn synthesize_spec_parse_body( + out_type: Option, + span: TextRange, +) -> (ExprBody, AstSourceMap) { + use crate::ast::CallArg; + let mut ctx = LoweringContext::new(); + let json_ref = ctx.alloc_expr(Expr::Path(vec![Name::new("json")]), span); + let callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("baml"), + Name::new("sap"), + Name::new("parse"), + ]), + span, + ); let call = ctx.alloc_expr( Expr::Call { callee, + type_args: out_type.map(|t| vec![t]).unwrap_or_default(), + args: vec![CallArg::positional(json_ref)], + }, + span, + ); + let (body, source_map, _diags, _env_refs) = ctx.finish(Some(call)); + (body, source_map) +} + +/// BEP `@spec` spec mode: synthesize the direct-call body of a `tools`-bearing +/// LLM function — run the default ai runner over the function's own spec and +/// unwrap the value: +/// +/// ```baml +/// ai.Agent.new(client = client).run(Fn$spec(p1, p2)).value +/// ``` +/// +/// `client` is the compiler-injected `ai.Client? = null` override parameter; +/// `Agent.run` falls back to the spec's default client when it is null. +pub(crate) fn synthesize_spec_agent_run_body( + function_name: &str, + param_names: &[Name], + out_type: Option, + span: TextRange, +) -> (ExprBody, AstSourceMap) { + use crate::ast::CallArg; + + let mut ctx = LoweringContext::new(); + + // Fn$spec(p1, p2, ...) + let spec_callee = ctx.alloc_expr( + Expr::Path(vec![Name::new(format!("{function_name}$spec"))]), + span, + ); + let spec_args: Vec = param_names + .iter() + .map(|n| { + let id = ctx.alloc_expr(Expr::Path(vec![n.clone()]), span); + CallArg::positional(id) + }) + .collect(); + let spec_call = ctx.alloc_expr( + Expr::Call { + callee: spec_callee, + type_args: vec![], + args: spec_args, + }, + span, + ); + + // ai.Agent.new(client = client) + let agent_path = ctx.alloc_expr(Expr::Path(vec![Name::new("ai"), Name::new("Agent")]), span); + let new_callee = ctx.alloc_expr( + Expr::MemberAccess { + base: agent_path, + member: Name::new("new"), + }, + span, + ); + let client_ref = ctx.alloc_expr(Expr::Path(vec![Name::new("client")]), span); + let type_args = out_type.map(|t| vec![t]).unwrap_or_default(); + let new_call = ctx.alloc_expr( + Expr::Call { + callee: new_callee, + type_args, + args: vec![CallArg::named("client", client_ref)], + }, + span, + ); + + // .run(spec).value + let run_callee = ctx.alloc_expr( + Expr::MemberAccess { + base: new_call, + member: Name::new("run"), + }, + span, + ); + let run_call = ctx.alloc_expr( + Expr::Call { + callee: run_callee, + type_args: vec![], + args: vec![CallArg::positional(spec_call)], + }, + span, + ); + let value = ctx.alloc_expr( + Expr::MemberAccess { + base: run_call, + member: Name::new("value"), + }, + span, + ); + + let (body, source_map, _diags, _env_refs) = ctx.finish(Some(value)); + (body, source_map) +} + +/// Synthesize the `$stream` companion body (built at PPIR level, where the +/// stream-expanded return type is known) — one-turn streaming over the +/// function's own spec: +/// +/// ```baml +/// ai.stream.from_spec(Fn$spec(p1, p2), client = client) +/// ``` +/// +/// `type_args` is the explicit `` pair, so the +/// stdlib reifies both types from its own frame via `reflect.type_of`. +/// `client` is the companion's injected `ai.StreamingClient? = null` +/// override; `from_spec` falls back to the spec's default client when it +/// is null. +pub fn synthesize_spec_stream_body( + function_name: &str, + param_names: &[Name], + type_args: Vec, + span: TextRange, +) -> (ExprBody, AstSourceMap) { + use crate::ast::CallArg; + + let mut ctx = LoweringContext::new(); + + // Fn$spec(p1, p2, ...) + let spec_callee = ctx.alloc_expr( + Expr::Path(vec![Name::new(format!("{function_name}$spec"))]), + span, + ); + let spec_args: Vec = param_names + .iter() + .map(|n| { + let id = ctx.alloc_expr(Expr::Path(vec![n.clone()]), span); + CallArg::positional(id) + }) + .collect(); + let spec_call = ctx.alloc_expr( + Expr::Call { + callee: spec_callee, + type_args: vec![], + args: spec_args, + }, + span, + ); + + // ai.stream.from_spec(spec, client = client) + let stream_spec_callee = ctx.alloc_expr( + Expr::Path(vec![ + Name::new("ai"), + Name::new("stream"), + Name::new("from_spec"), + ]), + span, + ); + let client_ref = ctx.alloc_expr(Expr::Path(vec![Name::new("client")]), span); + let call = ctx.alloc_expr( + Expr::Call { + callee: stream_spec_callee, type_args, args: vec![ - CallArg::positional(client_arg), - CallArg::positional(fn_name_expr), - CallArg::positional(args_map), - // `prompt_closure` is a defaulted param → must be passed by name. - CallArg::named("prompt_closure", prompt_closure), + CallArg::positional(spec_call), + CallArg::named("client", client_ref), ], }, span, ); - ctx.finish(Some(call)) + let (body, source_map, _diags, _env_refs) = ctx.finish(Some(call)); + (body, source_map) } struct LoweringContext { @@ -816,6 +1333,7 @@ impl LoweringContext { SyntaxKind::PATH_EXPR => self.lower_path_expr(node), SyntaxKind::FIELD_ACCESS_EXPR => self.lower_field_access_expr(node), SyntaxKind::UPCAST_EXPR => self.lower_upcast_expr(node), + SyntaxKind::SPEC_EXPR => self.lower_spec_expr(node), SyntaxKind::OPTIONAL_FIELD_ACCESS_EXPR => self.lower_optional_field_access_expr(node), SyntaxKind::ENV_ACCESS_EXPR => self.lower_env_access_expr(node), SyntaxKind::INDEX_EXPR => self.lower_index_expr(node), @@ -2699,6 +3217,48 @@ impl LoweringContext { self.wrap_generic_apply(node, id, node.span_range()) } + /// Lower `MyFunc@spec` (BEP `@spec` postfix) by renaming the base path's + /// last segment to the `$spec` companion function — resolution then + /// proceeds exactly as if the companion had been named directly. The base + /// must be a plain path (an LLM function reference); anything else lowers + /// to `Missing` with a diagnostic-friendly span. + fn lower_spec_expr(&mut self, node: &SyntaxNode) -> ExprId { + let span = node.span_range(); + // The base is either a PATH_EXPR child or a bare WORD token (single + // identifiers are tokens, not nodes, in postfix wrappers). + let mut segments: Vec = Vec::new(); + for elem in node.children_with_tokens() { + match elem { + rowan::NodeOrToken::Node(child) if child.kind() == SyntaxKind::PATH_EXPR => { + for t in child + .children_with_tokens() + .filter_map(rowan::NodeOrToken::into_token) + { + if is_ident_token(t.kind()) { + segments.push(Name::new(t.text())); + } + } + } + // Everything before the `@` is the base; the trailing + // `spec` word after it is the operator, not a segment. + rowan::NodeOrToken::Token(t) if t.kind() == SyntaxKind::AT => break, + rowan::NodeOrToken::Token(t) if is_ident_token(t.kind()) => { + segments.push(Name::new(t.text())); + } + _ => {} + } + } + let Some(last) = segments.pop() else { + self.diags.push(LoweringDiagnostic::UnparseableType { + context: "`@spec` target (expected an LLM function reference)".to_string(), + span, + }); + return self.alloc_expr(Expr::Missing, span); + }; + segments.push(Name::new(format!("{}$spec", last.as_str()))); + self.alloc_expr(Expr::Path(segments), span) + } + fn lower_field_access_expr(&mut self, node: &SyntaxNode) -> ExprId { let mut base = None; let mut field = None; @@ -3451,7 +4011,7 @@ impl LoweringContext { .unwrap_or_else(|| self.alloc_expr(Expr::Missing, span)); // BEP-049 ergonomic hack: a bare `` prompt`...` `` tag resolves to the - // stdlib `baml.llm.prompt`. `prompt` lives in the `baml.llm` namespace + // stdlib `ai.prompt`. BAML has no prelude, // and BAML has no prelude, so the unqualified form (which the BEP §10 // examples use) would otherwise be an unresolved name. Rewriting the bare // path here — same `ExprId`, so the source span is preserved — means every @@ -3460,11 +4020,7 @@ impl LoweringContext { // caller who needs a different `prompt` tag can write it qualified. if matches!(&self.exprs[tag], Expr::Path(segs) if segs.len() == 1 && segs[0].as_str() == "prompt") { - self.exprs[tag] = Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("prompt"), - ]); + self.exprs[tag] = Expr::Path(vec![Name::new("ai"), Name::new("prompt")]); } let segments = backtick_node @@ -3491,41 +4047,6 @@ impl LoweringContext { ) } - /// BEP-049 §10 (M5f). Build a `baml.llm.prompt`-tagged closure over the - /// segments for a new-mode LLM prompt — a *bare* backtick literal (no written tag), so the - /// `baml.llm.prompt` tag is synthesized here. Mirrors `lower_tagged_template_expr` - /// but with the synthetic tag; the segment interps keep their real source - /// spans (so `${…}` diagnostics point at the user's prompt). The result is a - /// `(Context) -> PromptAst`-producing expression the orchestrator invokes. - pub(crate) fn build_prompt_tag_closure( - &mut self, - backtick: &baml_compiler_syntax::BacktickStringLiteral, - span: TextRange, - ) -> ExprId { - let tag = self.alloc_expr( - Expr::Path(vec![ - Name::new("baml"), - Name::new("llm"), - Name::new("prompt"), - ]), - span, - ); - let segments = self.lower_template_segments_checked(backtick); - // The desugared closure body is entirely compiler-generated — mark its - // nodes synthetic, mirroring the untagged path. The segments were lowered - // above as real user code and keep their non-synthetic ids. - let prev_synth = std::mem::replace(&mut self.synthesizing, true); - let body = self.elaborate_tagged_body(&segments, span); - self.synthesizing = prev_synth; - self.alloc_expr( - Expr::Template { - tag: TemplateTag::Custom { tag, body }, - segments, - }, - span, - ) - } - /// Build the desugared body of a tagged template — the closure the tag is /// invoked with. Produces a block that flattens the segments into /// `baml.TaggedString { parts, values }` (BEP §10): text runs accumulate diff --git a/baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs b/baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs index 89e7cd7a5cc..0eefb702d16 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lowering_diagnostic.rs @@ -175,6 +175,47 @@ pub enum LoweringDiagnostic { /// generic function may be specialized into a value (`foo`). TypeArgsOnNonPathBase { span: TextRange }, + /// An LLM function's `tools` field cannot switch the function to the + /// ai-package spec desugar: the function is missing a backtick prompt or a + /// `"provider/model"` client string with a known provider prefix. + InvalidLlmToolsField { + function_name: String, + reason: &'static str, + span: TextRange, + }, + + /// A legacy `client Name { ... }` config block. Removed: clients are + /// plain values now — `client Name = ;`. + ClientBlockRemoved { name: String, span: TextRange }, + + /// A legacy `retry_policy` block. Removed: retry composes at the client + /// boundary via `ai.Retry.new(inner, ...)`. + RetryPolicyRemoved { name: String, span: TextRange }, + + /// A legacy Jinja `#"..."#` prompt on an LLM function. Removed: prompts + /// are backtick templates. + LlmJinjaPromptRemoved { + function_name: String, + span: TextRange, + }, + + /// The LLM function's `client` value cannot be used: unknown provider + /// prefix, a string without a `provider/model` shape, or the removed + /// unquoted shorthand. + InvalidLlmClient { + function_name: String, + reason: String, + span: TextRange, + }, + + /// A `${role(...)}` marker in an LLM prompt. Removed: the prompt is + /// instructions-only; the conversation lives in the journal and roles are + /// the client's wire concern. + LlmRoleMarkerRemoved { + function_name: String, + span: TextRange, + }, + /// A numeric literal token failed validation (`baml_base::num_lit`): /// uppercase base prefix, no digits after the prefix, a digit invalid /// for the base, or an integer magnitude exceeding `i64::MAX`. For @@ -504,6 +545,77 @@ impl LoweringDiagnostic { *span, "specialize a generic function directly, e.g. `foo`", ), + LoweringDiagnostic::InvalidLlmToolsField { + function_name, + reason, + span, + } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!("LLM function `{function_name}` cannot use a `tools` field: {reason}"), + *span, + "`tools` requires a backtick prompt and a \"provider/model\" client string", + ), + LoweringDiagnostic::ClientBlockRemoved { name, span } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!( + "`client` config blocks are removed; declare a client value instead: \ + `client {name} = openai.OpenAiClient.new(model = \"...\");` \ + (compose reliability with ai.Retry / ai.Fallback / ai.RoundRobin)" + ), + *span, + "replace with `client Name = ;`", + ), + LoweringDiagnostic::RetryPolicyRemoved { name, span } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!( + "`retry_policy` blocks are removed; wrap the client instead: \ + `client Reliable = ai.Retry.new(, max_attempts = ...);` \ + (`{name}` has no effect)" + ), + *span, + "retry composes at the client boundary now", + ), + LoweringDiagnostic::LlmJinjaPromptRemoved { + function_name, + span, + } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!( + "LLM function `{function_name}` uses a Jinja `#\"...\"#` prompt, which is \ + removed; write a backtick template: prompt `... ${{ctx.output_format}}`" + ), + *span, + "migrate to a backtick prompt", + ), + LoweringDiagnostic::InvalidLlmClient { + function_name, + reason, + span, + } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!("LLM function `{function_name}` has an unusable `client`: {reason}"), + *span, + "use a \"provider/model\" string or an expression evaluating to ai.Client", + ), + LoweringDiagnostic::LlmRoleMarkerRemoved { + function_name, + span, + } => ( + DiagnosticId::InvalidSyntax, + Severity::Error, + format!( + "`${{role(...)}}` markers are removed; the prompt of `{function_name}` is \ + instructions-only — the conversation lives in the journal, and message \ + roles are the client's wire concern" + ), + *span, + "delete the role marker; write plain instructions", + ), LoweringDiagnostic::InvalidNumericLiteral { error, span } => { use baml_base::num_lit::IntLitError; ( diff --git a/baml_language/crates/baml_compiler2_emit/src/lib.rs b/baml_language/crates/baml_compiler2_emit/src/lib.rs index dbb804ae7c3..25be35a95d6 100644 --- a/baml_language/crates/baml_compiler2_emit/src/lib.rs +++ b/baml_language/crates/baml_compiler2_emit/src/lib.rs @@ -1052,7 +1052,20 @@ pub(crate) use emit::compile_mir_function; fn is_builtin_function_name(name: &str) -> bool { matches!( name.split('.').next(), - Some("baml" | "boundary" | "reflect" | "assert" | "testing" | "log" | "env") + Some( + "baml" + | "boundary" + | "reflect" + | "assert" + | "testing" + | "log" + | "env" + | "ai" + | "openai" + | "anthropic" + | "google" + | "claude_code" + ) ) } diff --git a/baml_language/crates/baml_compiler2_hir/src/package.rs b/baml_language/crates/baml_compiler2_hir/src/package.rs index 2b3a6967aa2..98607d2959f 100644 --- a/baml_language/crates/baml_compiler2_hir/src/package.rs +++ b/baml_language/crates/baml_compiler2_hir/src/package.rs @@ -390,13 +390,26 @@ pub fn package_dependencies<'db>( // a primitive string. "boundary" => vec![], // "baml" depends on "log" and "reflect" so stdlib code can call - // log.info/debug/etc. and reflect.type_of() inside ns_llm. + // log.info/debug/etc. and reflect.type_of() inside builtin BAML namespaces. "baml" => vec![ PackageId::new(db, Name::new("log")), PackageId::new(db, Name::new("reflect")), ], // The "testing" and "assert" packages depend on "baml" only. "testing" | "assert" => vec![PackageId::new(db, Name::new("baml"))], + // The "ai" package uses baml primitives and reflection. + "ai" => vec![ + PackageId::new(db, Name::new("baml")), + PackageId::new(db, Name::new("reflect")), + ], + // Provider client packages implement the `ai.Client` interface; the + // claude_code harness client also logs its own event stream. + "openai" | "anthropic" | "google" | "claude_code" => vec![ + PackageId::new(db, Name::new("baml")), + PackageId::new(db, Name::new("reflect")), + PackageId::new(db, Name::new("log")), + PackageId::new(db, Name::new("ai")), + ], // User packages depend on public builtin packages. _ => vec![ PackageId::new(db, Name::new("baml")), @@ -405,6 +418,11 @@ pub fn package_dependencies<'db>( PackageId::new(db, Name::new("assert")), PackageId::new(db, Name::new("log")), PackageId::new(db, Name::new("reflect")), + PackageId::new(db, Name::new("ai")), + PackageId::new(db, Name::new("openai")), + PackageId::new(db, Name::new("anthropic")), + PackageId::new(db, Name::new("google")), + PackageId::new(db, Name::new("claude_code")), ], } } diff --git a/baml_language/crates/baml_compiler2_mir/src/lower.rs b/baml_language/crates/baml_compiler2_mir/src/lower.rs index bdee7348e10..a17d46814b6 100644 --- a/baml_language/crates/baml_compiler2_mir/src/lower.rs +++ b/baml_language/crates/baml_compiler2_mir/src/lower.rs @@ -1220,9 +1220,12 @@ fn package_lowering_data<'db>( db: &'db dyn crate::Db, pkg_id: baml_compiler2_hir::package::PackageId<'db>, ) -> PackageLoweringData { - use baml_compiler2_hir::package::{ - package_dependencies, package_dependency_closure, package_items, - }; + use baml_compiler2_hir::package::{package_dependencies, package_dependency_closure}; + // The canonical (PPIR) item view: includes synthesized `*$stream` classes, + // whose fields must be projectable like any other class's. TIR already + // resolves types against this view; using HIR's pre-expansion view here + // made MIR ICE on field access against a `$stream` partial. + use baml_compiler2_ppir::package_items; let resolved_aliases = resolved_aliases_for_package(db, pkg_id); @@ -4012,18 +4015,19 @@ impl<'db> LoweringContext<'db> { let lambda_scope_id: FileScopeId = if let Some(ref sm) = self.source_map { let lambda_span = sm.expr_span(expr_id); let index = file_semantic_index(self.db, self.file); - // Find the Lambda scope containing this span by searching for it. - // We look for a Lambda-kind scope whose range matches the lambda span. - let mut found = None; - for (i, scope) in index.scopes.iter().enumerate() { - if scope.kind == baml_compiler2_hir::scope::ScopeKind::Lambda - && scope.range == lambda_span - { - found = Some(FileScopeId::new(i as u32)); - break; - } - } - found.unwrap_or(self.current_scope) + // Two functions can carry a lambda at the *same* source span — an + // LLM function and its synthesized companions all share the parent's + // ranges (e.g. the `$spec` companion's prompt closure vs the + // parent's prompt-tag closure). A bare range match would pick + // whichever lambda scope appears first in the file, binding this + // lambda to the *other* function's captures. Disambiguate by + // preferring the lambda scope nested within the function currently + // being lowered; fall back to the first range match (mirrors + // `build_tagged_body_closure`). + index + .lambda_scope_for_within(self.current_scope, lambda_span) + .or_else(|| index.lambda_scope_for(lambda_span)) + .unwrap_or(self.current_scope) } else { self.current_scope }; @@ -4459,7 +4463,7 @@ impl LoweringContext<'_> { .map(|sm| sm.expr_span(tag).start()) .unwrap_or_default(); // Prefer the resolution TIR recorded for the tag expression. A qualified - // tag like `baml.llm.prompt` is a multi-segment path whose `func_loc` + // tag like `ai.prompt` is a multi-segment path whose `func_loc` // lives in `resolutions` (`infer_multi_segment_path`); resolving only // the bare last segment (`prompt`) in the user's scope would miss it. // Fall back to bare-name resolution for unqualified, in-file tags. @@ -5555,7 +5559,7 @@ impl LoweringContext<'_> { #[allow(clippy::elidable_lifetime_names)] impl<'db> LoweringContext<'db> { fn lower_path_expr(&mut self, expr_id: AstExprId, segments: &[Name], dest: Place) { - // Multi-segment paths (e.g. baml.llm.render_prompt, self.field, obj.method) — check TIR resolution first + // Multi-segment paths (e.g. baml.http.fetch, self.field, obj.method) — check TIR resolution first if segments.len() > 1 { // Check path_member_resolutions first (set by infer_local_rooted_path for local-rooted paths). // This takes priority over the flat resolutions map since infer_local_rooted_path @@ -7970,7 +7974,7 @@ impl<'db> LoweringContext<'db> { .unwrap_or(false), }; // Check if the resolved method expects a `self` receiver. - // Static methods (e.g. StreamCache.new) have no `self` param + // Static methods (e.g. ParseCache.new) have no `self` param // and must not get the class reference prepended as an argument. let method_takes_self = { use baml_compiler2_tir::inference::MemberResolution; @@ -8852,7 +8856,7 @@ impl LoweringContext<'_> { // args synthesized by PPIR companions reference `*$stream` classes // (e.g. `parse`), which only exist in // the PPIR-expanded item universe. Resolving against HIR's original - // items lowered them to `Unknown` → `Void` and broke `StreamCache.new` + // items lowered them to `Unknown` → `Void` and broke `ParseCache.new` // at runtime. let pkg_items = baml_compiler2_ppir::package_items(self.db, pkg_id); let mut diags = Vec::new(); @@ -9416,7 +9420,7 @@ impl<'db> LoweringContext<'db> { // prefix — so the source-verbatim form would miss the lookup. Falling // back to the parser name only when TIR has no type info handles // synthetic Object exprs from `lower_cst.rs` that already use registry- - // matching dotted forms like "baml.llm.Client". + // matching dotted forms like "ai.Prompt". let class_name = if let Some(tn) = &type_name_key { tn.render_dotted(false) } else { diff --git a/baml_language/crates/baml_compiler2_ppir/src/lib.rs b/baml_language/crates/baml_compiler2_ppir/src/lib.rs index ed40c593ca7..19db9923af8 100644 --- a/baml_language/crates/baml_compiler2_ppir/src/lib.rs +++ b/baml_language/crates/baml_compiler2_ppir/src/lib.rs @@ -367,16 +367,30 @@ pub fn ppir_expansion_items(db: &dyn Db, file: SourceFile) -> PpirExpansionItems synthetic_items.push(ast::Item::TypeAlias(stream_alias)); } + // LLM `$stream` companions, single-path StreamingClient design: + // `Fn$stream(args, client)` = one-turn streaming over the + // function's own spec, returning the typed partial stream + // `ai.stream.Stream`. Synthesized here (not with the + // AST-level companions) because the body's explicit type args + // need the stream-expanded return type, which only PPIR can + // compute. Tools-bearing functions get no `$stream`: streaming + // does not run the tool loop yet (`LlmBodyDef::has_tools` is the + // conservative compile-time signal; `ai.from_spec` re-checks + // the toolbox at runtime for the dynamic cases). ast::Item::Function(func) => { - // Only LLM functions get $parse_stream companions. - if !matches!(&func.declarative_meta, Some(ast::DeclarativeMeta::Llm(_))) { + let Some(ast::DeclarativeMeta::Llm(llm)) = &func.declarative_meta else { + continue; + }; + if !llm.companion_bodies.iter().any(|(t, _)| t == "spec") { + continue; + } + if llm.has_tools { continue; } // Skip companions (contain $) to avoid recursive generation. if func.name.contains('$') { continue; } - // Skip functions without return types. let Some(ref return_type_spanned) = func.return_type else { continue; }; @@ -394,169 +408,89 @@ pub fn ppir_expansion_items(db: &dyn Db, file: SourceFile) -> PpirExpansionItems let (stream_type, _sap_attrs) = stream_expand(&ppir_ty, &ctx); let stream_type_expr = stream_type.to_type_expr(); - // Extract client name from parent's LLM metadata. - let client_name = match &func.declarative_meta { - Some(ast::DeclarativeMeta::Llm(llm)) => { - llm.client.as_ref().map(|n| n.as_str().to_string()) - } - _ => None, - }; - // Share the parent function's span — same as AST-level companions. let span = func.span; let name_span = func.name_span; - // Return type: baml.llm.Stream - // (matches the stdlib `class Stream` ordering: - // stream type first, final type second.) - // - // The same `` pair is also passed as - // explicit call-site type args in every companion body below, so - // the stdlib reifies the types from its frame - // (`reflect.type_of()`) instead of a name-keyed - // registry lookup. Explicit args lower through - // `lower_type_expr_in_ns` with the companion's own namespace - // context — bare in-namespace names resolve correctly, with - // diagnostics surfaced as ordinary compile errors. - let original_return_type_expr = return_type_spanned.clone(); - let companion_type_args = - || vec![stream_type_expr.clone(), original_return_type_expr.clone()]; - let companion_stream_return_type = ast::TypeExprKind::Path { - segments: vec![Name::new("baml"), Name::new("llm"), Name::new("Stream")], - generic_args: companion_type_args(), + // The `` pair: the return type + // `ai.stream.Stream` and the body's explicit call-site + // type args on `ai.stream.from_spec`, so the stdlib reifies both types + // from its own frame (`reflect.type_of()`). + let companion_type_args = vec![stream_type_expr, return_type_spanned.clone()]; + let return_type = ast::TypeExprKind::Path { + // The canonical host-facing stream lives with the rest of + // the AI streaming contract. + segments: vec![Name::new("ai"), Name::new("stream"), Name::new("Stream")], + generic_args: companion_type_args.clone(), associated_type_bindings: vec![], attrs: vec![], } .at(span); - // --- $stream companion --- - // Calls baml.llm.stream_llm_function(CLIENT, "FuncName", map { args }) - { - let param_names: Vec = func - .params - .iter() - .filter(|p| p.name.as_str() != "client") - .map(|p| p.name.clone()) - .collect(); - let client_arg_name = if func.params.iter().any(|p| p.name.as_str() == "client") - { - Some("client") - } else { - client_name.as_deref() - }; - // BEP-049 M5e: a new-mode (backtick) function threads the - // same `prompt`…`` closure into `stream_llm_function` that - // the oneshot path threads into `call_llm_function`, so the - // orchestrator renders via the closure instead of looking up - // a (nonexistent) Jinja template. `lower_cst` pre-built this - // body while the CST was still in hand (the closure captures - // this function's params, hence a dedicated arena); reuse it. - // Legacy Jinja prompts keep the 3-arg path. - let stream_body = match &func.declarative_meta { - Some(ast::DeclarativeMeta::Llm(llm)) => llm.stream_body.clone(), - _ => None, - }; - let (body, source_map) = stream_body.unwrap_or_else(|| { - ast::synthesize_llm_builtin_call( - "stream_llm_function", - func.name.as_str(), - ¶m_names, - client_arg_name, - companion_type_args(), - span, - ) - }); - let companion = ast::FunctionDef { - name: SmolStr::new(format!("{}$stream", func.name)), - generic_params: func.generic_params.clone(), - params: func.params.clone(), - defaults: func.defaults.clone(), - return_type: Some(companion_stream_return_type.clone()), - throws: None, - body: Some(ast::FunctionBodyDef::Expr(body, source_map)), - declarative_meta: None, - metadata: ast::FunctionMetadata::user_facing( - ast::FunctionOrigin::Companion, - ), - is_tagged_template_tag: func.is_tagged_template_tag, - attributes: vec![], - docstring: func.docstring.clone(), - span, - name_span, - }; - synthetic_items.push(ast::Item::Function(companion)); - } - - // --- $parse_stream companion --- - // Requires a client (calls CLIENT.__make_stream). - if let Some(client_arg_name) = func + // Params: the parent's own params plus its injected trailing + // `client` override, retyped to the streaming capability + // (`ai.StreamingClient? = null`) so passing a non-streaming + // client fails at the call site. The parent's `null` default + // is reused (the defaults arena is cloned as-is). + let params: Vec = func .params .iter() - .find(|p| p.name.as_str() == "client") - .map(|_| "client") - .or(client_name.as_deref()) - { - let sse_param = ast::Param { - name: Name::new("sse"), - type_expr: Some( - ast::TypeExprKind::Path { + .cloned() + .map(|mut p| { + if p.name.as_str() == "client" { + let capability = ast::TypeExprKind::Path { segments: vec![ - Name::new("baml"), - Name::new("http"), - Name::new("SseStream"), + Name::new("ai"), + Name::new("stream"), + Name::new("StreamingClient"), ], generic_args: vec![], associated_type_bindings: vec![], attrs: vec![], } - .at(span), - ), - default: None, - span, - name_span, - }; - - let (body, source_map) = ast::synthesize_llm_make_stream_call( - companion_type_args(), - client_arg_name, - span, - ); - let mut params = vec![sse_param]; - if let Some(client_param) = - func.params.iter().find(|p| p.name.as_str() == "client") - { - params.push(client_param.clone()); - } + .at(span); + p.type_expr = Some( + ast::TypeExprKind::Optional { + inner: Box::new(capability), + attrs: vec![], + } + .at(span), + ); + } + p + }) + .collect(); - let companion = ast::FunctionDef { - name: SmolStr::new(format!("{}$parse_stream", func.name)), - generic_params: func.generic_params.clone(), - params, - defaults: func.defaults.clone(), - return_type: Some(companion_stream_return_type), - throws: None, - body: Some(ast::FunctionBodyDef::Expr(body, source_map)), - declarative_meta: None, - metadata: ast::FunctionMetadata::user_facing( - ast::FunctionOrigin::Companion, - ), - is_tagged_template_tag: func.is_tagged_template_tag, - attributes: vec![], - docstring: func.docstring.clone(), - span, - name_span, - }; - synthetic_items.push(ast::Item::Function(companion)); - } + let param_names: Vec = func + .params + .iter() + .filter(|p| p.name.as_str() != "client") + .map(|p| p.name.clone()) + .collect(); + let (body, source_map) = ast::synthesize_spec_stream_body( + func.name.as_str(), + ¶m_names, + companion_type_args, + span, + ); - // --- $parse companion --- - // Built by `companions::llm_parse` (kept with its sibling - // expanders in baml_compiler2_ast), but invoked from here: - // its body needs the stream-expanded return type as an - // explicit type arg, which only PPIR can compute. - if let Some(companion) = ast::llm_parse_companion(func, companion_type_args()) { - synthetic_items.push(ast::Item::Function(companion)); - } + let companion = ast::FunctionDef { + name: SmolStr::new(format!("{}$stream", func.name)), + generic_params: func.generic_params.clone(), + params, + defaults: func.defaults.clone(), + return_type: Some(return_type), + throws: None, + body: Some(ast::FunctionBodyDef::Expr(body, source_map)), + declarative_meta: None, + metadata: ast::FunctionMetadata::user_facing(ast::FunctionOrigin::Companion), + is_tagged_template_tag: func.is_tagged_template_tag, + attributes: vec![], + docstring: func.docstring.clone(), + span, + name_span, + }; + synthetic_items.push(ast::Item::Function(companion)); } _ => {} } diff --git a/baml_language/crates/baml_compiler2_tir/src/builder.rs b/baml_language/crates/baml_compiler2_tir/src/builder.rs index 417ee58c8bd..b87ad33e200 100644 --- a/baml_language/crates/baml_compiler2_tir/src/builder.rs +++ b/baml_language/crates/baml_compiler2_tir/src/builder.rs @@ -4099,7 +4099,7 @@ impl<'db> TypeInferenceBuilder<'db> { // callee (free/method/static) reaches here with `generic_params` // cleared by receiver/class substitution yet its own params still // inferable — restricting there would wrongly freeze `arr.map`'s - // `U` or `StreamCache.new`'s class params. + // `U` or `ParseCache.new`'s class params. if is_value_call { bindings.retain(|name, _| { crate::generics::is_value_call_inferable(name, &generic_params) @@ -10102,7 +10102,7 @@ impl<'db> TypeInferenceBuilder<'db> { self.infer_local_rooted_path(segments, expr_id, true) } else { // Root is not a known local. Try full package/namespace resolution: - // 1. Package path (e.g. baml.llm.ClientType.Primitive, baml.env.get) + // 1. Package path (e.g. ai.Prompt, baml.env.get) let pkg_ty = self.infer_multi_segment_path(segments, expr_id); if !matches!(pkg_ty, Ty::Unknown { .. }) { return pkg_ty; @@ -10298,7 +10298,7 @@ impl<'db> TypeInferenceBuilder<'db> { current_ty } - /// Resolve a multi-segment path like `baml.llm.render_prompt` or `root.sys.panic`. + /// Resolve a multi-segment path like `baml.http.fetch` or `root.sys.panic`. /// /// The first segment is either a literal package name or `root` (maps to the /// current file's package). @@ -15664,8 +15664,8 @@ impl TypeInferenceBuilder<'_> { // (with a per-member type). After all branches lower, we // collapse same-named bindings into one entry typed at // the join of the per-branch types — so e.g. `parsed` - // bound across `S` and `StreamNoYield` ends up typed at - // `S | StreamNoYield` rather than last-write-wins. + // bound across `S` and `NoYield` ends up typed at + // `S | NoYield` rather than last-write-wins. let mut bindings_by_name: indexmap::IndexMap)> = indexmap::IndexMap::new(); // `pattern_types` is single-valued per PatId, but lowering the diff --git a/baml_language/crates/baml_compiler_lexer/src/tokens.rs b/baml_language/crates/baml_compiler_lexer/src/tokens.rs index 3b63a9e6bc0..7770cc1ac52 100644 --- a/baml_language/crates/baml_compiler_lexer/src/tokens.rs +++ b/baml_language/crates/baml_compiler_lexer/src/tokens.rs @@ -1048,8 +1048,8 @@ mod tests { #[test] fn test_path_with_keyword_segment() { - // `baml.llm.get_client` should be 5 tokens: WORD DOT WORD DOT WORD - let tokens = lex_no_whitespace("baml.llm.get_client"); + // `baml.prompt.get_client` should be 5 tokens: WORD DOT WORD DOT WORD + let tokens = lex_no_whitespace("baml.prompt.get_client"); assert_eq!( tokens, vec![ diff --git a/baml_language/crates/baml_compiler_parser/src/parser.rs b/baml_language/crates/baml_compiler_parser/src/parser.rs index ab02d9ebb07..1d374b84ef2 100644 --- a/baml_language/crates/baml_compiler_parser/src/parser.rs +++ b/baml_language/crates/baml_compiler_parser/src/parser.rs @@ -4209,22 +4209,36 @@ impl<'a> Parser<'a> { if text == "const" { return false; } + // `tools` is deliberately NOT a trigger: every LLM function + // must also declare `client` and `prompt`, and raw string + // contents lex as ordinary tokens in this scan, so a body + // containing e.g. "tools/list" would misclassify. if text == "client" || text == "prompt" { // An LLM field is `client ` / `prompt