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