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}