Skip to main content

nexus_core/provider/
mod.rs

1pub mod openrouter;
2
3use serde::{Deserialize, Serialize};
4
5/// A tool the model may call, in `OpenAI` function-calling shape. Serde
6/// derives so the Phase 4 remote `ToolExecutor` can ship the same wire
7/// shape without churning this type.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolDef {
10    pub name: String,
11    pub description: String,
12    pub parameters: serde_json::Value,
13}
14
15/// One call the model made to a tool, accumulated from streamed fragments.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct ToolCall {
18    pub id: String,
19    pub name: String,
20    /// Raw JSON arguments string, as the model streamed it.
21    pub arguments: String,
22}
23
24/// Which configured backend a `Model` came from — every backend's models
25/// are merged into one list (`App::models`), so this is how a pick routes
26/// back to the right one at request time.
27#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
28pub enum BackendTag {
29    OpenRouter,
30    OpenAi,
31    OpencodeGo,
32    Codex,
33}
34
35impl BackendTag {
36    /// Human-readable backend name, used in usage analytics.
37    pub const fn name(self) -> &'static str {
38        match self {
39            Self::OpenRouter => "OpenRouter",
40            Self::OpenAi => "OpenAI",
41            Self::OpencodeGo => "OpenCode Go",
42            Self::Codex => "Codex",
43        }
44    }
45
46    /// Prefix used to key favorites/last-used/current-model/etc. for this
47    /// backend's models — bare (no prefix) for ``OpenRouter`` so existing
48    /// users' saved data keeps working untouched; the other three are
49    /// visually tagged since their raw ids can collide (e.g. two "gpt-4.1"s).
50    pub const fn key_prefix(self) -> &'static str {
51        match self {
52            Self::OpenRouter => "",
53            Self::OpenAi => "openai:",
54            Self::OpencodeGo => "opencode:",
55            Self::Codex => "codex:",
56        }
57    }
58
59    /// Prefix used for model ids crossing an OpenAI-wire API boundary.
60    /// Unlike `key_prefix`, this qualifies `OpenRouter` too.
61    pub const fn wire_prefix(self) -> &'static str {
62        match self {
63            Self::OpenRouter => "openrouter:",
64            Self::OpenAi => "openai:",
65            Self::OpencodeGo => "opencode:",
66            Self::Codex => "codex:",
67        }
68    }
69
70    pub const fn display_name(self) -> &'static str {
71        match self {
72            Self::OpenRouter => "OpenRouter",
73            Self::OpenAi => "OpenAI",
74            Self::OpencodeGo => "OpenCode Go",
75            Self::Codex => "Codex",
76        }
77    }
78}
79
80/// A reasoning/thinking effort value a model accepts, in cycle order.
81/// Provider catalogs expose different subsets per model, so this enum covers
82/// every effort currently present in those catalogs. `None` is the explicit
83/// wire-level disable value; absence from `ChatParams` still means "do not
84/// send a reasoning parameter".
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
86pub enum ReasoningEffort {
87    None,
88    Minimal,
89    Low,
90    Medium,
91    High,
92    XHigh,
93    Max,
94}
95
96impl ReasoningEffort {
97    /// Wire value sent to the provider (and stored in `model_prefs`).
98    pub const fn as_str(self) -> &'static str {
99        match self {
100            Self::None => "none",
101            Self::Minimal => "minimal",
102            Self::Low => "low",
103            Self::Medium => "medium",
104            Self::High => "high",
105            Self::XHigh => "xhigh",
106            Self::Max => "max",
107        }
108    }
109
110    /// Every known wire value in UI cycle order: enabled tiers from least to
111    /// most thinking, then explicit disable when the model accepts it.
112    pub const CYCLE_ORDER: &'static [Self] = &[
113        Self::Minimal,
114        Self::Low,
115        Self::Medium,
116        Self::High,
117        Self::XHigh,
118        Self::Max,
119        Self::None,
120    ];
121
122    /// The values most reasoning models accept when no richer metadata exists.
123    pub const STANDARD: &'static [Self] = &[Self::Low, Self::Medium, Self::High];
124
125    /// Models that add a `minimal` tier to the standard set.
126    pub const WITH_MINIMAL: &'static [Self] = &[Self::Minimal, Self::Low, Self::Medium, Self::High];
127
128    /// Models that can be explicitly disabled and add an `xhigh` tier.
129    pub const WITH_XHIGH_AND_NONE: &'static [Self] =
130        &[Self::Low, Self::Medium, Self::High, Self::XHigh, Self::None];
131
132    /// Models that additionally expose a top-level `max` tier.
133    pub const WITH_MAX_XHIGH_AND_NONE: &'static [Self] = &[
134        Self::Low,
135        Self::Medium,
136        Self::High,
137        Self::XHigh,
138        Self::Max,
139        Self::None,
140    ];
141
142    /// Models whose only configurable reasoning tier is `high`.
143    pub const HIGH_ONLY: &'static [Self] = &[Self::High];
144}
145
146/// Per-token catalog prices in USD per 1M tokens.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct ModelPricing {
149    pub prompt: f64,
150    pub completion: f64,
151    /// Discounted cache-read price. `None` means cache reads use the regular
152    /// prompt price (or the catalog does not expose a separate rate).
153    pub cache_read: Option<f64>,
154    /// Cache-write price. `None` means writes use the regular prompt price.
155    pub cache_write: Option<f64>,
156}
157
158/// A model offered by the provider, as shown in the picker.
159#[derive(Debug, Clone)]
160pub struct Model {
161    pub id: String,
162    pub name: String,
163    /// The reasoning effort values this model accepts, in Ctrl+T cycle order.
164    /// Empty = the model has no reasoning/thinking mode at all.
165    pub reasoning_efforts: Vec<ReasoningEffort>,
166    /// Context window size in tokens, if the provider reports it.
167    pub context_length: Option<u64>,
168    /// Whether the model accepts image input (`architecture.input_modalities`).
169    pub supports_images: bool,
170    /// Whether the model generates image output (`architecture.output_modalities`).
171    pub supports_image_generation: bool,
172    /// Whether the model is listed by ``OpenRouter``'s dedicated video catalog.
173    pub supports_video_generation: bool,
174    /// Which backend this model came from.
175    pub backend: BackendTag,
176    /// USD per 1M tokens from the catalog (`OpenRouter` only; other backends
177    /// report no prices). `None` = cost unknown.
178    pub pricing: Option<ModelPricing>,
179}
180
181/// Sampling + reasoning parameters for a completion request.
182#[derive(Debug, Clone, Default)]
183pub struct ChatParams {
184    /// Reasoning effort wire value (for example `minimal`, `low`, `high`,
185    /// `xhigh`, `max`, or explicit `none`), as stored per model. Rust `None`
186    /// means do not send the parameter at all.
187    pub reasoning_effort: Option<String>,
188    /// Stable per-session key used by compatible OpenAI-wire backends to
189    /// keep prompt-cache entries on one cache lane.
190    pub prompt_cache_key: Option<String>,
191    pub temperature: Option<f32>,
192    pub top_p: Option<f32>,
193    pub max_tokens: Option<u32>,
194}
195
196/// A message sent to the completions API. `tool_calls` (assistant requesting
197/// tools) and `tool_call_id` (a tool's result) are only set on those two
198/// message shapes; wire format follows the `OpenAI` function-calling schema.
199/// `images` (data URLs) are set for user messages with attachments when the
200/// active model supports vision; when non-empty, `content` serializes as an
201/// `OpenAI` vision parts array instead of a plain string.
202#[derive(Debug, Clone, Default)]
203pub struct ChatMessage {
204    pub role: String,
205    pub content: String,
206    /// Provider reasoning text that must be passed back with assistant tool
207    /// calls when the backend requires it for replayable context.
208    pub reasoning_content: Option<String>,
209    pub tool_calls: Option<Vec<ToolCall>>,
210    pub tool_call_id: Option<String>,
211    pub images: Vec<String>,
212}
213
214impl ChatMessage {
215    pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
216        Self {
217            role: role.into(),
218            content: content.into(),
219            ..Default::default()
220        }
221    }
222}
223
224#[derive(Serialize)]
225struct Function<'a> {
226    name: &'a str,
227    arguments: &'a str,
228}
229
230#[derive(Serialize)]
231struct Wire<'a> {
232    id: &'a str,
233    r#type: &'static str,
234    function: Function<'a>,
235}
236
237impl Serialize for ChatMessage {
238    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
239        use serde::ser::SerializeMap;
240        let mut map = s.serialize_map(None)?;
241        map.serialize_entry("role", &self.role)?;
242        if self.images.is_empty() {
243            map.serialize_entry("content", &self.content)?;
244        } else {
245            // OpenAI vision shape: text part (if any) + one image_url part per image.
246            let mut parts: Vec<serde_json::Value> = Vec::new();
247            if !self.content.is_empty() {
248                parts.push(serde_json::json!({ "type": "text", "text": self.content }));
249            }
250            for url in &self.images {
251                parts.push(serde_json::json!({ "type": "image_url", "image_url": { "url": url } }));
252            }
253            map.serialize_entry("content", &parts)?;
254        }
255        if let Some(reasoning) = &self.reasoning_content {
256            map.serialize_entry("reasoning_content", reasoning)?;
257        }
258        if let Some(calls) = &self.tool_calls {
259            let wire: Vec<Wire> = calls
260                .iter()
261                .map(|c| Wire {
262                    id: &c.id,
263                    r#type: "function",
264                    function: Function {
265                        name: &c.name,
266                        arguments: &c.arguments,
267                    },
268                })
269                .collect();
270            map.serialize_entry("tool_calls", &wire)?;
271        }
272        if let Some(id) = &self.tool_call_id {
273            map.serialize_entry("tool_call_id", id)?;
274        }
275        map.end()
276    }
277}
278
279/// Events emitted while a completion streams. Delivered over an mpsc channel so
280/// the UI event loop can interleave them with keypresses.
281/// Exact token accounting reported by the provider at end of stream.
282#[derive(Debug, Clone, Copy)]
283// _tokens postfix is the unit — removing it would make the fields ambiguous.
284#[allow(clippy::struct_field_names)]
285pub struct Usage {
286    pub prompt_tokens: u64,
287    pub completion_tokens: u64,
288    pub total_tokens: u64,
289    /// Prompt tokens served from the provider's prompt cache (cache reads).
290    pub cache_read_tokens: u64,
291    /// Prompt tokens written into the cache on this request (cache writes).
292    pub cache_creation_tokens: u64,
293    /// Provider-reported request cost in USD. `OpenRouter` includes it in the
294    /// final usage object; `OpenCode` Zen/Go reports it in a trailing streamed
295    /// chunk (`"cost":"0.0012"`). `None` when the provider omits cost.
296    pub cost: Option<f64>,
297}
298
299impl Usage {
300    /// Fraction of this request's prompt served from cache, 0.0..=1.0.
301    /// `None` when the provider reported no usage or a zero prompt.
302    #[allow(clippy::cast_precision_loss)] // token counts are too large for u32; a ratio loses nothing meaningful
303    pub fn cache_hit_rate(&self) -> Option<f64> {
304        if self.prompt_tokens == 0 {
305            None
306        } else {
307            Some((self.cache_read_tokens as f64 / self.prompt_tokens as f64).clamp(0.0, 1.0))
308        }
309    }
310}
311
312/// The text and usage returned by a one-shot completion.
313#[derive(Debug, Clone)]
314pub struct Completion {
315    /// Assistant-visible text returned by the provider.
316    pub text: String,
317    /// Exact request usage, when the provider included it.
318    pub usage: Option<Usage>,
319}
320
321/// Rebuild the tool-result dedup state — `(tool, arguments) → latest full
322/// result` — from a wire history produced by `build_history`. Both that
323/// replay and the live tool loop apply the same rule ("keep the first full
324/// copy of a (tool, arguments, result) triple; replace later identical
325/// copies with `tool_result_unchanged_note`"), and the loop seeds its map
326/// from the incoming history so its compression decisions match what the
327/// next turn's replay will rebuild — which keeps the prompt-cache prefix
328/// continuous across turns. Note messages (marked with
329/// `TOOL_RESULT_OMITTED_PREFIX`) never update the map, exactly as in the
330/// replay.
331pub fn seed_tool_result_dedup(
332    messages: &[ChatMessage],
333) -> std::collections::HashMap<(String, String), String> {
334    use std::collections::HashMap;
335    let mut seen: HashMap<(String, String), String> = HashMap::new();
336    // Assistant tool_calls arrive immediately before their tool results, in
337    // order (build_history emits one pair per row; the loop emits the call
338    // batch then its results) — a FIFO queue pairs them up.
339    let mut pending: std::collections::VecDeque<(String, String)> =
340        std::collections::VecDeque::new();
341    for msg in messages {
342        if let Some(calls) = &msg.tool_calls {
343            for call in calls {
344                pending.push_back((call.name.clone(), call.arguments.clone()));
345            }
346        } else if msg.role == "tool"
347            && let Some((name, args)) = pending.pop_front()
348            && !msg
349                .content
350                .starts_with(crate::tools::TOOL_RESULT_OMITTED_PREFIX)
351        {
352            seen.insert((name, args), msg.content.clone());
353        }
354    }
355    seen
356}
357
358#[derive(Debug, Clone)]
359pub enum StreamEvent {
360    /// A chunk of the visible answer.
361    Token(String),
362    /// A chunk of the model's reasoning/thinking (shown separately).
363    Reasoning(String),
364    /// Exact token counts (arrives near end when usage accounting is on).
365    Usage(Usage),
366    /// A tool is about to run (e.g. "Searching the web…"), shown next to the
367    /// thinking spinner while the model waits on the result.
368    Status(String),
369    /// A tool finished: shown (and persisted) as its own transcript block.
370    ToolCall {
371        /// The provider's stable call id, retained for exact replay. It is
372        /// intentionally omitted by the remote host wire mirror.
373        id: String,
374        /// Reasoning content that accompanied the assistant tool-call batch,
375        /// when the provider requires it on the next request.
376        reasoning: Option<String>,
377        /// Any visible assistant content sent alongside the tool-call batch.
378        assistant_content: Option<String>,
379        name: String,
380        arguments: String,
381        result: String,
382    },
383    Done,
384    Error(String),
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn seed_tool_result_dedup_reconstructs_replay_state() {
393        // A build_history-style wire history: full v1, note (dup), full v2
394        // (file changed), note (dup of v2).
395        let pair = |id: &str, name: &str, args: &str, content: &str| {
396            vec![
397                ChatMessage {
398                    role: "assistant".into(),
399                    content: String::new(),
400                    reasoning_content: None,
401                    tool_calls: Some(vec![ToolCall {
402                        id: id.into(),
403                        name: name.into(),
404                        arguments: args.into(),
405                    }]),
406                    tool_call_id: None,
407                    images: Vec::new(),
408                },
409                ChatMessage {
410                    role: "tool".into(),
411                    content: content.into(),
412                    reasoning_content: None,
413                    tool_calls: None,
414                    tool_call_id: Some(id.into()),
415                    images: Vec::new(),
416                },
417            ]
418        };
419        let args = r#"{"name":"a.txt"}"#;
420        let mut msgs = pair("c0", "read_file", args, "v1");
421        msgs.extend(pair(
422            "c1",
423            "read_file",
424            args,
425            crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
426        ));
427        msgs.extend(pair("c2", "read_file", args, "v2"));
428        msgs.extend(pair(
429            "c3",
430            "read_file",
431            args,
432            crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
433        ));
434        let seen = seed_tool_result_dedup(&msgs);
435        // Notes must never overwrite the map: the latest full result wins.
436        assert_eq!(
437            seen.get(&("read_file".to_string(), args.to_string())),
438            Some(&"v2".to_string())
439        );
440        assert_eq!(seen.len(), 1);
441    }
442
443    #[test]
444    fn seed_tool_result_dedup_keeps_latest_full_per_call() {
445        let msgs = vec![
446            ChatMessage {
447                role: "assistant".into(),
448                content: String::new(),
449                reasoning_content: None,
450                tool_calls: Some(vec![ToolCall {
451                    id: "a".into(),
452                    name: "search".into(),
453                    arguments: r#"{"query":"x"}"#.into(),
454                }]),
455                tool_call_id: None,
456                images: Vec::new(),
457            },
458            ChatMessage {
459                role: "tool".into(),
460                content: "hits-a".into(),
461                reasoning_content: None,
462                tool_calls: None,
463                tool_call_id: Some("a".into()),
464                images: Vec::new(),
465            },
466        ];
467        let seen = seed_tool_result_dedup(&msgs);
468        assert_eq!(
469            seen.get(&("search".to_string(), r#"{"query":"x"}"#.to_string())),
470            Some(&"hits-a".to_string())
471        );
472    }
473
474    #[test]
475    fn chat_message_serializes_string_content_when_no_images() {
476        let m = ChatMessage::text("user", "hi");
477        let v = serde_json::to_value(&m).unwrap();
478        assert_eq!(v["content"], "hi");
479        assert!(v.get("tool_calls").is_none());
480    }
481
482    #[test]
483    fn chat_message_serializes_reasoning_content_when_present() {
484        let mut m = ChatMessage::text("assistant", "");
485        m.reasoning_content = Some("check the tool result".into());
486        let v = serde_json::to_value(&m).unwrap();
487        assert_eq!(v["reasoning_content"], "check the tool result");
488    }
489
490    #[test]
491    fn chat_message_serializes_parts_when_images_present() {
492        let mut m = ChatMessage::text("user", "what is this?");
493        m.images = vec!["data:image/png;base64,AAAA".into()];
494        let v = serde_json::to_value(&m).unwrap();
495        assert_eq!(v["content"][0]["type"], "text");
496        assert_eq!(v["content"][0]["text"], "what is this?");
497        assert_eq!(v["content"][1]["type"], "image_url");
498        assert_eq!(
499            v["content"][1]["image_url"]["url"],
500            "data:image/png;base64,AAAA"
501        );
502    }
503
504    #[test]
505    fn chat_message_with_tool_calls_still_serializes_them() {
506        let m = ChatMessage {
507            role: "assistant".into(),
508            content: String::new(),
509            reasoning_content: None,
510            tool_calls: Some(vec![ToolCall {
511                id: "c1".into(),
512                name: "web_search".into(),
513                arguments: "{}".into(),
514            }]),
515            tool_call_id: None,
516            images: Vec::new(),
517        };
518        let v = serde_json::to_value(&m).unwrap();
519        assert_eq!(v["tool_calls"][0]["function"]["name"], "web_search");
520        assert_eq!(v["tool_calls"][0]["type"], "function");
521    }
522}