Skip to main content

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}