use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::error::ProfileError;
pub const SCHEMA_VERSION_MAX: u32 = 1;
#[derive(Debug, Clone)]
pub struct LoadOptions<'a> {
pub id: Option<&'a str>,
pub explicit_file: Option<&'a Path>,
pub extra_profile_dirs: Vec<PathBuf>,
pub include_shipped: bool,
pub include_user_config: bool,
}
impl Default for LoadOptions<'_> {
fn default() -> Self {
Self {
id: None,
explicit_file: None,
extra_profile_dirs: Vec::new(),
include_shipped: true,
include_user_config: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedProfile {
pub schema_version: u32,
pub id: String,
pub display_name: Option<String>,
pub dialect: Dialect,
pub http: Http,
pub oauth: Option<OauthPack>,
pub access_env: Vec<String>,
pub fingerprint: Option<Fingerprint>,
pub betas: Betas,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dialect {
pub wire: Option<Wire>,
pub stream_events: Vec<String>,
pub list_merge: ListMerge,
pub tool_type_policy: ToolTypePolicy,
pub stream_unknown_policy: StreamUnknownPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Wire {
ChatCompletions,
Messages,
Responses,
Gemini,
}
impl Wire {
const NAMES: &'static [&'static str] = &["chat-completions", "messages", "responses", "gemini"];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ChatCompletions => "chat-completions",
Self::Messages => "messages",
Self::Responses => "responses",
Self::Gemini => "gemini",
}
}
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"chat-completions" => Ok(Self::ChatCompletions),
"messages" => Ok(Self::Messages),
"responses" => Ok(Self::Responses),
"gemini" => Ok(Self::Gemini),
other => Err(unknown_kebab("wire", other, Self::NAMES)),
}
}
#[must_use]
pub fn suggest(s: &str) -> Option<&'static str> {
suggest_kebab(s, Self::NAMES)
}
#[must_use]
pub fn default_stream_events(self) -> &'static [&'static str] {
match self {
Self::ChatCompletions => &["chunk", "[DONE]"],
Self::Messages => &[
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
"ping",
"error",
],
Self::Responses => &[
"response.created",
"response.in_progress",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.reasoning_summary_text.delta",
"response.reasoning.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.output_item.done",
"response.completed",
"response.failed",
"response.incomplete",
],
Self::Gemini => &["chunk"],
}
}
#[must_use]
pub fn default_chat_path(self) -> &'static str {
match self {
Self::ChatCompletions => "/v1/chat/completions",
Self::Messages => "/v1/messages",
Self::Responses => "/v1/responses",
Self::Gemini => "/v1beta/models/{model}:generateContent",
}
}
#[must_use]
pub fn default_auth_scheme(self) -> AuthScheme {
match self {
Self::Messages => AuthScheme::XApiKey,
Self::Gemini => AuthScheme::Header("x-goog-api-key".into()),
Self::ChatCompletions | Self::Responses => AuthScheme::Bearer,
}
}
}
impl<'de> Deserialize<'de> for Wire {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ListMerge {
#[default]
Union,
Replace,
}
impl ListMerge {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"union" => Ok(Self::Union),
"replace" | "verbatim" => Ok(Self::Replace),
other => Err(unknown_kebab(
"list_merge",
other,
&["union", "replace", "verbatim"],
)),
}
}
}
impl<'de> Deserialize<'de> for ListMerge {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ToolTypePolicy {
Passthrough,
FlattenNamespace,
#[default]
HardError,
}
impl ToolTypePolicy {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"passthrough" => Ok(Self::Passthrough),
"flatten-namespace" => Ok(Self::FlattenNamespace),
"hard-error" => Ok(Self::HardError),
other => Err(unknown_kebab(
"tool_type_policy",
other,
&["passthrough", "flatten-namespace", "hard-error"],
)),
}
}
}
impl<'de> Deserialize<'de> for ToolTypePolicy {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum StreamUnknownPolicy {
#[default]
HardError,
Passthrough,
}
impl StreamUnknownPolicy {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"hard-error" => Ok(Self::HardError),
"passthrough" => Ok(Self::Passthrough),
other => Err(unknown_kebab(
"stream_unknown_policy",
other,
&["hard-error", "passthrough"],
)),
}
}
}
impl<'de> Deserialize<'de> for StreamUnknownPolicy {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Http {
pub base_url: Option<String>,
pub chat_path: Option<String>,
pub auth_scheme: Option<AuthScheme>,
pub headers: BTreeMap<String, String>,
pub header_merge: ListMerge,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthScheme {
Bearer,
XApiKey,
Header(String),
None,
}
impl AuthScheme {
pub(crate) fn parse(s: &str) -> Result<Self, ProfileError> {
if let Some(name) = s.strip_prefix("header:") {
if name.is_empty() {
return Err(ProfileError::Parse(
"auth_scheme header: requires a header name".into(),
));
}
return Ok(Self::Header(name.to_string()));
}
match s {
"bearer" => Ok(Self::Bearer),
"x-api-key" => Ok(Self::XApiKey),
"none" => Ok(Self::None),
other => {
let listed = "bearer|x-api-key|none|header:<name>";
let mut msg = format!("unknown auth_scheme `{other}` ({listed})");
if let Some(suggest) = suggest_auth_scheme(other) {
msg.push_str(&format!("; did you mean `{suggest}`"));
}
Err(ProfileError::Parse(msg))
}
}
}
}
const AUTH_SCHEME_NAMES: &[&str] = &["bearer", "x-api-key", "none", "header:<name>"];
fn suggest_auth_scheme(s: &str) -> Option<&'static str> {
suggest_kebab(s, AUTH_SCHEME_NAMES)
}
fn fold_enum_key(s: &str) -> String {
s.to_ascii_lowercase().replace(['_', '-'], "")
}
pub(crate) fn suggest_kebab<'a>(input: &str, legal: &[&'a str]) -> Option<&'a str> {
let folded = fold_enum_key(input);
if folded.is_empty() {
return None;
}
let mut close = Vec::new();
for &opt in legal {
let candidate = fold_enum_key(opt);
if candidate == folded {
return Some(opt);
}
if candidate.starts_with(&folded)
|| folded.starts_with(&candidate)
|| candidate.ends_with(&folded)
|| folded.ends_with(&candidate)
{
close.push(opt);
}
}
match close.as_slice() {
[only] => Some(*only),
_ => None,
}
}
fn unknown_kebab(field: &str, got: &str, legal: &[&str]) -> String {
let listed = legal.join("|");
let mut msg = format!("unknown {field} `{got}` ({listed})");
if let Some(suggest) = suggest_kebab(got, legal) {
msg.push_str(&format!("; did you mean `{suggest}`"));
}
msg
}
fn deserialize_kebab<'de, T, D>(
deserializer: D,
parse: fn(&str) -> Result<T, String>,
) -> Result<T, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse(&s).map_err(D::Error::custom)
}
impl<'de> Deserialize<'de> for AuthScheme {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(D::Error::custom)
}
}
impl Serialize for AuthScheme {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let owned: String;
let s = match self {
Self::Bearer => "bearer",
Self::XApiKey => "x-api-key",
Self::Header(name) => {
owned = format!("header:{name}");
owned.as_str()
}
Self::None => "none",
};
serializer.serialize_str(s)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OauthPack {
pub token_url: String,
pub token_url_fallback: Option<String>,
pub authorize_url: Option<String>,
pub authorize_params: BTreeMap<String, String>,
pub device_auth_url: Option<String>,
pub client_id: Option<String>,
pub redirect_uri: Option<String>,
pub scopes: Vec<String>,
pub list_merge: ListMerge,
pub pkce: Option<bool>,
pub refresh_grant: Option<String>,
pub refresh_body: BTreeMap<String, String>,
pub token_request_format: Option<TokenRequestFormat>,
pub token_headers: BTreeMap<String, String>,
pub creds_path: Option<String>,
pub creds_format: Option<CredsFormat>,
pub access_token_ptr: Option<String>,
pub refresh_token_ptr: Option<String>,
pub expires_ptr: Option<String>,
pub expires_unit: Option<ExpiresUnit>,
pub access_env: Option<String>,
pub login: Option<Login>,
pub setup_token_hint: Option<String>,
pub keychain_service: Option<String>,
pub keychain_accounts: Vec<String>,
pub token_response: Option<TokenResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenResponse {
pub access_token_ptr: Option<String>,
pub refresh_token_ptr: Option<String>,
pub expires_ptr: Option<String>,
pub expires_unit: Option<ExpiresUnit>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TokenRequestFormat {
Json,
Form,
}
impl TokenRequestFormat {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"json" => Ok(Self::Json),
"form" => Ok(Self::Form),
other => Err(unknown_kebab(
"token_request_format",
other,
&["json", "form"],
)),
}
}
}
impl<'de> Deserialize<'de> for TokenRequestFormat {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredsFormat {
JsonPointer,
ClaudeCredentials,
OidcAuthJson,
CopilotHosts,
}
impl CredsFormat {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"json-pointer" => Ok(Self::JsonPointer),
"claude-credentials" => Ok(Self::ClaudeCredentials),
"oidc-auth-json" => Ok(Self::OidcAuthJson),
"copilot-hosts" => Ok(Self::CopilotHosts),
other => Err(unknown_kebab(
"creds_format",
other,
&[
"json-pointer",
"claude-credentials",
"oidc-auth-json",
"copilot-hosts",
],
)),
}
}
}
impl<'de> Deserialize<'de> for CredsFormat {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExpiresUnit {
Ms,
S,
}
impl ExpiresUnit {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"ms" => Ok(Self::Ms),
"s" => Ok(Self::S),
other => Err(unknown_kebab("expires_unit", other, &["ms", "s"])),
}
}
}
impl<'de> Deserialize<'de> for ExpiresUnit {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Login {
SetupToken,
Pkce,
Device,
None,
}
impl Login {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"setup-token" => Ok(Self::SetupToken),
"pkce" => Ok(Self::Pkce),
"device" => Ok(Self::Device),
"none" => Ok(Self::None),
other => Err(unknown_kebab(
"login",
other,
&["setup-token", "pkce", "device", "none"],
)),
}
}
}
impl<'de> Deserialize<'de> for Login {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Fingerprint {
pub user_agent: Option<String>,
pub x_app: Option<String>,
pub system_prompt_prefix: Option<String>,
pub tool_name_case: Option<ToolNameCase>,
pub forbidden_body_fields: Vec<String>,
pub forbidden_field_policy: ForbiddenFieldPolicy,
pub extra_body: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ToolNameCase {
AsIs,
Snake,
Kebab,
}
impl ToolNameCase {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"as-is" => Ok(Self::AsIs),
"snake" => Ok(Self::Snake),
"kebab" => Ok(Self::Kebab),
other => Err(unknown_kebab(
"tool_name_case",
other,
&["as-is", "snake", "kebab"],
)),
}
}
}
impl<'de> Deserialize<'de> for ToolNameCase {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ForbiddenFieldPolicy {
Strip,
#[default]
HardError,
}
impl ForbiddenFieldPolicy {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"strip" => Ok(Self::Strip),
"hard-error" => Ok(Self::HardError),
other => Err(unknown_kebab(
"forbidden_field_policy",
other,
&["strip", "hard-error"],
)),
}
}
}
impl<'de> Deserialize<'de> for ForbiddenFieldPolicy {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserialize_kebab(deserializer, Self::parse)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Betas {
pub values: Vec<String>,
pub header: String,
pub merge: ListMerge,
}
impl Betas {
pub(crate) fn default_for(wire: Option<Wire>, has_oauth: bool) -> Self {
let merge = if matches!(wire, Some(Wire::Messages)) && has_oauth {
ListMerge::Replace
} else {
ListMerge::Union
};
Self {
values: Vec::new(),
header: "anthropic-beta".into(),
merge,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unknown_text(input: &str) -> String {
AuthScheme::parse(input)
.expect_err("must reject")
.to_string()
}
#[test]
fn unknown_auth_scheme_lists_legal_values() {
let text = unknown_text("oauth");
assert!(text.contains("unknown auth_scheme `oauth`"), "{text}");
assert!(
text.contains("bearer")
&& text.contains("x-api-key")
&& text.contains("none")
&& text.contains("header:<name>"),
"must list legal auth_scheme values, got {text}"
);
}
#[test]
fn unknown_auth_scheme_suggests_close_matches() {
let bearer = unknown_text("Bearer");
assert!(
bearer.contains("bearer")
&& bearer.contains("x-api-key")
&& bearer.contains("none")
&& bearer.contains("header:<name>"),
"must list legal values, got {bearer}"
);
assert!(
bearer.contains("bearer"),
"Bearer must point at bearer, got {bearer}"
);
assert!(
bearer.to_ascii_lowercase().contains("did you mean") && bearer.contains("`bearer`"),
"Bearer should suggest bearer, got {bearer}"
);
for input in ["api-key", "x_api_key", "X-Api-Key"] {
let text = unknown_text(input);
assert!(
text.contains("x-api-key")
&& text.contains("bearer")
&& text.contains("none")
&& text.contains("header:<name>"),
"{input} must list legal values, got {text}"
);
assert!(
text.to_ascii_lowercase().contains("did you mean") && text.contains("`x-api-key`"),
"{input} should suggest x-api-key, got {text}"
);
}
}
}