harn_vm/stdlib/template/
llm_context.rs1use std::cell::RefCell;
26use std::collections::BTreeMap;
27
28use crate::value::VmValue;
29
30#[derive(Debug, Clone)]
34pub struct LlmRenderContext {
35 pub provider: String,
36 pub model: String,
37 pub family: String,
38 pub capabilities: VmValue,
41}
42
43impl LlmRenderContext {
44 pub fn resolve(provider: &str, model: &str) -> Self {
47 let caps = crate::llm::capabilities::lookup(provider, model);
48 let capabilities =
49 crate::llm::config_builtins::capabilities_to_vm_value(provider, model, &caps);
50 Self {
51 provider: provider.to_string(),
52 model: model.to_string(),
53 family: crate::llm_config::model_family(provider, model),
54 capabilities,
55 }
56 }
57
58 pub fn to_vm_value(&self) -> VmValue {
61 let mut dict = BTreeMap::new();
62 dict.insert(
63 "provider".to_string(),
64 VmValue::String(std::sync::Arc::from(self.provider.as_str())),
65 );
66 dict.insert(
67 "model".to_string(),
68 VmValue::String(std::sync::Arc::from(self.model.as_str())),
69 );
70 dict.insert(
71 "family".to_string(),
72 VmValue::String(std::sync::Arc::from(self.family.as_str())),
73 );
74 dict.insert("capabilities".to_string(), self.capabilities.clone());
75 VmValue::Dict(std::sync::Arc::new(dict))
76 }
77}
78
79thread_local! {
80 static LLM_RENDER_STACK: RefCell<Vec<LlmRenderContext>> = const { RefCell::new(Vec::new()) };
81}
82
83pub fn push_llm_render_context(ctx: LlmRenderContext) {
87 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().push(ctx));
88}
89
90pub fn pop_llm_render_context() -> Option<LlmRenderContext> {
94 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().pop())
95}
96
97pub fn current_llm_render_context() -> Option<LlmRenderContext> {
101 LLM_RENDER_STACK.with(|stack| stack.borrow().last().cloned())
102}
103
104pub(crate) fn reset_llm_render_stack() {
107 LLM_RENDER_STACK.with(|stack| stack.borrow_mut().clear());
108}
109
110pub struct LlmRenderContextGuard {
114 expected_depth: usize,
118}
119
120impl LlmRenderContextGuard {
121 pub fn enter(ctx: LlmRenderContext) -> Self {
122 push_llm_render_context(ctx);
123 let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
124 Self {
125 expected_depth: depth,
126 }
127 }
128}
129
130impl Drop for LlmRenderContextGuard {
131 fn drop(&mut self) {
132 let depth = LLM_RENDER_STACK.with(|stack| stack.borrow().len());
133 debug_assert_eq!(
134 depth, self.expected_depth,
135 "LlmRenderContextGuard nested-drop order violated",
136 );
137 pop_llm_render_context();
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn derive_family(provider: &str, model: &str) -> String {
146 crate::llm_config::model_family(provider, model)
147 }
148
149 #[test]
150 fn family_from_model_id_takes_precedence() {
151 assert_eq!(
152 derive_family("openrouter", "anthropic/claude-3-5-sonnet"),
153 "anthropic-claude"
154 );
155 assert_eq!(derive_family("openrouter", "openai/gpt-4o"), "openai-gpt");
156 assert_eq!(
157 derive_family("openrouter", "google/gemini-1.5-pro"),
158 "google-gemini"
159 );
160 assert_eq!(
161 derive_family("ollama", "qwen3.6:35b-a3b-coding-nvfp4"),
162 "qwen"
163 );
164 }
165
166 #[test]
167 fn family_falls_back_to_provider_alias() {
168 assert_eq!(
169 derive_family("anthropic", "unknown-future-model"),
170 "anthropic-claude"
171 );
172 assert_eq!(derive_family("azure", "deployment-xyz"), "openai-gpt");
173 assert_eq!(derive_family("vertex", "model-xyz"), "google-gemini");
174 assert_eq!(derive_family("local", "anonymous-snapshot"), "local");
175 assert_eq!(derive_family("", ""), "unknown");
176 }
177
178 #[test]
179 fn push_pop_stack_round_trip() {
180 reset_llm_render_stack();
181 assert!(current_llm_render_context().is_none());
182 push_llm_render_context(LlmRenderContext::resolve("anthropic", "claude-3-5-sonnet"));
183 assert_eq!(
184 current_llm_render_context().map(|c| c.family),
185 Some("anthropic-claude".to_string()),
186 );
187 push_llm_render_context(LlmRenderContext::resolve("openai", "gpt-4o"));
188 assert_eq!(
189 current_llm_render_context().map(|c| c.family),
190 Some("openai-gpt".to_string()),
191 );
192 pop_llm_render_context();
193 assert_eq!(
194 current_llm_render_context().map(|c| c.family),
195 Some("anthropic-claude".to_string()),
196 );
197 pop_llm_render_context();
198 assert!(current_llm_render_context().is_none());
199 }
200
201 #[test]
202 fn guard_pops_on_drop() {
203 reset_llm_render_stack();
204 {
205 let _guard = LlmRenderContextGuard::enter(LlmRenderContext::resolve(
206 "anthropic",
207 "claude-3-5-sonnet",
208 ));
209 assert!(current_llm_render_context().is_some());
210 }
211 assert!(current_llm_render_context().is_none());
212 }
213}