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(provider: impl Into<String>, api_key: impl Into<BindingValue<String>>) -> Self {
123        Self::External(ExternalAiBinding {
124            provider: provider.into(),
125            api_key: api_key.into(),
126        })
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_bedrock_binding_roundtrip() {
136        let binding = AiBinding::bedrock("us-east-1");
137
138        let json = serde_json::to_string(&binding).unwrap();
139        assert!(json.contains(r#""service":"bedrock""#));
140
141        let deserialized: AiBinding = serde_json::from_str(&json).unwrap();
142        assert_eq!(binding, deserialized);
143    }
144
145    #[test]
146    fn test_vertex_binding_roundtrip() {
147        let binding = AiBinding::vertex("my-project", "us-central1");
148
149        let json = serde_json::to_string(&binding).unwrap();
150        assert!(json.contains(r#""service":"vertex""#));
151
152        let deserialized: AiBinding = serde_json::from_str(&json).unwrap();
153        assert_eq!(binding, deserialized);
154    }
155
156    #[test]
157    fn test_foundry_binding_roundtrip() {
158        let binding = AiBinding::foundry("https://my-foundry.openai.azure.com", "my-subscription");
159
160        let json = serde_json::to_value(&binding).unwrap();
161        let json_str = json.to_string();
162        assert!(json_str.contains(r#""service":"foundry""#));
163        assert!(
164            !json_str.contains("instantAccess"),
165            "foundry binding must not serialize instant_access"
166        );
167
168        let deserialized: AiBinding = serde_json::from_value(json).unwrap();
169        assert_eq!(binding, deserialized);
170    }
171
172    #[test]
173    fn test_external_binding_roundtrip() {
174        let binding = AiBinding::external("openai", "sk-test-key");
175
176        // The injected env-var JSON must match exactly what the SDK's
177        // `ai(name)` parser expects: service-tagged, camelCase, key inline.
178        let json = serde_json::to_value(&binding).unwrap();
179        let json_str = json.to_string();
180        assert!(
181            json_str.contains(r#""apiKey""#),
182            "external binding must serialize apiKey in camelCase"
183        );
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}