Skip to main content

alien_core/bindings/
ai.rs

1//! AI Gateway binding definitions for managed AI inference across cloud providers.
2
3use super::BindingValue;
4use serde::{Deserialize, Serialize};
5
6/// Represents an AI Gateway binding for managed inference across cloud providers.
7///
8/// The managed variants (Bedrock/Vertex/Foundry) carry only identifiers and
9/// endpoints; authentication uses the workload's ambient cloud identity. The
10/// `External` (BYO-key) variant deliberately carries a vault-resolved API key.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
14#[serde(tag = "service", rename_all = "lowercase")]
15pub enum AiBinding {
16    /// AWS Bedrock AI binding
17    Bedrock(BedrockAiBinding),
18    /// GCP Vertex AI binding
19    Vertex(VertexAiBinding),
20    /// Azure AI Foundry binding
21    Foundry(FoundryAiBinding),
22    /// External provider binding (generic endpoint-based). The tag must stay
23    /// unique across every resource type's binding enum — a bare "external"
24    /// collides with the external Postgres binding in the shared
25    /// `ALIEN_*_BINDING` namespace.
26    #[serde(rename = "external-ai")]
27    External(ExternalAiBinding),
28}
29
30/// AWS Bedrock AI binding configuration
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
34#[serde(rename_all = "camelCase")]
35pub struct BedrockAiBinding {
36    /// The AWS region where Bedrock is accessed
37    pub region: String,
38}
39
40/// GCP Vertex AI binding configuration
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
44#[serde(rename_all = "camelCase")]
45pub struct VertexAiBinding {
46    /// The GCP project ID
47    pub project: String,
48    /// The Vertex AI region (e.g., "us-central1")
49    pub location: String,
50}
51
52/// Azure AI Foundry binding configuration
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "camelCase")]
57pub struct FoundryAiBinding {
58    /// The Foundry deployment endpoint URL
59    pub endpoint: String,
60    /// The Azure account or subscription identifier
61    pub account: String,
62}
63
64/// External AI provider binding configuration (BYO-key).
65///
66/// The operator-supplied secret rides inside the binding via
67/// `BindingValue<String>`, so it is a literal on cloud platforms and gains
68/// Kubernetes SecretRef resolution for free (`extract_binding_secrets` walks
69/// the binding JSON for `secretRef`).
70// No derived `Debug` — an inline `api_key` would print cleartext; see the redacting impl below.
71#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
74#[serde(rename_all = "camelCase")]
75pub struct ExternalAiBinding {
76    /// The external AI provider name (e.g., "openai", "anthropic")
77    pub provider: String,
78    /// The provider API key. Resolved to plaintext in the worker environment
79    /// (literal on cloud, Kubernetes Secret on K8s) so the SDK reads it directly.
80    pub api_key: BindingValue<String>,
81}
82
83// Redacts the inline key and keeps every other field, mirroring the external
84// Postgres binding's redacting impl.
85impl std::fmt::Debug for ExternalAiBinding {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("ExternalAiBinding")
88            .field("provider", &self.provider)
89            .field("api_key", &"<redacted>")
90            .finish()
91    }
92}
93
94impl AiBinding {
95    /// The env var a developer sets to bring their own provider key on the Local platform.
96    /// Shared by the Local controller (provision-time check) and the local bindings
97    /// resolver (runtime-only re-resolution), so the two never drift.
98    pub const LOCAL_API_KEY_ENV: &'static str = "OPENAI_API_KEY";
99    /// The BYO-key provider assumed on the Local platform.
100    pub const LOCAL_DEFAULT_PROVIDER: &'static str = "openai";
101
102    pub fn bedrock(region: impl Into<String>) -> Self {
103        Self::Bedrock(BedrockAiBinding {
104            region: region.into(),
105        })
106    }
107
108    pub fn vertex(project: impl Into<String>, location: impl Into<String>) -> Self {
109        Self::Vertex(VertexAiBinding {
110            project: project.into(),
111            location: location.into(),
112        })
113    }
114
115    pub fn foundry(endpoint: impl Into<String>, account: impl Into<String>) -> Self {
116        Self::Foundry(FoundryAiBinding {
117            endpoint: endpoint.into(),
118            account: account.into(),
119        })
120    }
121
122    pub fn external(
123        provider: impl Into<String>,
124        api_key: impl Into<BindingValue<String>>,
125    ) -> Self {
126        Self::External(ExternalAiBinding {
127            provider: provider.into(),
128            api_key: api_key.into(),
129        })
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_bedrock_binding_roundtrip() {
139        let binding = AiBinding::bedrock("us-east-1");
140
141        let json = serde_json::to_string(&binding).unwrap();
142        assert!(json.contains(r#""service":"bedrock""#));
143
144        let deserialized: AiBinding = serde_json::from_str(&json).unwrap();
145        assert_eq!(binding, deserialized);
146    }
147
148    #[test]
149    fn test_vertex_binding_roundtrip() {
150        let binding = AiBinding::vertex("my-project", "us-central1");
151
152        let json = serde_json::to_string(&binding).unwrap();
153        assert!(json.contains(r#""service":"vertex""#));
154
155        let deserialized: AiBinding = serde_json::from_str(&json).unwrap();
156        assert_eq!(binding, deserialized);
157    }
158
159    #[test]
160    fn test_foundry_binding_roundtrip() {
161        let binding = AiBinding::foundry("https://my-foundry.openai.azure.com", "my-subscription");
162
163        let json = serde_json::to_value(&binding).unwrap();
164        let json_str = json.to_string();
165        assert!(json_str.contains(r#""service":"foundry""#));
166        assert!(
167            !json_str.contains("instantAccess"),
168            "foundry binding must not serialize instant_access"
169        );
170
171        let deserialized: AiBinding = serde_json::from_value(json).unwrap();
172        assert_eq!(binding, deserialized);
173    }
174
175    #[test]
176    fn test_external_binding_roundtrip() {
177        let binding = AiBinding::external("openai", "sk-test-key");
178
179        // The injected env-var JSON must match exactly what the SDK's
180        // `ai(name)` parser expects: service-tagged, camelCase, key inline.
181        let json = serde_json::to_value(&binding).unwrap();
182        let json_str = json.to_string();
183        assert!(json_str.contains(r#""apiKey""#), "external binding must serialize apiKey in camelCase");
184        assert_eq!(
185            json,
186            serde_json::json!({
187                "service": "external-ai",
188                "provider": "openai",
189                "apiKey": "sk-test-key",
190            })
191        );
192
193        let deserialized: AiBinding =
194            serde_json::from_value(json).expect("external binding should round-trip");
195        assert_eq!(binding, deserialized);
196    }
197}