1use std::cell::RefCell;
26use std::collections::BTreeMap;
27use std::rc::Rc;
28
29use crate::value::VmValue;
30
31#[derive(Debug, Clone)]
35pub struct LlmRenderContext {
36 pub provider: String,
37 pub model: String,
38 pub family: String,
39 pub capabilities: VmValue,
42}
43
44impl LlmRenderContext {
45 pub fn resolve(provider: &str, model: &str) -> Self {
48 let caps = crate::llm::capabilities::lookup(provider, model);
49 let capabilities =
50 crate::llm::config_builtins::capabilities_to_vm_value(provider, model, &caps);
51 Self {
52 provider: provider.to_string(),
53 model: model.to_string(),
54 family: derive_family(provider, model),
55 capabilities,
56 }
57 }
58
59 pub fn to_vm_value(&self) -> VmValue {
62 let mut dict = BTreeMap::new();
63 dict.insert(
64 "provider".to_string(),
65 VmValue::String(Rc::from(self.provider.as_str())),
66 );
67 dict.insert(
68 "model".to_string(),
69 VmValue::String(Rc::from(self.model.as_str())),
70 );
71 dict.insert(
72 "family".to_string(),
73 VmValue::String(Rc::from(self.family.as_str())),
74 );
75 dict.insert("capabilities".to_string(), self.capabilities.clone());
76 VmValue::Dict(Rc::new(dict))
77 }
78}
79
80thread_local! {
81 static LLM_RENDER_STACK: RefCell<Vec<LlmRenderContext>> = const { RefCell::new(Vec::new()) };
82}
83
84pub fn push_llm_render_context(ctx: LlmRenderContext) {
88 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().push(ctx));
89}
90
91pub fn pop_llm_render_context() -> Option<LlmRenderContext> {
95 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().pop())
96}
97
98pub fn current_llm_render_context() -> Option<LlmRenderContext> {
102 LLM_RENDER_STACK.with(|stack| stack.borrow().last().cloned())
103}
104
105pub(crate) fn reset_llm_render_stack() {
108 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().clear());
109}
110
111pub struct LlmRenderContextGuard {
115 expected_depth: usize,
119}
120
121impl LlmRenderContextGuard {
122 pub fn enter(ctx: LlmRenderContext) -> Self {
123 push_llm_render_context(ctx);
124 let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
125 Self {
126 expected_depth: depth,
127 }
128 }
129}
130
131impl Drop for LlmRenderContextGuard {
132 fn drop(&mut self) {
133 let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
134 debug_assert_eq!(
135 depth, self.expected_depth,
136 "LlmRenderContextGuard nested-drop order violated",
137 );
138 pop_llm_render_context();
139 }
140}
141
142pub(crate) fn derive_family(provider: &str, model: &str) -> String {
153 let model_lc = model.to_ascii_lowercase();
154 const MARKERS: &[(&str, &[&str])] = &[
157 ("claude", &["claude"]),
158 ("gpt", &["gpt-", "gpt_", "o1-", "o3-", "o4-"]),
159 ("gemini", &["gemini"]),
160 ("qwen", &["qwen"]),
161 ("llama", &["llama"]),
162 ("mistral", &["mistral", "mixtral"]),
163 ("deepseek", &["deepseek"]),
164 ("phi", &["phi-", "phi_"]),
165 ("grok", &["grok"]),
166 ("command", &["command-", "command_"]),
167 ];
168 for (family, needles) in MARKERS {
169 if needles.iter().any(|needle| model_lc.contains(needle)) {
170 return (*family).to_string();
171 }
172 }
173 match provider {
177 "anthropic" | "bedrock" | "vertex-anthropic" => "claude".to_string(),
178 "openai" | "azure" => "gpt".to_string(),
179 "gemini" | "vertex" | "google" => "gemini".to_string(),
180 other if !other.is_empty() => other.to_string(),
181 _ => "unknown".to_string(),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn family_from_model_id_takes_precedence() {
191 assert_eq!(
192 derive_family("openrouter", "anthropic/claude-3-5-sonnet"),
193 "claude"
194 );
195 assert_eq!(derive_family("openrouter", "openai/gpt-4o"), "gpt");
196 assert_eq!(
197 derive_family("openrouter", "google/gemini-1.5-pro"),
198 "gemini"
199 );
200 assert_eq!(
201 derive_family("ollama", "qwen3.6:35b-a3b-coding-nvfp4"),
202 "qwen"
203 );
204 }
205
206 #[test]
207 fn family_falls_back_to_provider_alias() {
208 assert_eq!(derive_family("anthropic", "unknown-future-model"), "claude");
209 assert_eq!(derive_family("azure", "deployment-xyz"), "gpt");
210 assert_eq!(derive_family("vertex", "model-xyz"), "gemini");
211 assert_eq!(derive_family("local", "anonymous-snapshot"), "local");
212 assert_eq!(derive_family("", ""), "unknown");
213 }
214
215 #[test]
216 fn push_pop_stack_round_trip() {
217 reset_llm_render_stack();
218 assert!(current_llm_render_context().is_none());
219 push_llm_render_context(LlmRenderContext::resolve("anthropic", "claude-3-5-sonnet"));
220 assert_eq!(
221 current_llm_render_context().map(|c| c.family),
222 Some("claude".to_string()),
223 );
224 push_llm_render_context(LlmRenderContext::resolve("openai", "gpt-4o"));
225 assert_eq!(
226 current_llm_render_context().map(|c| c.family),
227 Some("gpt".to_string()),
228 );
229 pop_llm_render_context();
230 assert_eq!(
231 current_llm_render_context().map(|c| c.family),
232 Some("claude".to_string()),
233 );
234 pop_llm_render_context();
235 assert!(current_llm_render_context().is_none());
236 }
237
238 #[test]
239 fn guard_pops_on_drop() {
240 reset_llm_render_stack();
241 {
242 let _guard = LlmRenderContextGuard::enter(LlmRenderContext::resolve(
243 "anthropic",
244 "claude-3-5-sonnet",
245 ));
246 assert!(current_llm_render_context().is_some());
247 }
248 assert!(current_llm_render_context().is_none());
249 }
250}