Skip to main content

meerkat_core/auth/
metadata.rs

1//! Auth metadata types — generic, provider-neutral.
2//!
3//! Per-provider marker structs (`OpenAiRouteHints`, `AnthropicAuthMetadata`,
4//! etc.) are intentionally empty `#[non_exhaustive]` placeholders in Phase 1.
5//! They pin the route-hint shape so the public `AuthRouteHints` enum is
6//! stable, but `meerkat-core` does not own provider-specific semantics —
7//! those land in `meerkat-client` provider runtimes when the fields are
8//! actually needed.
9
10use serde::{Deserialize, Serialize};
11
12/// Generic auth metadata attached to a resolved lease. Provider-specific
13/// metadata goes under [`AuthMetadata::provider_metadata`] / [`AuthMetadata::route_hints`].
14#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub struct AuthMetadata {
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub account_id: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub workspace_id: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub organization_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub user_id: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub plan: Option<String>,
27    #[serde(default)]
28    pub route_hints: AuthRouteHints,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub provider_metadata: Option<ProviderAuthMetadata>,
31}
32
33/// Non-secret defaults merged during auth profile resolution.
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct AuthMetadataDefaults {
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub organization_id: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub workspace_id: Option<String>,
41    #[serde(default)]
42    pub route_hints: AuthRouteHints,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub provider_metadata: Option<ProviderAuthMetadata>,
45}
46
47/// Provider-specific route hints (boxed to keep the enum small).
48#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[serde(tag = "provider", rename_all = "snake_case")]
51pub enum AuthRouteHints {
52    #[default]
53    None,
54    OpenAi(Box<OpenAiRouteHints>),
55    Anthropic(Box<AnthropicRouteHints>),
56    Google(Box<GoogleRouteHints>),
57}
58
59/// Provider-tagged auth metadata. Content is opaque to `meerkat-core`.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62#[serde(tag = "provider", rename_all = "snake_case")]
63pub enum ProviderAuthMetadata {
64    OpenAi(OpenAiAuthMetadata),
65    Anthropic(AnthropicAuthMetadata),
66    Google(GoogleAuthMetadata),
67}
68
69// Per-provider marker structs. Empty + non_exhaustive means `meerkat-core`
70// declares the shape without owning the contents. Provider runtimes fill
71// them in as real fields become necessary.
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[non_exhaustive]
76pub struct OpenAiRouteHints {}
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[non_exhaustive]
81pub struct AnthropicRouteHints {}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85#[non_exhaustive]
86pub struct GoogleRouteHints {}
87
88/// ChatGPT-specific claims lifted from the ID token per Codex
89/// `token_data.rs:71-160`.
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92pub struct OpenAiAuthMetadata {
93    /// `chatgpt_plan_type` — e.g. "free", "plus", "pro", "enterprise".
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub plan_type: Option<String>,
96    /// `chatgpt_user_id`.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub user_id: Option<String>,
99    /// `chatgpt_account_id` — used as the `ChatGPT-Account-ID` wire header.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub account_id: Option<String>,
102    /// `chatgpt_account_is_fedramp` — when true, emit `X-OpenAI-Fedramp`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub is_fedramp: Option<bool>,
105    /// Primary email address on the account.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub email: Option<String>,
108}
109
110/// Anthropic-specific metadata (subscription tier for Claude.ai OAuth,
111/// Bedrock/Vertex/Foundry region/project hints).
112#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct AnthropicAuthMetadata {
115    /// Claude.ai subscription tier ("free" / "pro" / "max" / "team").
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub subscription_tier: Option<String>,
118    /// AWS region for Bedrock.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub aws_region: Option<String>,
121    /// GCP project ID for Vertex.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub vertex_project_id: Option<String>,
124    /// GCP region for Vertex / Vertex model endpoints.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub vertex_region: Option<String>,
127    /// Azure deployment URL prefix for Foundry.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub foundry_deployment: Option<String>,
130}
131
132/// Google-specific metadata (ADC account, Vertex project/region hints).
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135pub struct GoogleAuthMetadata {
136    /// Identifier for the Google account (email address).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub account_email: Option<String>,
139    /// GCP project ID for Vertex / Code Assist.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub project_id: Option<String>,
142    /// Preferred region for Vertex model endpoints.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub region: Option<String>,
145    /// Code Assist user tier ("free" / "standard" / "enterprise").
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub code_assist_tier: Option<String>,
148}
149
150#[cfg(test)]
151#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn auth_metadata_default_is_empty() {
157        let m = AuthMetadata::default();
158        assert!(m.account_id.is_none());
159        assert_eq!(m.route_hints, AuthRouteHints::None);
160        assert!(m.provider_metadata.is_none());
161    }
162
163    #[test]
164    fn route_hints_serde_roundtrip() {
165        for hints in [
166            AuthRouteHints::None,
167            AuthRouteHints::OpenAi(Box::default()),
168            AuthRouteHints::Anthropic(Box::default()),
169            AuthRouteHints::Google(Box::default()),
170        ] {
171            let s = serde_json::to_string(&hints).unwrap();
172            let back: AuthRouteHints = serde_json::from_str(&s).unwrap();
173            assert_eq!(back, hints);
174        }
175    }
176
177    #[test]
178    fn provider_auth_metadata_roundtrip() {
179        let m = ProviderAuthMetadata::OpenAi(OpenAiAuthMetadata::default());
180        let s = serde_json::to_string(&m).unwrap();
181        let back: ProviderAuthMetadata = serde_json::from_str(&s).unwrap();
182        assert_eq!(back, m);
183    }
184}