greentic_ext_runtime/host_ports.rs
1//! Narrow ports the runtime depends on for host-side capabilities.
2//!
3//! `greentic-ext-runtime` defines these traits locally so it stays free of
4//! the (large) `greentic-i18n` / `greentic-secrets` dependency trees. The
5//! designer crate wires production adapters; tests use the in-crate fakes.
6
7use std::collections::HashMap;
8use std::sync::Mutex;
9
10use thiserror::Error;
11
12/// Look up i18n keys against a locale catalog set up by the host.
13///
14/// `t` returns the rendered string for `key` (or the key itself if no
15/// translation is available — the runtime never panics on missing keys).
16/// `tf` performs simple `{name}` substitution against `args`.
17pub trait Translator: Send + Sync + 'static {
18 fn t(&self, key: &str) -> String;
19 fn tf(&self, key: &str, args: &[(&str, &str)]) -> String;
20}
21
22/// A default no-op translator. Returns each key verbatim. Used when the
23/// designer is built without an i18n catalog and as a safe baseline in
24/// tests that don't care about i18n behaviour.
25#[derive(Debug, Default, Clone, Copy)]
26pub struct KeyTranslator;
27
28impl Translator for KeyTranslator {
29 fn t(&self, key: &str) -> String {
30 key.to_string()
31 }
32 fn tf(&self, key: &str, _args: &[(&str, &str)]) -> String {
33 key.to_string()
34 }
35}
36
37/// Errors the runtime surfaces when a secret lookup fails.
38#[derive(Debug, Error)]
39pub enum SecretsError {
40 #[error("secret not found: {0}")]
41 NotFound(String),
42 #[error("backend error: {0}")]
43 Backend(String),
44}
45
46/// Narrow secrets port used by the `host.secrets.get` WIT host fn.
47///
48/// `key` is the raw URI the extension passed (e.g. `"api.openai.com/api_key"`
49/// or `"secrets://team/openai/key"`). Permission gating happens in
50/// `HostState::secrets::get` BEFORE this trait is called.
51pub trait SecretsBackend: Send + Sync + 'static {
52 fn get(&self, key: &str) -> Result<String, SecretsError>;
53}
54
55/// In-memory `SecretsBackend` used by tests and the designer's
56/// `--dev-secrets-inline` mode. Thread-safe.
57#[derive(Default)]
58pub struct InMemorySecrets {
59 map: Mutex<HashMap<String, String>>,
60}
61
62impl InMemorySecrets {
63 #[must_use]
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn insert(&mut self, key: &str, value: &str) {
69 let mut g = self
70 .map
71 .lock()
72 .unwrap_or_else(std::sync::PoisonError::into_inner);
73 g.insert(key.to_string(), value.to_string());
74 }
75}
76
77impl SecretsBackend for InMemorySecrets {
78 fn get(&self, key: &str) -> Result<String, SecretsError> {
79 let g = self
80 .map
81 .lock()
82 .unwrap_or_else(std::sync::PoisonError::into_inner);
83 g.get(key)
84 .cloned()
85 .ok_or_else(|| SecretsError::NotFound(key.to_string()))
86 }
87}
88
89/// Chat-completion request forwarded to the host. Credential-free by design:
90/// the host resolves provider/model/key from the resolved `role`.
91#[derive(Debug, Clone)]
92pub struct LlmPortRequest {
93 pub system_prompt: String,
94 /// (role, content) pairs; role is "system" | "user" | "assistant".
95 pub messages: Vec<(String, String)>,
96 pub response_format: LlmPortResponseFormat,
97}
98
99/// Desired shape of the completion output. `Text` is the default; `Json`
100/// requests a free-form JSON object; `JsonSchema` carries a serialized JSON
101/// Schema the host should constrain the model to.
102#[derive(Debug, Clone, Default)]
103pub enum LlmPortResponseFormat {
104 #[default]
105 Text,
106 Json,
107 JsonSchema(String),
108}
109
110/// Successful completion result returned by the host.
111#[derive(Debug, Clone)]
112pub struct LlmPortResponse {
113 pub content: String,
114 pub total_tokens: Option<u32>,
115}
116
117/// Errors the runtime surfaces when an LLM completion fails. Mirrors
118/// [`SecretsError`]'s plain-enum + `thiserror` style so `host_state` can
119/// stringify the failure for the WIT `result<_, string>` boundary.
120#[derive(Debug, Error)]
121pub enum LlmPortError {
122 /// The resolved role is not assigned to the extension (or the host has
123 /// no mapping for it). Carries the offending role name.
124 #[error("llm role unassigned: {0}")]
125 RoleUnassigned(String),
126 /// The host LLM backend failed (network, provider, quota, etc.).
127 #[error("backend error: {0}")]
128 Backend(String),
129}
130
131/// Host port for LLM completions, implemented by the embedding host
132/// (designer maps it onto its per-tenant `llm_for(role, identity)` seam).
133/// Synchronous on purpose: wasmtime host fns are wired with the sync linker.
134pub trait LlmPort: Send + Sync {
135 /// Resolve and run a completion for `extension_id` against `role`.
136 ///
137 /// `ctx` is the per-call [`HostCallContext`] threaded from the embedding
138 /// host: it carries the caller's tenant slug and the authenticated end
139 /// user's email. The designer uses `ctx.tenant` to resolve the role
140 /// per-tenant (`llm_for(role, identity)`, strict, no fallback) and
141 /// `ctx.user_email` to satisfy the admin's per-user identity check
142 /// (`X-Greentic-User`) — without it the admin's service-key auth rejects
143 /// the call with 403.
144 fn complete(
145 &self,
146 extension_id: &str,
147 ctx: &HostCallContext,
148 role: &str,
149 request: LlmPortRequest,
150 ) -> Result<LlmPortResponse, LlmPortError>;
151}
152
153/// Per-invocation caller context threaded from the embedding host into
154/// host-port calls. Extend cautiously: every field is visible to all ports.
155#[derive(Debug, Clone, Default)]
156pub struct HostCallContext {
157 /// Tenant slug of the end caller (multi-tenant hosts); None for
158 /// single-tenant/dev.
159 pub tenant: Option<String>,
160 /// Email of the authenticated end user on whose behalf the call runs.
161 /// Hosts that validate per-user identity (e.g. the designer-admin) require
162 /// it.
163 pub user_email: Option<String>,
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn key_translator_returns_key_for_t() {
172 let t = KeyTranslator;
173 assert_eq!(t.t("greentic.test.hello"), "greentic.test.hello");
174 }
175
176 #[test]
177 fn key_translator_substitutes_args_for_tf() {
178 let t = KeyTranslator;
179 let out = t.tf("greentic.test.hello.{}", &[("name", "Bima")]);
180 assert_eq!(out, "greentic.test.hello.{}");
181 }
182
183 #[test]
184 fn in_memory_secrets_returns_value_when_present() {
185 let mut s = InMemorySecrets::default();
186 s.insert("api.openai.com/api_key", "sk-test");
187 let v = s.get("api.openai.com/api_key").unwrap();
188 assert_eq!(v, "sk-test");
189 }
190
191 #[test]
192 fn in_memory_secrets_returns_not_found_when_absent() {
193 let s = InMemorySecrets::default();
194 let err = s.get("api.openai.com/api_key").unwrap_err();
195 assert!(matches!(err, SecretsError::NotFound(_)));
196 }
197}