feat(login): custom provider rows, URL / name / key steps, and custom:<name> authenticate
The picker lists Custom provider (OpenAI compatible) and (Anthropic compatible).
The TUI asks for a base URL, a name (default from the host), and a key, saves the
definition and the key, and the agent validates the key against GET {base}/models.
PTY e2e covers both wires against the mock inference server.
This commit is contained in:
1 parent
eb86d7ec26
commit
791f953c07
18 files changed
+1198
-85
No files matched your search
@@ -165,10 +165,60 @@ pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
|
||||
Ok(url.to_owned())
|
||||
}
|
||||
|
||||
/// A valid provider name from a base URL's host, for a login default.
|
||||
pub fn default_name(base_url: &str) -> String {
|
||||
let host = base_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.and_then(|authority| authority.split(':').next())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let labels: Vec<&str> = host
|
||||
.split('.')
|
||||
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
|
||||
.collect();
|
||||
let joined: String = labels
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
|
||||
.take(MAX_NAME_LEN)
|
||||
.collect();
|
||||
let name = joined.trim_matches('-').to_owned();
|
||||
if validate_name(&name).is_ok() {
|
||||
name
|
||||
} else {
|
||||
"custom".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_names_come_from_the_host() {
|
||||
for (url, expected) in [
|
||||
("https://api.together.xyz/v1", "together-xyz"),
|
||||
("https://openrouter.ai/api/v1", "openrouter-ai"),
|
||||
("http://localhost:11434/v1", "localhost"),
|
||||
("http://127.0.0.1:8080/v1", "127-0-0-1"),
|
||||
("https://api.openai.com/v1", "openai-com"),
|
||||
(
|
||||
"https://gateway.internal.example.org/anthropic",
|
||||
"gateway-internal-example-org",
|
||||
),
|
||||
("https://API.Example.COM/v1", "example-com"),
|
||||
("not a url", "custom"),
|
||||
("https://api/v1", "custom"),
|
||||
("https://[::1]:9000/v1", "custom"),
|
||||
] {
|
||||
assert_eq!(default_name(url), expected, "{url}");
|
||||
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
|
||||
}
|
||||
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_round_trips_and_maps_to_the_wire() {
|
||||
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
|
||||
|
||||
Reference in new issue
Block a user