Skip to main content

wiremux_auth/profile/
types.rs

1//! Profile AST (data only). No IR.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::error::ProfileError;
10
11/// Highest `schema_version` this crate understands.
12pub const SCHEMA_VERSION_MAX: u32 = 1;
13
14/// Catalog load inputs. Same-id layers merge field-wise
15/// (shipped < user dir < explicit file).
16#[derive(Debug, Clone)]
17pub struct LoadOptions<'a> {
18    /// Optional id hint (required unless an explicit file supplies `id`).
19    pub id: Option<&'a str>,
20    /// Extra file to include, keyed by its document `id`.
21    pub explicit_file: Option<&'a Path>,
22    /// Extra directories of profile files (`WIREMUX_PROFILE_DIR`).
23    pub extra_profile_dirs: Vec<PathBuf>,
24    /// When false, skip crate-shipped presets.
25    pub include_shipped: bool,
26    /// When false, skip XDG/HOME user dirs and `WIREMUX_PROFILE_DIR`.
27    pub include_user_config: bool,
28}
29
30impl Default for LoadOptions<'_> {
31    fn default() -> Self {
32        Self {
33            id: None,
34            explicit_file: None,
35            extra_profile_dirs: Vec::new(),
36            include_shipped: true,
37            include_user_config: true,
38        }
39    }
40}
41
42/// Fully resolved profile document (one `id`).
43#[derive(Debug, Clone, PartialEq)]
44pub struct ResolvedProfile {
45    /// Schema version after parse (`<= SCHEMA_VERSION_MAX`).
46    pub schema_version: u32,
47    /// Stable catalog key.
48    pub id: String,
49    /// Optional human label.
50    pub display_name: Option<String>,
51    /// Dialect table (root-level fields in the file).
52    pub dialect: Dialect,
53    /// HTTP table (root-level fields in the file).
54    pub http: Http,
55    /// Optional OAuth pack.
56    pub oauth: Option<OauthPack>,
57    /// Env names for a static key when `[oauth]` is absent. First non-empty wins.
58    pub access_env: Vec<String>,
59    /// Optional fingerprint table.
60    pub fingerprint: Option<Fingerprint>,
61    /// Beta header list and merge policy.
62    pub betas: Betas,
63}
64
65/// Dialect selection and stream/tool policies.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Dialect {
68    /// `chat-completions` | `messages` | `responses` | `gemini` | `converse`.
69    pub wire: Option<Wire>,
70    /// Recognized SSE event names (built-in list when omitted).
71    pub stream_events: Vec<String>,
72    /// Merge policy for `stream_events` (and `oauth.scopes` when set there).
73    pub list_merge: ListMerge,
74    /// How to treat namespaced / hosted tool types.
75    pub tool_type_policy: ToolTypePolicy,
76    /// How to treat unrecognized stream events.
77    pub stream_unknown_policy: StreamUnknownPolicy,
78}
79
80/// v1 wire dialects. Not a host registry name.
81///
82/// New dialects may be added in a minor release. Hosts must include a
83/// `_` arm so an additive variant is not a compile break:
84///
85/// ```
86/// use wiremux_auth::Wire;
87///
88/// fn host_label(wire: Wire) -> &'static str {
89///     match wire {
90///         Wire::ChatCompletions => "chat-completions",
91///         Wire::Messages => "messages",
92///         Wire::Responses => "responses",
93///         Wire::Gemini => "gemini",
94///         Wire::Converse => "converse",
95///         _ => "unknown",
96///     }
97/// }
98/// assert_eq!(host_label(Wire::Messages), "messages");
99/// ```
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
101#[non_exhaustive]
102#[serde(rename_all = "kebab-case")]
103pub enum Wire {
104    /// OpenAI Chat Completions.
105    ChatCompletions,
106    /// Anthropic Messages.
107    Messages,
108    /// OpenAI Responses.
109    Responses,
110    /// Google Gemini generateContent.
111    Gemini,
112    /// Amazon Bedrock Converse.
113    Converse,
114}
115
116impl Wire {
117    /// Catalog spellings. CLI `--from` also accepts `chat` for `chat-completions`.
118    pub const NAMES: &'static [&'static str] = &[
119        "chat-completions",
120        "messages",
121        "responses",
122        "gemini",
123        "converse",
124    ];
125
126    /// Catalog / file spelling (`messages`, `chat-completions`, `responses`, `gemini`, `converse`).
127    #[must_use]
128    pub fn as_str(self) -> &'static str {
129        match self {
130            Self::ChatCompletions => "chat-completions",
131            Self::Messages => "messages",
132            Self::Responses => "responses",
133            Self::Gemini => "gemini",
134            Self::Converse => "converse",
135        }
136    }
137
138    pub(crate) fn parse(s: &str) -> Result<Self, String> {
139        match s {
140            "chat-completions" => Ok(Self::ChatCompletions),
141            "messages" => Ok(Self::Messages),
142            "responses" => Ok(Self::Responses),
143            "gemini" => Ok(Self::Gemini),
144            "converse" => Ok(Self::Converse),
145            other => Err(unknown_kebab("wire", other, Self::NAMES)),
146        }
147    }
148
149    /// Close match after folding case and `_`/`-`.
150    #[must_use]
151    pub fn suggest(s: &str) -> Option<&'static str> {
152        suggest_kebab(s, Self::NAMES)
153    }
154
155    /// Built-in SSE event names when `stream_events` is omitted.
156    #[must_use]
157    pub fn default_stream_events(self) -> &'static [&'static str] {
158        match self {
159            Self::ChatCompletions => &["chunk", "[DONE]"],
160            Self::Messages => &[
161                "message_start",
162                "content_block_start",
163                "content_block_delta",
164                "content_block_stop",
165                "message_delta",
166                "message_stop",
167                "ping",
168                "error",
169            ],
170            Self::Responses => &[
171                "response.created",
172                "response.in_progress",
173                "response.output_item.added",
174                "response.content_part.added",
175                "response.content_part.done",
176                "response.output_text.delta",
177                "response.output_text.done",
178                "response.reasoning_summary_part.added",
179                "response.reasoning_summary_part.done",
180                "response.reasoning_summary_text.delta",
181                "response.reasoning_summary_text.done",
182                "response.reasoning.delta",
183                "response.reasoning_text.delta",
184                "response.reasoning_text.done",
185                "response.refusal.delta",
186                "response.refusal.done",
187                "response.output_text.annotation.added",
188                "response.audio.delta",
189                "response.audio.done",
190                "response.audio.transcript.delta",
191                "response.audio.transcript.done",
192                "response.function_call_arguments.delta",
193                "response.function_call_arguments.done",
194                "response.custom_tool_call_input.delta",
195                "response.custom_tool_call_input.done",
196                "response.output_item.done",
197                "response.completed",
198                "response.failed",
199                "response.incomplete",
200                "error",
201                "response.web_search_call.in_progress",
202                "response.web_search_call.searching",
203                "response.web_search_call.completed",
204                "response.file_search_call.in_progress",
205                "response.file_search_call.searching",
206                "response.file_search_call.completed",
207                "response.code_interpreter_call.in_progress",
208                "response.code_interpreter_call.interpreting",
209                "response.code_interpreter_call.completed",
210                "response.image_generation_call.in_progress",
211                "response.image_generation_call.generating",
212                "response.image_generation_call.completed",
213                "response.mcp_call.in_progress",
214                "response.mcp_call.completed",
215                "response.mcp_call.failed",
216                "response.mcp_list_tools.in_progress",
217                "response.mcp_list_tools.completed",
218                "response.mcp_list_tools.failed",
219            ],
220            Self::Gemini => &["chunk"],
221            Self::Converse => &[
222                "messageStart",
223                "contentBlockStart",
224                "contentBlockDelta",
225                "contentBlockStop",
226                "messageStop",
227                "metadata",
228            ],
229        }
230    }
231
232    /// Dialect default chat path.
233    #[must_use]
234    pub fn default_chat_path(self) -> &'static str {
235        match self {
236            Self::ChatCompletions => "/v1/chat/completions",
237            Self::Messages => "/v1/messages",
238            Self::Responses => "/v1/responses",
239            Self::Gemini => "/v1beta/models/{model}:generateContent",
240            Self::Converse => "/model/{model}/converse",
241        }
242    }
243
244    /// Dialect default auth scheme.
245    #[must_use]
246    pub fn default_auth_scheme(self) -> AuthScheme {
247        match self {
248            Self::Messages => AuthScheme::XApiKey,
249            Self::Gemini => AuthScheme::Header("x-goog-api-key".into()),
250            Self::ChatCompletions | Self::Responses | Self::Converse => AuthScheme::Bearer,
251        }
252    }
253}
254
255impl<'de> Deserialize<'de> for Wire {
256    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
257        deserialize_kebab(deserializer, Self::parse)
258    }
259}
260
261/// List merge policy. `verbatim` is an alias of `replace`.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
263#[serde(rename_all = "kebab-case")]
264pub enum ListMerge {
265    /// Later items overlay; earlier unique values remain.
266    #[default]
267    Union,
268    /// Later list or map is the entire set.
269    Replace,
270}
271
272impl ListMerge {
273    pub(crate) fn parse(s: &str) -> Result<Self, String> {
274        match s {
275            "union" => Ok(Self::Union),
276            "replace" | "verbatim" => Ok(Self::Replace),
277            other => Err(unknown_kebab(
278                "list_merge",
279                other,
280                &["union", "replace", "verbatim"],
281            )),
282        }
283    }
284}
285
286impl<'de> Deserialize<'de> for ListMerge {
287    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
288        deserialize_kebab(deserializer, Self::parse)
289    }
290}
291
292/// How encode treats unknown / namespaced tool types.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum ToolTypePolicy {
296    /// Forward the tool type as-is.
297    Passthrough,
298    /// Flatten `namespace.tool` into the target dialect.
299    FlattenNamespace,
300    /// Fail the map.
301    #[default]
302    HardError,
303}
304
305impl ToolTypePolicy {
306    pub(crate) fn parse(s: &str) -> Result<Self, String> {
307        match s {
308            "passthrough" => Ok(Self::Passthrough),
309            "flatten-namespace" => Ok(Self::FlattenNamespace),
310            "hard-error" => Ok(Self::HardError),
311            other => Err(unknown_kebab(
312                "tool_type_policy",
313                other,
314                &["passthrough", "flatten-namespace", "hard-error"],
315            )),
316        }
317    }
318}
319
320impl<'de> Deserialize<'de> for ToolTypePolicy {
321    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
322        deserialize_kebab(deserializer, Self::parse)
323    }
324}
325
326/// How decode treats unrecognized SSE events.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
328#[serde(rename_all = "kebab-case")]
329pub enum StreamUnknownPolicy {
330    /// Fail the map.
331    #[default]
332    HardError,
333    /// Forward the raw SSE frame.
334    Passthrough,
335}
336
337impl StreamUnknownPolicy {
338    pub(crate) fn parse(s: &str) -> Result<Self, String> {
339        match s {
340            "hard-error" => Ok(Self::HardError),
341            "passthrough" => Ok(Self::Passthrough),
342            other => Err(unknown_kebab(
343                "stream_unknown_policy",
344                other,
345                &["hard-error", "passthrough"],
346            )),
347        }
348    }
349}
350
351impl<'de> Deserialize<'de> for StreamUnknownPolicy {
352    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
353        deserialize_kebab(deserializer, Self::parse)
354    }
355}
356
357/// HTTP request surface (API calls, not the token POST).
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct Http {
360    /// `https` or loopback `http`.
361    pub base_url: Option<String>,
362    /// Request path, or an absolute URL.
363    pub chat_path: Option<String>,
364    /// How the access token is sent.
365    pub auth_scheme: Option<AuthScheme>,
366    /// Extra API headers (after env subst).
367    pub headers: BTreeMap<String, String>,
368    /// Merge policy for `headers`.
369    pub header_merge: ListMerge,
370    /// When set (`bedrock`), the HTTP client SigV4-signs the POST.
371    pub aws_service: Option<String>,
372    /// Region for SigV4 (`us-east-1`). `{env:AWS_REGION}` is allowed.
373    pub aws_region: Option<String>,
374    /// Env that holds a GCP service-account JSON path (`GOOGLE_APPLICATION_CREDENTIALS`).
375    pub gcp_key_env: Option<String>,
376    /// Idle-read timeout in seconds for API calls. Default 120 when unset.
377    pub read_timeout_secs: Option<u64>,
378}
379
380/// API auth scheme. `none` sends no `Authorization` and no `x-api-key`.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum AuthScheme {
383    /// `Authorization: Bearer <token>`.
384    Bearer,
385    /// `x-api-key: <token>`.
386    XApiKey,
387    /// Named header (`header:<name>`).
388    Header(String),
389    /// No auth header.
390    None,
391}
392
393impl AuthScheme {
394    pub(crate) fn parse(s: &str) -> Result<Self, ProfileError> {
395        if let Some(name) = s.strip_prefix("header:") {
396            if name.is_empty() {
397                return Err(ProfileError::Parse(
398                    "auth_scheme header: requires a header name".into(),
399                ));
400            }
401            return Ok(Self::Header(name.to_string()));
402        }
403        match s {
404            "bearer" => Ok(Self::Bearer),
405            "x-api-key" => Ok(Self::XApiKey),
406            "none" => Ok(Self::None),
407            other => {
408                let listed = "bearer|x-api-key|none|header:<name>";
409                let mut msg = format!("unknown auth_scheme `{other}` ({listed})");
410                if let Some(suggest) = suggest_auth_scheme(other) {
411                    msg.push_str(&format!("; did you mean `{suggest}`"));
412                }
413                Err(ProfileError::Parse(msg))
414            }
415        }
416    }
417}
418
419const AUTH_SCHEME_NAMES: &[&str] = &["bearer", "x-api-key", "none", "header:<name>"];
420
421fn suggest_auth_scheme(s: &str) -> Option<&'static str> {
422    suggest_kebab(s, AUTH_SCHEME_NAMES)
423}
424
425fn fold_enum_key(s: &str) -> String {
426    s.to_ascii_lowercase().replace(['_', '-'], "")
427}
428
429/// Folded exact match, else edit distance <= 2, else a unique prefix/suffix.
430pub(crate) fn suggest_kebab<'a>(input: &str, legal: &[&'a str]) -> Option<&'a str> {
431    let folded = fold_enum_key(input);
432    if folded.is_empty() {
433        return None;
434    }
435    let mut best: Option<(&'a str, usize)> = None;
436    let mut prefix = Vec::new();
437    for &opt in legal {
438        let candidate = fold_enum_key(opt);
439        if candidate == folded {
440            return Some(opt);
441        }
442        let dist = edit_distance(&folded, &candidate);
443        if dist > 0 && dist <= 2 {
444            match best {
445                None => best = Some((opt, dist)),
446                Some((_, best_dist)) if dist < best_dist => best = Some((opt, dist)),
447                Some((best_opt, best_dist)) if dist == best_dist && opt.len() > best_opt.len() => {
448                    best = Some((opt, dist));
449                }
450                _ => {}
451            }
452        }
453        if candidate.starts_with(&folded)
454            || folded.starts_with(&candidate)
455            || candidate.ends_with(&folded)
456            || folded.ends_with(&candidate)
457        {
458            prefix.push(opt);
459        }
460    }
461    if let Some((opt, _)) = best {
462        return Some(opt);
463    }
464    match prefix.as_slice() {
465        [only] => Some(*only),
466        _ => None,
467    }
468}
469
470fn edit_distance(a: &str, b: &str) -> usize {
471    let a: Vec<char> = a.chars().collect();
472    let b: Vec<char> = b.chars().collect();
473    let mut prev: Vec<usize> = (0..=b.len()).collect();
474    let mut cur = vec![0; b.len() + 1];
475    for (i, ca) in a.iter().enumerate() {
476        cur[0] = i + 1;
477        for (j, cb) in b.iter().enumerate() {
478            let cost = usize::from(ca != cb);
479            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
480        }
481        std::mem::swap(&mut prev, &mut cur);
482    }
483    prev[b.len()]
484}
485
486fn unknown_kebab(field: &str, got: &str, legal: &[&str]) -> String {
487    let listed = legal.join("|");
488    let mut msg = format!("unknown {field} `{got}` ({listed})");
489    if let Some(suggest) = suggest_kebab(got, legal) {
490        msg.push_str(&format!("; did you mean `{suggest}`"));
491    }
492    msg
493}
494
495fn deserialize_kebab<'de, T, D>(
496    deserializer: D,
497    parse: fn(&str) -> Result<T, String>,
498) -> Result<T, D::Error>
499where
500    D: Deserializer<'de>,
501{
502    let s = String::deserialize(deserializer)?;
503    parse(&s).map_err(D::Error::custom)
504}
505
506impl<'de> Deserialize<'de> for AuthScheme {
507    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
508        let s = String::deserialize(deserializer)?;
509        Self::parse(&s).map_err(D::Error::custom)
510    }
511}
512
513impl Serialize for AuthScheme {
514    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
515        let owned: String;
516        let s = match self {
517            Self::Bearer => "bearer",
518            Self::XApiKey => "x-api-key",
519            Self::Header(name) => {
520                owned = format!("header:{name}");
521                owned.as_str()
522            }
523            Self::None => "none",
524        };
525        serializer.serialize_str(s)
526    }
527}
528
529/// The `[oauth]` table: token POST and login surface.
530#[derive(Debug, Clone, PartialEq)]
531pub struct OauthPack {
532    /// Token endpoint. Required when `[oauth]` is present.
533    pub token_url: String,
534    /// Used only on HTTP 404 from `token_url`.
535    pub token_url_fallback: Option<String>,
536    /// Authorize URL. Absent means no login in this profile.
537    pub authorize_url: Option<String>,
538    /// Extra authorize query parameters.
539    pub authorize_params: BTreeMap<String, String>,
540    /// Device-flow endpoint.
541    pub device_auth_url: Option<String>,
542    /// Public client id. May be empty; login then exits 2.
543    pub client_id: Option<String>,
544    /// PKCE loopback redirect.
545    pub redirect_uri: Option<String>,
546    /// OAuth scopes.
547    pub scopes: Vec<String>,
548    /// Merge policy for `scopes`.
549    pub list_merge: ListMerge,
550    /// PKCE flag. Default true when `authorize_url` is set.
551    pub pkce: Option<bool>,
552    /// `grant_type` when `refresh_body` omits it.
553    pub refresh_grant: Option<String>,
554    /// Extra token-POST body fields (data only).
555    pub refresh_body: BTreeMap<String, String>,
556    /// Token POST encoding.
557    pub token_request_format: Option<TokenRequestFormat>,
558    /// Headers on the token POST only.
559    pub token_headers: BTreeMap<String, String>,
560    /// Credential store path (`~` and `{env:}` allowed).
561    pub creds_path: Option<String>,
562    /// How to read/write the credential store.
563    pub creds_format: Option<CredsFormat>,
564    /// JSON Pointer into the store for the access token.
565    pub access_token_ptr: Option<String>,
566    /// JSON Pointer into the store for the refresh token.
567    pub refresh_token_ptr: Option<String>,
568    /// JSON Pointer into the store for expiry.
569    pub expires_ptr: Option<String>,
570    /// Unit of the store expiry field.
571    pub expires_unit: Option<ExpiresUnit>,
572    /// Env fallback for the access token.
573    pub access_env: Option<String>,
574    /// Login mode.
575    pub login: Option<Login>,
576    /// Free text printed for `login = "setup-token"`. Backticks allowed.
577    pub setup_token_hint: Option<String>,
578    /// Optional macOS keychain service.
579    pub keychain_service: Option<String>,
580    /// Optional keychain account names.
581    pub keychain_accounts: Vec<String>,
582    /// Pointers into the HTTP refresh response (not the store).
583    pub token_response: Option<TokenResponse>,
584}
585
586/// `[oauth.token_response]` pointers into the refresh HTTP body.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct TokenResponse {
589    /// Access token pointer. RFC 6749 default `/access_token`.
590    pub access_token_ptr: Option<String>,
591    /// Refresh token pointer. RFC 6749 default `/refresh_token`.
592    pub refresh_token_ptr: Option<String>,
593    /// Expiry pointer. RFC 6749 default `/expires_in`.
594    pub expires_ptr: Option<String>,
595    /// Expiry unit. RFC 6749 default `s`.
596    pub expires_unit: Option<ExpiresUnit>,
597}
598
599/// Token POST body encoding.
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
601#[serde(rename_all = "kebab-case")]
602pub enum TokenRequestFormat {
603    /// JSON body (Anthropic).
604    Json,
605    /// Form body (typical OIDC).
606    Form,
607}
608
609impl TokenRequestFormat {
610    pub(crate) fn parse(s: &str) -> Result<Self, String> {
611        match s {
612            "json" => Ok(Self::Json),
613            "form" => Ok(Self::Form),
614            other => Err(unknown_kebab(
615                "token_request_format",
616                other,
617                &["json", "form"],
618            )),
619        }
620    }
621}
622
623impl<'de> Deserialize<'de> for TokenRequestFormat {
624    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
625        deserialize_kebab(deserializer, Self::parse)
626    }
627}
628
629/// Named credential-store layouts.
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
631#[serde(rename_all = "kebab-case")]
632pub enum CredsFormat {
633    /// Explicit JSON Pointers on the pack.
634    JsonPointer,
635    /// Claude credentials.json alias map.
636    ClaudeCredentials,
637    /// Named `{issuer}::{client_id}` object inside an auth.json.
638    ///
639    /// Pointers: `/{issuer}::{client_id}/key` (access), `/refresh_token`,
640    /// `/expires_at` (RFC 3339). `issuer` is the origin of `authorize_url`,
641    /// else `token_url`. One `{issuer}::{client_id}@{name}` suffix matches
642    /// when the exact key is absent. Two or more named suffixes fail
643    /// closed. Empty `client_id` matches the single `{issuer}::` entry;
644    /// two or more issuer entries fail closed.
645    OidcAuthJson,
646    /// GitHub Copilot `hosts.json` / `apps.json` layout.
647    ///
648    /// Pointer: `/{host}/oauth_token` (first object that has a non-empty
649    /// token; default host `github.com`). GitHub Copilot product terms
650    /// apply to the token. This crate does not ship a Copilot preset.
651    CopilotHosts,
652}
653
654impl CredsFormat {
655    pub(crate) fn parse(s: &str) -> Result<Self, String> {
656        match s {
657            "json-pointer" => Ok(Self::JsonPointer),
658            "claude-credentials" => Ok(Self::ClaudeCredentials),
659            "oidc-auth-json" => Ok(Self::OidcAuthJson),
660            "copilot-hosts" => Ok(Self::CopilotHosts),
661            other => Err(unknown_kebab(
662                "creds_format",
663                other,
664                &[
665                    "json-pointer",
666                    "claude-credentials",
667                    "oidc-auth-json",
668                    "copilot-hosts",
669                ],
670            )),
671        }
672    }
673}
674
675impl<'de> Deserialize<'de> for CredsFormat {
676    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
677        deserialize_kebab(deserializer, Self::parse)
678    }
679}
680
681/// Expiry field unit.
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
683#[serde(rename_all = "kebab-case")]
684pub enum ExpiresUnit {
685    /// Milliseconds since epoch (Claude credentials).
686    Ms,
687    /// Seconds (`expires_in` / unix time).
688    S,
689}
690
691impl ExpiresUnit {
692    pub(crate) fn parse(s: &str) -> Result<Self, String> {
693        match s {
694            "ms" => Ok(Self::Ms),
695            "s" => Ok(Self::S),
696            other => Err(unknown_kebab("expires_unit", other, &["ms", "s"])),
697        }
698    }
699}
700
701impl<'de> Deserialize<'de> for ExpiresUnit {
702    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
703        deserialize_kebab(deserializer, Self::parse)
704    }
705}
706
707/// Login engine selection.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
709#[serde(rename_all = "kebab-case")]
710pub enum Login {
711    /// Print `setup_token_hint` and exit 2.
712    SetupToken,
713    /// PKCE loopback.
714    Pkce,
715    /// Device authorization grant.
716    Device,
717    /// Not ready / disabled.
718    None,
719}
720
721impl Login {
722    pub(crate) fn parse(s: &str) -> Result<Self, String> {
723        match s {
724            "setup-token" => Ok(Self::SetupToken),
725            "pkce" => Ok(Self::Pkce),
726            "device" => Ok(Self::Device),
727            "none" => Ok(Self::None),
728            other => Err(unknown_kebab(
729                "login",
730                other,
731                &["setup-token", "pkce", "device", "none"],
732            )),
733        }
734    }
735}
736
737impl<'de> Deserialize<'de> for Login {
738    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
739        deserialize_kebab(deserializer, Self::parse)
740    }
741}
742
743/// Encode-time fingerprint (API request only).
744#[derive(Debug, Clone, PartialEq)]
745pub struct Fingerprint {
746    /// `User-Agent` header.
747    pub user_agent: Option<String>,
748    /// `x-app` header.
749    pub x_app: Option<String>,
750    /// Prepended to the first system item.
751    pub system_prompt_prefix: Option<String>,
752    /// Tool name case conversion.
753    pub tool_name_case: Option<ToolNameCase>,
754    /// Keys the encode path must not send.
755    pub forbidden_body_fields: Vec<String>,
756    /// What to do when a forbidden field is present.
757    pub forbidden_field_policy: ForbiddenFieldPolicy,
758    /// Data-only JSON keys merged into the API body.
759    pub extra_body: BTreeMap<String, serde_json::Value>,
760}
761
762/// Tool-name case conversion.
763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
764#[serde(rename_all = "kebab-case")]
765pub enum ToolNameCase {
766    /// Leave names unchanged.
767    AsIs,
768    /// `snake_case`.
769    Snake,
770    /// `kebab-case`.
771    Kebab,
772}
773
774impl ToolNameCase {
775    pub(crate) fn parse(s: &str) -> Result<Self, String> {
776        match s {
777            "as-is" => Ok(Self::AsIs),
778            "snake" => Ok(Self::Snake),
779            "kebab" => Ok(Self::Kebab),
780            other => Err(unknown_kebab(
781                "tool_name_case",
782                other,
783                &["as-is", "snake", "kebab"],
784            )),
785        }
786    }
787}
788
789impl<'de> Deserialize<'de> for ToolNameCase {
790    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
791        deserialize_kebab(deserializer, Self::parse)
792    }
793}
794
795/// Encode-time policy for `forbidden_body_fields`.
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
797#[serde(rename_all = "kebab-case")]
798pub enum ForbiddenFieldPolicy {
799    /// Drop the field.
800    Strip,
801    /// Fail encode.
802    #[default]
803    HardError,
804}
805
806impl ForbiddenFieldPolicy {
807    pub(crate) fn parse(s: &str) -> Result<Self, String> {
808        match s {
809            "strip" => Ok(Self::Strip),
810            "hard-error" => Ok(Self::HardError),
811            other => Err(unknown_kebab(
812                "forbidden_field_policy",
813                other,
814                &["strip", "hard-error"],
815            )),
816        }
817    }
818}
819
820impl<'de> Deserialize<'de> for ForbiddenFieldPolicy {
821    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
822        deserialize_kebab(deserializer, Self::parse)
823    }
824}
825
826/// Beta header values and merge policy.
827#[derive(Debug, Clone, PartialEq, Eq)]
828pub struct Betas {
829    /// Header values, e.g. `oauth-2025-04-20`.
830    pub values: Vec<String>,
831    /// Header name. Default `anthropic-beta`.
832    pub header: String,
833    /// `union` or `replace` (`verbatim` alias).
834    pub merge: ListMerge,
835}
836
837impl Betas {
838    pub(crate) fn default_for(wire: Option<Wire>, has_oauth: bool) -> Self {
839        let merge = if matches!(wire, Some(Wire::Messages)) && has_oauth {
840            ListMerge::Replace
841        } else {
842            ListMerge::Union
843        };
844        Self {
845            values: Vec::new(),
846            header: "anthropic-beta".into(),
847            merge,
848        }
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    fn unknown_text(input: &str) -> String {
857        AuthScheme::parse(input)
858            .expect_err("must reject")
859            .to_string()
860    }
861
862    #[test]
863    #[allow(unreachable_patterns)] // non_exhaustive still lets this crate match all known variants
864    fn hosts_should_wildcard_unknown_wire_dialects() {
865        fn host_spelling(wire: Wire) -> &'static str {
866            match wire {
867                Wire::ChatCompletions => "chat-completions",
868                Wire::Messages => "messages",
869                Wire::Responses => "responses",
870                Wire::Gemini => "gemini",
871                Wire::Converse => "converse",
872                _ => "unknown",
873            }
874        }
875        assert_eq!(host_spelling(Wire::ChatCompletions), "chat-completions");
876        assert_eq!(host_spelling(Wire::Messages), "messages");
877        assert_eq!(host_spelling(Wire::Responses), "responses");
878        assert_eq!(host_spelling(Wire::Gemini), "gemini");
879        assert_eq!(host_spelling(Wire::Converse), "converse");
880        assert!(
881            Wire::NAMES.contains(&"converse"),
882            "NAMES must include converse"
883        );
884    }
885
886    #[test]
887    fn as_str_rustdoc_lists_converse() {
888        let src = include_str!("types.rs");
889        let as_str_doc = src
890            .lines()
891            .find(|line| line.contains("Catalog / file spelling"))
892            .expect("as_str rustdoc lists catalog spellings");
893        assert!(
894            as_str_doc.contains("converse"),
895            "as_str rustdoc must list converse with the other spellings, got: {as_str_doc}"
896        );
897    }
898
899    #[test]
900    fn unknown_auth_scheme_lists_legal_values() {
901        let text = unknown_text("oauth");
902        assert!(text.contains("unknown auth_scheme `oauth`"), "{text}");
903        assert!(
904            text.contains("bearer")
905                && text.contains("x-api-key")
906                && text.contains("none")
907                && text.contains("header:<name>"),
908            "must list legal auth_scheme values, got {text}"
909        );
910    }
911
912    #[test]
913    fn unknown_auth_scheme_suggests_close_matches() {
914        let bearer = unknown_text("Bearer");
915        assert!(
916            bearer.contains("bearer")
917                && bearer.contains("x-api-key")
918                && bearer.contains("none")
919                && bearer.contains("header:<name>"),
920            "must list legal values, got {bearer}"
921        );
922        assert!(
923            bearer.contains("bearer"),
924            "Bearer must point at bearer, got {bearer}"
925        );
926        assert!(
927            bearer.to_ascii_lowercase().contains("did you mean") && bearer.contains("`bearer`"),
928            "Bearer should suggest bearer, got {bearer}"
929        );
930
931        for input in ["api-key", "x_api_key", "X-Api-Key"] {
932            let text = unknown_text(input);
933            assert!(
934                text.contains("x-api-key")
935                    && text.contains("bearer")
936                    && text.contains("none")
937                    && text.contains("header:<name>"),
938                "{input} must list legal values, got {text}"
939            );
940            assert!(
941                text.to_ascii_lowercase().contains("did you mean") && text.contains("`x-api-key`"),
942                "{input} should suggest x-api-key, got {text}"
943            );
944        }
945    }
946}