Skip to main content

netget/llm/
bridge.rs

1//! An LLM backend that asks the host to answer.
2//!
3//! The browser build has no HTTP client it could point at Ollama and no model of its own;
4//! what it has is a page. The page may run a model in-browser (the browser's built-in model,
5//! or WebLLM over WebGPU), or show the request to the person at the keyboard and let *them*
6//! be the model. NetGet does not need to know which: every request
7//! `OllamaClient` would have sent over HTTP becomes a [`BridgeRequest`] on a channel, and
8//! whoever drains the channel answers it with a [`BridgeReply`].
9//!
10//! The request carries everything the wire request would have: the model name, the full
11//! message list (system prompt included) and the tool schemas. That is deliberate: the demo
12//! page shows all of it, so a visitor sees exactly what NetGet says to a model and what comes
13//! back. Nothing here is browser-specific; a native test can drain the same channel, which is
14//! how the mapping from request to `ChatResponse` is verified.
15//!
16//! It also carries what no wire request does: the actions the prompt offers, as structured
17//! data ([`BridgeRequest::actions`]). A network event's request has no native tool schemas —
18//! the action list is prose inside the prompt — and a person answering by hand needs the
19//! names, the parameters and a working example in a form a page can turn into a form.
20
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::{Arc, Mutex};
23
24use serde::{Deserialize, Serialize};
25use tokio::sync::{mpsc, oneshot};
26
27use crate::llm::actions::ActionDefinition;
28use crate::llm::ollama_client::Message;
29
30/// Which client entry point produced the request. The host may treat them alike (both are
31/// "here are messages, give me a completion"); the distinction is kept because the two
32/// paths parse the answer differently.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum BridgeRequestKind {
36    /// `generate_with_format`: one flattened prompt, the answer is parsed from its text.
37    /// Every network event goes this way.
38    Generate,
39    /// `chat_with_tools`: structured messages plus tool schemas; the answer may carry
40    /// native tool calls. User chat input and scheduled tasks go this way.
41    Chat,
42}
43
44/// The network event behind a model request. The token identifies one event conversation,
45/// so a host can attach deterministic state once even when the model answer is retried.
46/// This is bridge metadata only; native model backends keep their existing wire format.
47#[derive(Debug, Clone, Serialize)]
48pub struct BridgeEventContext {
49    pub token: String,
50    pub server_id: u32,
51    pub connection_id: Option<u32>,
52    pub protocol: String,
53    pub event_type: String,
54    pub data: serde_json::Value,
55}
56
57/// One LLM request, as the host sees it.
58#[derive(Debug, Serialize)]
59pub struct BridgeRequest {
60    pub id: u64,
61    pub kind: BridgeRequestKind,
62    pub model: String,
63    /// The full prompt. For `Generate` this is a single `user` message holding the
64    /// flattened prompt; for `Chat` it is the conversation, system message first.
65    pub messages: Vec<Message>,
66    /// Tool schemas in OpenAI function-tool format; empty for `Generate`.
67    pub tools: Vec<serde_json::Value>,
68    /// Every action the prompt offers for this request, in the shape [`offered_action`]
69    /// documents — the same list the model is shown, whether it is shown as prose (every
70    /// network event) or as native tools. Empty for a request that offers no actions.
71    /// Nothing on the native Ollama/OpenAI wire carries it.
72    pub actions: Vec<serde_json::Value>,
73    /// Present for server event conversations; unchanged across retries of that event.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub event: Option<BridgeEventContext>,
76    /// Where the answer goes. Dropping it without answering fails the request.
77    #[serde(skip)]
78    pub reply: oneshot::Sender<Result<BridgeReply, String>>,
79}
80
81/// A native tool call in the host's answer.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct BridgeToolCall {
84    #[serde(default)]
85    pub id: Option<String>,
86    /// Accepts either spelling so a host can pass an OpenAI `function.name` through
87    /// untouched.
88    #[serde(default, alias = "function_name")]
89    pub name: String,
90    #[serde(default)]
91    pub arguments: serde_json::Value,
92}
93
94/// The host's answer.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct BridgeReply {
97    /// The answer: the action envelope, or plain text. Only this is parsed.
98    #[serde(default)]
99    pub content: Option<String>,
100    #[serde(default)]
101    pub tool_calls: Vec<BridgeToolCall>,
102    /// The reasoning a model that thinks natively wrote before this answer — the demo page
103    /// sends Qwen3's `<think>` block here and the text after it as `content`. It is never
104    /// parsed as part of the answer. It reaches the status channel as `[REASONING]` lines,
105    /// exactly as an Ollama `thinking` stream or an OpenAI `reasoning`/`reasoning_content`
106    /// stream does, so the dashboard shows it the same way.
107    #[serde(default, alias = "reasoning_content", alias = "thinking")]
108    pub reasoning: Option<String>,
109    #[serde(default)]
110    pub prompt_tokens: u64,
111    #[serde(default)]
112    pub completion_tokens: u64,
113}
114
115/// The channel between `OllamaClient` and the host.
116pub struct LlmBridge {
117    tx: mpsc::UnboundedSender<BridgeRequest>,
118    next_id: AtomicU64,
119    models: Mutex<Vec<String>>,
120}
121
122impl LlmBridge {
123    /// Create a bridge and the receiver the host drains.
124    pub fn new() -> (Arc<Self>, mpsc::UnboundedReceiver<BridgeRequest>) {
125        let (tx, rx) = mpsc::unbounded_channel();
126        (
127            Arc::new(Self {
128                tx,
129                next_id: AtomicU64::new(1),
130                models: Mutex::new(Vec::new()),
131            }),
132            rx,
133        )
134    }
135
136    /// Hand a request to the host. The returned receiver yields the answer; an `Err` inside
137    /// it is the host's own error text, an `Err` on the receive is the host dropping the
138    /// request unanswered.
139    pub fn submit(
140        &self,
141        kind: BridgeRequestKind,
142        model: String,
143        messages: Vec<Message>,
144        tools: Vec<serde_json::Value>,
145        offered: &[ActionDefinition],
146    ) -> (u64, oneshot::Receiver<Result<BridgeReply, String>>) {
147        self.submit_with_event(kind, model, messages, tools, offered, None)
148    }
149
150    /// Submit an event request without requiring the host to parse event data out of prose.
151    pub(crate) fn submit_with_event(
152        &self,
153        kind: BridgeRequestKind,
154        model: String,
155        messages: Vec<Message>,
156        tools: Vec<serde_json::Value>,
157        offered: &[ActionDefinition],
158        event: Option<BridgeEventContext>,
159    ) -> (u64, oneshot::Receiver<Result<BridgeReply, String>>) {
160        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
161        let (reply, rx) = oneshot::channel();
162        let request = BridgeRequest {
163            id,
164            kind,
165            model,
166            messages,
167            tools,
168            actions: offered.iter().map(offered_action).collect(),
169            event,
170            reply,
171        };
172        // A closed receiver means no host; the request's reply sender is dropped with it,
173        // which the caller sees as an unanswered request.
174        let _ = self.tx.send(request);
175        (id, rx)
176    }
177
178    /// What `list_models` reports. The host sets this to whatever it can run.
179    pub fn set_models(&self, models: Vec<String>) {
180        *self.models.lock().expect("bridge model list") = models;
181    }
182
183    pub fn models(&self) -> Vec<String> {
184        self.models.lock().expect("bridge model list").clone()
185    }
186}
187
188/// The names [`offered_action`] marks `generic`.
189static GENERIC_ACTIONS: std::sync::LazyLock<std::collections::HashSet<String>> =
190    std::sync::LazyLock::new(|| {
191        let mut names: std::collections::HashSet<String> =
192            crate::llm::actions::common::get_network_event_common_actions()
193                .into_iter()
194                .map(|a| a.name)
195                .collect();
196        names.insert(crate::llm::actions::common::provide_feedback_action().name);
197        names
198    });
199
200/// One offered action as the host sees it:
201///
202/// ```json
203/// {"name": "send_tcp_data", "description": "...", "tool": false, "generic": false,
204///  "parameters": [{"name": "encoding", "type": "\"utf8\" | \"hex\"", "description": "...",
205///                  "required": false, "choices": ["utf8", "hex"]}],
206///  "example": {"type": "send_tcp_data", "data": "hi"},
207///  "schema": {"type": "object", "properties": {...}, "required": [...]}}
208/// ```
209///
210/// - `type` is the declared type hint verbatim (`"string"`, `"number | string"`,
211///   `"array"`…); `choices` is present only for a closed value set (`Parameter::choices`).
212/// - `example` is the action's own example, which its executor accepts
213///   (`tests/executable_examples_test.rs`), so a host can prefill a form from it.
214/// - `tool` is true for a tool (`read_file`, `web_search`, …): the model is asked again with
215///   its result, and in the JSON envelope it goes under `"tools"` rather than `"actions"`.
216/// - `generic` is true for the bookkeeping actions every network event offers whatever the
217///   protocol (`set_memory`, `show_message`, `provide_feedback`, …), so a host can put the
218///   protocol's own answer first.
219/// - `schema` is the JSON Schema of the parameters, the one a native tool schema carries.
220pub fn offered_action(action: &ActionDefinition) -> serde_json::Value {
221    let parameters: Vec<serde_json::Value> = action
222        .parameters
223        .iter()
224        .map(|p| {
225            let mut v = serde_json::json!({
226                "name": p.name,
227                "type": p.type_hint,
228                "description": p.description,
229                "required": p.required,
230            });
231            if let Some(choices) = p.choices() {
232                v["choices"] = serde_json::json!(choices);
233            }
234            v
235        })
236        .collect();
237    let schema = action
238        .to_tool_schema()
239        .pointer("/function/parameters")
240        .cloned()
241        .unwrap_or(serde_json::Value::Null);
242    serde_json::json!({
243        "name": action.name,
244        "description": action.description,
245        "tool": action.is_tool(),
246        "generic": GENERIC_ACTIONS.contains(action.name.as_str()),
247        "parameters": parameters,
248        "example": action.example,
249        "schema": schema,
250    })
251}