harn_vm/stdlib/template/
llm_context.rs1use crate::value::VmDictExt;
26use std::cell::RefCell;
27use std::collections::BTreeMap;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use crate::value::VmValue;
31
32#[derive(Debug, Clone)]
36pub struct LlmRenderContext {
37 pub provider: String,
38 pub model: String,
39 pub family: String,
40 pub capabilities: VmValue,
43}
44
45impl LlmRenderContext {
46 pub fn resolve(provider: &str, model: &str) -> Self {
49 let caps = crate::llm::capabilities::lookup(provider, model);
50 let capabilities =
51 crate::llm::config_builtins::capabilities_to_vm_value(provider, model, &caps);
52 Self {
53 provider: provider.to_string(),
54 model: model.to_string(),
55 family: crate::llm_config::model_family(provider, model),
56 capabilities,
57 }
58 }
59
60 pub fn to_vm_value(&self) -> VmValue {
63 let mut dict = BTreeMap::new();
64 dict.put_str("provider", self.provider.as_str());
65 dict.put_str("model", self.model.as_str());
66 dict.put_str("family", self.family.as_str());
67 dict.insert("capabilities".to_string(), self.capabilities.clone());
68 VmValue::dict(dict)
69 }
70}
71
72thread_local! {
73 static LLM_RENDER_STACK: RefCell<Vec<LlmRenderContextFrame>> = const { RefCell::new(Vec::new()) };
74}
75
76static NEXT_LLM_RENDER_FRAME_ID: AtomicU64 = AtomicU64::new(1);
77
78#[derive(Debug, Clone)]
82pub(crate) struct LlmRenderContextFrame {
83 id: u64,
84 context: LlmRenderContext,
85}
86
87fn next_frame_id() -> u64 {
88 NEXT_LLM_RENDER_FRAME_ID.fetch_add(1, Ordering::Relaxed)
89}
90
91fn push_llm_render_context_frame(ctx: LlmRenderContext) -> u64 {
92 let id = next_frame_id();
93 LLM_RENDER_STACK.with(|stack| {
94 stack
95 .borrow_mut()
96 .push(LlmRenderContextFrame { id, context: ctx });
97 });
98 id
99}
100
101pub fn push_llm_render_context(ctx: LlmRenderContext) {
105 push_llm_render_context_frame(ctx);
106}
107
108pub fn pop_llm_render_context() -> Option<LlmRenderContext> {
112 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().pop().map(|frame| frame.context))
113}
114
115pub fn current_llm_render_context() -> Option<LlmRenderContext> {
119 LLM_RENDER_STACK.with(|stack| stack.borrow().last().map(|frame| frame.context.clone()))
120}
121
122pub(crate) fn swap_llm_render_stack(
128 next: Vec<LlmRenderContextFrame>,
129) -> Vec<LlmRenderContextFrame> {
130 LLM_RENDER_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
131}
132
133pub(crate) fn reset_llm_render_stack() {
136 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().clear());
137}
138
139pub struct LlmRenderContextGuard {
143 frame_id: u64,
144 expected_depth: usize,
148}
149
150impl LlmRenderContextGuard {
151 pub fn enter(ctx: LlmRenderContext) -> Self {
152 let frame_id = push_llm_render_context_frame(ctx);
153 let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
154 Self {
155 frame_id,
156 expected_depth: depth,
157 }
158 }
159}
160
161impl Drop for LlmRenderContextGuard {
162 fn drop(&mut self) {
163 LLM_RENDER_STACK.with(|stack| {
164 let mut stack = stack.borrow_mut();
165 if stack.last().is_some_and(|frame| frame.id == self.frame_id) {
166 stack.pop();
167 return;
168 }
169
170 if let Some(index) = stack.iter().position(|frame| frame.id == self.frame_id) {
171 debug_assert_eq!(
172 stack.len(),
173 self.expected_depth,
174 "LlmRenderContextGuard nested-drop order violated",
175 );
176 debug_assert_eq!(index + 1, stack.len(), "nested-drop order violated");
177 stack.remove(index);
178 }
179 });
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn derive_family(provider: &str, model: &str) -> String {
188 crate::llm_config::model_family(provider, model)
189 }
190
191 #[test]
192 fn family_from_model_id_takes_precedence() {
193 assert_eq!(
194 derive_family("openrouter", "anthropic/claude-3-5-sonnet"),
195 "anthropic-claude"
196 );
197 assert_eq!(derive_family("openrouter", "openai/gpt-4o"), "openai-gpt");
198 assert_eq!(
199 derive_family("openrouter", "google/gemini-1.5-pro"),
200 "google-gemini"
201 );
202 assert_eq!(derive_family("llamacpp", "qwen3.6-35b-a3b"), "qwen");
203 }
204
205 #[test]
206 fn family_falls_back_to_provider_alias() {
207 assert_eq!(
208 derive_family("anthropic", "unknown-future-model"),
209 "anthropic-claude"
210 );
211 assert_eq!(derive_family("azure", "deployment-xyz"), "openai-gpt");
212 assert_eq!(derive_family("vertex", "model-xyz"), "google-gemini");
213 assert_eq!(derive_family("local", "anonymous-snapshot"), "local");
214 assert_eq!(derive_family("", ""), "unknown");
215 }
216
217 #[test]
218 fn push_pop_stack_round_trip() {
219 reset_llm_render_stack();
220 assert!(current_llm_render_context().is_none());
221 push_llm_render_context(LlmRenderContext::resolve("anthropic", "claude-3-5-sonnet"));
222 assert_eq!(
223 current_llm_render_context().map(|c| c.family),
224 Some("anthropic-claude".to_string()),
225 );
226 push_llm_render_context(LlmRenderContext::resolve("openai", "gpt-4o"));
227 assert_eq!(
228 current_llm_render_context().map(|c| c.family),
229 Some("openai-gpt".to_string()),
230 );
231 pop_llm_render_context();
232 assert_eq!(
233 current_llm_render_context().map(|c| c.family),
234 Some("anthropic-claude".to_string()),
235 );
236 pop_llm_render_context();
237 assert!(current_llm_render_context().is_none());
238 }
239
240 #[test]
241 fn guard_pops_on_drop() {
242 reset_llm_render_stack();
243 {
244 let _guard = LlmRenderContextGuard::enter(LlmRenderContext::resolve(
245 "anthropic",
246 "claude-3-5-sonnet",
247 ));
248 assert!(current_llm_render_context().is_some());
249 }
250 assert!(current_llm_render_context().is_none());
251 }
252}