harn_vm/stdlib/template/
llm_context.rs1use crate::value::VmDictExt;
26use std::cell::RefCell;
27use std::collections::BTreeMap;
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: crate::llm_config::model_family(provider, model),
55 capabilities,
56 }
57 }
58
59 pub fn to_vm_value(&self) -> VmValue {
62 let mut dict = BTreeMap::new();
63 dict.put_str("provider", self.provider.as_str());
64 dict.put_str("model", self.model.as_str());
65 dict.put_str("family", self.family.as_str());
66 dict.insert("capabilities".to_string(), self.capabilities.clone());
67 VmValue::dict(dict)
68 }
69}
70
71thread_local! {
72 static LLM_RENDER_STACK: RefCell<Vec<LlmRenderContext>> = const { RefCell::new(Vec::new()) };
73}
74
75pub fn push_llm_render_context(ctx: LlmRenderContext) {
79 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().push(ctx));
80}
81
82pub fn pop_llm_render_context() -> Option<LlmRenderContext> {
86 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().pop())
87}
88
89pub fn current_llm_render_context() -> Option<LlmRenderContext> {
93 LLM_RENDER_STACK.with(|stack| stack.borrow().last().cloned())
94}
95
96pub(crate) fn swap_llm_render_stack(next: Vec<LlmRenderContext>) -> Vec<LlmRenderContext> {
102 LLM_RENDER_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
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
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn derive_family(provider: &str, model: &str) -> String {
147 crate::llm_config::model_family(provider, model)
148 }
149
150 #[test]
151 fn family_from_model_id_takes_precedence() {
152 assert_eq!(
153 derive_family("openrouter", "anthropic/claude-3-5-sonnet"),
154 "anthropic-claude"
155 );
156 assert_eq!(derive_family("openrouter", "openai/gpt-4o"), "openai-gpt");
157 assert_eq!(
158 derive_family("openrouter", "google/gemini-1.5-pro"),
159 "google-gemini"
160 );
161 assert_eq!(derive_family("llamacpp", "qwen3.6-35b-a3b"), "qwen");
162 }
163
164 #[test]
165 fn family_falls_back_to_provider_alias() {
166 assert_eq!(
167 derive_family("anthropic", "unknown-future-model"),
168 "anthropic-claude"
169 );
170 assert_eq!(derive_family("azure", "deployment-xyz"), "openai-gpt");
171 assert_eq!(derive_family("vertex", "model-xyz"), "google-gemini");
172 assert_eq!(derive_family("local", "anonymous-snapshot"), "local");
173 assert_eq!(derive_family("", ""), "unknown");
174 }
175
176 #[test]
177 fn push_pop_stack_round_trip() {
178 reset_llm_render_stack();
179 assert!(current_llm_render_context().is_none());
180 push_llm_render_context(LlmRenderContext::resolve("anthropic", "claude-3-5-sonnet"));
181 assert_eq!(
182 current_llm_render_context().map(|c| c.family),
183 Some("anthropic-claude".to_string()),
184 );
185 push_llm_render_context(LlmRenderContext::resolve("openai", "gpt-4o"));
186 assert_eq!(
187 current_llm_render_context().map(|c| c.family),
188 Some("openai-gpt".to_string()),
189 );
190 pop_llm_render_context();
191 assert_eq!(
192 current_llm_render_context().map(|c| c.family),
193 Some("anthropic-claude".to_string()),
194 );
195 pop_llm_render_context();
196 assert!(current_llm_render_context().is_none());
197 }
198
199 #[test]
200 fn guard_pops_on_drop() {
201 reset_llm_render_stack();
202 {
203 let _guard = LlmRenderContextGuard::enter(LlmRenderContext::resolve(
204 "anthropic",
205 "claude-3-5-sonnet",
206 ));
207 assert!(current_llm_render_context().is_some());
208 }
209 assert!(current_llm_render_context().is_none());
210 }
211}