Skip to main content

salvor_server/
executor.rs

1//! The model-executor seam: the general injection point a host supplies so the
2//! server can perform a model call on a client-driven run's behalf.
3//!
4//! # Why it is a seam, not baked in
5//!
6//! This mirrors the [`AgentFactory`](crate::AgentFactory) decision exactly. The
7//! server owns the *mechanism* of a durable model step (append the write-ahead
8//! intent, perform the call, append the completion, answer retries from the
9//! log), but it must not own the *policy* of which provider runs, where its
10//! credential comes from, or how the request is shaped. Salvor is for anyone
11//! building on it, browser or backend; aarg is only the first consumer. So the
12//! executor is a trait the embedding binary implements and injects, never a
13//! hard-wired provider.
14//!
15//! `salvor serve` wires a default [`LlmModelExecutor`] from its own
16//! client-construction path, so the feature works out of the box; another host
17//! (a future `aarg serve`) injects its own executor resolving a keychain
18//! credential, and nothing consumer-specific leaks into salvor-server.
19//!
20//! # The two methods
21//!
22//! - [`ModelExecutor::execute`] performs a single call and returns the assembled
23//!   [`MessageResponse`]. This backs the non-streaming model step.
24//! - [`ModelExecutor::open_stream`] opens the provider stream and returns a
25//!   [`ModelStream`] the server pumps: each event feeds a live ticker frame and
26//!   a [`MessageAccumulator`], and the assembled completion is recorded once at
27//!   the end. This backs the server-sent-events model step.
28//!
29//! The request crosses the seam as a raw [`Value`] (the caller's canonical
30//! request JSON), not a typed `MessageRequest`, because the server hashes and
31//! records exactly those bytes: the hash it recorded is the hash it sent.
32//!
33//! An executor error is a plain `String`, the same human-message convention the
34//! agent factory uses. A provider failure maps to the server error envelope
35//! without a completion being recorded, so the intent is left dangling (legal,
36//! the crash story) and the run stays drivable.
37//!
38//! # Naming the key on a 401, the way the CLI already does
39//!
40//! A raw 401 from `salvor_llm::Error::Api` says the request was rejected; it
41//! says nothing about where to fix it. The CLI's `contextualize_auth_error`
42//! (`salvor-cli/src/commands.rs`) solves this for the agent-driven run/resume
43//! path because it still has the agent's own `[llm] api_key_env` in scope. This
44//! executor has no such per-agent config to read: a client-driven run's model
45//! step is served by the one [`LlmModelExecutor`] `salvor serve` wires for the
46//! whole process, built by `Config::from_env`, which only ever reads
47//! `ANTHROPIC_API_KEY` (see `salvor-llm/src/config.rs`). So `contextualize_401`
48//! names that fixed variable directly, and points at the machine running the
49//! server rather than the client's own environment: for a client-driven run the
50//! key lives with the server, not the caller driving it over HTTP.
51
52use async_trait::async_trait;
53use salvor_llm::{Client, Error, MessageResponse, MessageStream, StreamEvent};
54use serde_json::Value;
55
56/// The environment variable `Config::from_env` reads for the client-driven
57/// model step's executor. Fixed, not per-agent: see the module doc above.
58const API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
59
60/// Turns a 401 from the Messages API into a message naming `ANTHROPIC_API_KEY`
61/// and where to set it; every other error passes through as `to_string()`.
62fn contextualize_401(error: Error) -> String {
63    let Error::Api(ref api) = error else {
64        return error.to_string();
65    };
66    if api.status != 401 {
67        return error.to_string();
68    }
69    format!(
70        "authentication failed calling the Messages API (HTTP 401: {}). The client-driven \
71         model step reads its API key from the `{API_KEY_ENV}` environment variable on this \
72         server (the key lives with the server, not the client driving the run). Export \
73         `{API_KEY_ENV}` where `salvor serve` runs and try again.",
74        api.message,
75    )
76}
77
78/// Performs a model call on behalf of a client-driven run. Injected by the
79/// embedding binary, exactly like [`AgentFactory`](crate::AgentFactory).
80#[async_trait]
81pub trait ModelExecutor: Send + Sync {
82    /// Perform a single (non-streaming) model call and return the response.
83    ///
84    /// # Errors
85    ///
86    /// A human message when the provider call fails. The server maps it to the
87    /// error envelope and records no completion.
88    async fn execute(&self, request: Value) -> Result<MessageResponse, String>;
89
90    /// Open a streaming model call, returning a [`ModelStream`] of provider
91    /// events the server pumps for the live ticker.
92    ///
93    /// # Errors
94    ///
95    /// A human message when the stream cannot be opened.
96    async fn open_stream(&self, request: Value) -> Result<Box<dyn ModelStream>, String>;
97}
98
99/// A stream of provider events opened by [`ModelExecutor::open_stream`].
100///
101/// It mirrors `salvor_llm::MessageStream`: pull one typed event at a time until
102/// `None`. The server feeds each event to a [`MessageAccumulator`] so the
103/// recorded completion is byte-identical to the non-streaming path.
104#[async_trait]
105pub trait ModelStream: Send {
106    /// The next provider event, or `None` once the stream is exhausted. An
107    /// `Err(String)` is a mid-stream failure: the server surfaces it and records
108    /// no completion, leaving the intent dangling for a safe re-issue on resume.
109    async fn next_event(&mut self) -> Option<Result<StreamEvent, String>>;
110}
111
112/// The default [`ModelExecutor`], wrapping a general `salvor_llm::Client`.
113///
114/// This is not consumer-specific: it wraps the general model transport, so any
115/// host that reaches a provider through `salvor-llm` gets a working executor by
116/// constructing a [`Client`] and handing it here. `salvor serve` builds one from
117/// its environment client-construction path.
118pub struct LlmModelExecutor {
119    client: Client,
120}
121
122impl LlmModelExecutor {
123    /// Wraps `client` as a model executor.
124    #[must_use]
125    pub fn new(client: Client) -> Self {
126        Self { client }
127    }
128}
129
130#[async_trait]
131impl ModelExecutor for LlmModelExecutor {
132    async fn execute(&self, request: Value) -> Result<MessageResponse, String> {
133        // Forward the caller's request bytes verbatim: the hash the server
134        // recorded over `request` is the hash it sends.
135        self.client
136            .send_message_value(&request)
137            .await
138            .map_err(contextualize_401)
139    }
140
141    async fn open_stream(&self, request: Value) -> Result<Box<dyn ModelStream>, String> {
142        let stream = self
143            .client
144            .stream_message_value(&request)
145            .await
146            .map_err(contextualize_401)?;
147        Ok(Box::new(LlmModelStream { inner: stream }))
148    }
149}
150
151/// The [`ModelStream`] over a `salvor_llm::MessageStream`.
152struct LlmModelStream {
153    inner: MessageStream,
154}
155
156#[async_trait]
157impl ModelStream for LlmModelStream {
158    async fn next_event(&mut self) -> Option<Result<StreamEvent, String>> {
159        self.inner
160            .next_event()
161            .await
162            .map(|event| event.map_err(contextualize_401))
163    }
164}