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] {
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct BuiltAuthMethods {
|
||||
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
|
||||
/// 5. every API-key registry platform, in `PlatformId::ALL` order
|
||||
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
|
||||
/// 6. the two custom provider rows (`custom-openai`, `custom-anthropic`)
|
||||
///
|
||||
/// The platform methods are for the INTERACTIVE login picker only: they come
|
||||
/// after `kimi-code` so they can never become `auth_methods.first()` (the
|
||||
@@ -174,6 +175,7 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
|
||||
methods.push(platform_auth_method(platform));
|
||||
}
|
||||
}
|
||||
methods.extend(crate::agent::custom_providers::picker_methods());
|
||||
|
||||
BuiltAuthMethods {
|
||||
methods,
|
||||
@@ -189,6 +191,8 @@ pub enum AuthMethodKind {
|
||||
KimiCode,
|
||||
/// Registry API-key platform login (method id = the platform id).
|
||||
ApiKeyPlatform(kigi_models::PlatformId),
|
||||
/// A saved custom provider's login (method id = `custom:<name>`).
|
||||
CustomProvider,
|
||||
/// Generic device-code OAuth platform login (method id = the platform id,
|
||||
/// e.g. `xai-grok`). Interactive, like Kimi Code.
|
||||
OAuthPlatform(kigi_models::PlatformId),
|
||||
@@ -201,6 +205,9 @@ impl AuthMethodKind {
|
||||
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
|
||||
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
|
||||
KIMI_CODE_METHOD_ID => Self::KimiCode,
|
||||
other if crate::agent::custom_providers::login_provider_name(other).is_some() => {
|
||||
Self::CustomProvider
|
||||
}
|
||||
other => match kigi_models::PlatformId::parse(other) {
|
||||
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
|
||||
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
|
||||
@@ -213,7 +220,10 @@ impl AuthMethodKind {
|
||||
/// The registry platform methods qualify — they validate a configured
|
||||
/// platform key and then behave exactly like an external-API-key session.
|
||||
pub fn is_api_key(self) -> bool {
|
||||
matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_))
|
||||
matches!(
|
||||
self,
|
||||
Self::XaiApiKey | Self::ApiKeyPlatform(_) | Self::CustomProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// `true` for session-based methods (cached_token, interactive login).
|
||||
@@ -589,6 +599,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `custom:<name>` is an API-key login; the picker rows are not loginable.
|
||||
#[test]
|
||||
fn custom_provider_method_ids_classify_as_api_key() {
|
||||
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy"));
|
||||
assert_eq!(kind, AuthMethodKind::CustomProvider);
|
||||
assert!(kind.is_api_key());
|
||||
assert!(!kind.is_session_based());
|
||||
assert!(!kind.needs_interactive_login());
|
||||
for picker in ["custom-openai", "custom-anthropic"] {
|
||||
assert_eq!(
|
||||
AuthMethodKind::from_id(&acp::AuthMethodId::new(picker)),
|
||||
AuthMethodKind::Unknown,
|
||||
"{picker} only opens the TUI flow; it cannot authenticate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier matrix for all auth method variants.
|
||||
#[test]
|
||||
fn auth_method_kind_classifier_matrix() {
|
||||
@@ -896,7 +923,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||
@@ -948,7 +977,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -993,7 +1024,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -1041,7 +1074,9 @@ mod tests {
|
||||
"xiaomi",
|
||||
"xiaomi-token-plan-cn",
|
||||
"minimax",
|
||||
"minimax-cn"
|
||||
"minimax-cn",
|
||||
"custom-openai",
|
||||
"custom-anthropic"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), None);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Login for custom providers: picker rows, method ids, key validation.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_models::custom::CustomApi;
|
||||
|
||||
use super::CredentialedProvider;
|
||||
|
||||
const PICKER_PREFIX: &str = "custom-";
|
||||
const LOGIN_PREFIX: &str = "custom:";
|
||||
const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic];
|
||||
|
||||
/// The picker row that starts the custom provider flow for one wire.
|
||||
pub fn picker_method(api: CustomApi) -> acp::AuthMethod {
|
||||
acp::AuthMethod::Agent(
|
||||
acp::AuthMethodAgent::new(
|
||||
acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())),
|
||||
format!("Custom provider ({})", api.label()),
|
||||
)
|
||||
.description(Some("Your own base URL and API key".to_owned())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Both picker rows, in picker order.
|
||||
pub fn picker_methods() -> Vec<acp::AuthMethod> {
|
||||
ALL_APIS.into_iter().map(picker_method).collect()
|
||||
}
|
||||
|
||||
/// The wire behind a picker row id.
|
||||
pub fn picker_api(id: &str) -> Option<CustomApi> {
|
||||
let suffix = id.strip_prefix(PICKER_PREFIX)?;
|
||||
ALL_APIS.into_iter().find(|api| api.as_str() == suffix)
|
||||
}
|
||||
|
||||
/// The id `authenticate` takes once the provider is saved.
|
||||
pub fn login_method_id(name: &str) -> acp::AuthMethodId {
|
||||
acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}"))
|
||||
}
|
||||
|
||||
/// The provider name inside an `authenticate` method id.
|
||||
pub fn login_provider_name(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(LOGIN_PREFIX)
|
||||
}
|
||||
|
||||
/// Probes the listing endpoint with the key; the error text never holds the key.
|
||||
pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> {
|
||||
let provider = &credentialed.provider;
|
||||
let url = provider.models_url();
|
||||
let client = crate::http::shared_client();
|
||||
let request = match provider.api {
|
||||
CustomApi::OpenAi => client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", credentialed.key())),
|
||||
CustomApi::Anthropic => client
|
||||
.get(&url)
|
||||
.header("x-api-key", credentialed.key())
|
||||
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200..=299 => Ok(()),
|
||||
401 | 403 => Err(format!(
|
||||
"{} rejected the API key (HTTP {status})",
|
||||
provider.name
|
||||
)),
|
||||
_ => Err(format!("{url} answered HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::CustomProvider;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
|
||||
CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_ids_round_trip_and_reject_lookalikes() {
|
||||
for api in ALL_APIS {
|
||||
let method = picker_method(api);
|
||||
assert_eq!(picker_api(method.id().0.as_ref()), Some(api));
|
||||
}
|
||||
assert_eq!(picker_api("custom-responses"), None);
|
||||
assert_eq!(picker_api("custom-OpenAI"), None);
|
||||
assert_eq!(picker_api("custom:proxy"), None);
|
||||
assert_eq!(picker_api("moonshot-cn"), None);
|
||||
assert_eq!(picker_methods().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_ids_carry_the_provider_name() {
|
||||
let id = login_method_id("my-proxy");
|
||||
assert_eq!(id.0.as_ref(), "custom:my-proxy");
|
||||
assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy"));
|
||||
assert_eq!(login_provider_name("custom-openai"), None);
|
||||
assert_eq!(login_provider_name("proxy"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_sends_the_dialects_key_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer sk-openai"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("x-api-key", "sk-anthropic"))
|
||||
.and(header(
|
||||
"anthropic-version",
|
||||
kigi_sampling_types::ANTHROPIC_VERSION,
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base = format!("{}/v1", server.uri());
|
||||
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_names_the_failure_without_the_key() {
|
||||
let server = MockServer::start().await;
|
||||
for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] {
|
||||
Mock::given(path(format!("/{path_suffix}/models")))
|
||||
.respond_with(ResponseTemplate::new(status))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
let check = |suffix: &str| {
|
||||
let base = format!("{}/{suffix}", server.uri());
|
||||
async move {
|
||||
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
}
|
||||
};
|
||||
assert!(check("a").await.contains("rejected the API key (HTTP 401)"));
|
||||
assert!(check("b").await.contains("rejected the API key (HTTP 403)"));
|
||||
assert!(check("c").await.contains("answered HTTP 404"));
|
||||
assert!(check("d").await.contains("answered HTTP 500"));
|
||||
|
||||
let err = validate_key(&credentialed(
|
||||
CustomApi::OpenAi,
|
||||
"http://127.0.0.1:1/v1",
|
||||
"sk-secret-key",
|
||||
))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Couldn't reach"), "{err}");
|
||||
assert!(!err.contains("sk-secret-key"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
|
||||
|
||||
mod fetch;
|
||||
mod login;
|
||||
mod save;
|
||||
|
||||
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
|
||||
pub(crate) use login::validate_key;
|
||||
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
|
||||
pub use save::save_custom_provider;
|
||||
|
||||
/// Tables setting `base_url` or `api`, valid or not.
|
||||
|
||||
@@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent {
|
||||
.oauth_platform()
|
||||
{
|
||||
self.authenticate_oauth_platform(platform, arguments).await
|
||||
} else if let Some(name) = crate::agent::custom_providers::login_provider_name(
|
||||
arguments.method_id.0.as_ref(),
|
||||
) {
|
||||
self.authenticate_custom_provider(name, arguments.method_id.clone())
|
||||
.await
|
||||
} else if let Some(platform) =
|
||||
auth_method::platform_for_method_id(&arguments.method_id)
|
||||
{
|
||||
|
||||
@@ -526,6 +526,43 @@ impl MvpAgent {
|
||||
Some("platform_key_invalid_or_missing"),
|
||||
);
|
||||
})?;
|
||||
Ok(self.finish_api_key_login(method_id).await)
|
||||
}
|
||||
/// `authenticate(custom:<name>)`: validate the saved provider's key.
|
||||
pub(super) async fn authenticate_custom_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
method_id: acp::AuthMethodId,
|
||||
) -> Result<AuthenticateResponse, acp::Error> {
|
||||
let keys =
|
||||
crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
|
||||
let auth_err = |message: String| {
|
||||
let mut err = acp::Error::auth_required();
|
||||
err.message = message;
|
||||
err
|
||||
};
|
||||
let outcome = match keys.custom().iter().find(|c| c.provider.name == name) {
|
||||
Some(credentialed) => {
|
||||
crate::agent::custom_providers::validate_key(credentialed).await
|
||||
}
|
||||
None => Err(format!(
|
||||
"No custom provider named {name} with an API key in config.toml or auth.json"
|
||||
)),
|
||||
};
|
||||
if let Err(message) = outcome {
|
||||
tracing::warn!(provider = name, %message, "custom provider login failed");
|
||||
emit_login_span(
|
||||
false,
|
||||
"custom-provider",
|
||||
None,
|
||||
Some("custom_provider_invalid_or_missing"),
|
||||
);
|
||||
return Err(auth_err(message));
|
||||
}
|
||||
Ok(self.finish_api_key_login(method_id).await)
|
||||
}
|
||||
/// Shared tail of an API-key login: reload config, refresh the catalog.
|
||||
async fn finish_api_key_login(&self, method_id: acp::AuthMethodId) -> AuthenticateResponse {
|
||||
// Rebuild the catalog from the on-disk config: the rebuild freshly
|
||||
// resolves platform keys (env > auth.json > config), so the key just
|
||||
// persisted to auth.json is stamped onto the platform's entries; a
|
||||
@@ -557,7 +594,7 @@ impl MvpAgent {
|
||||
let meta = serde_json::to_value(auth_meta)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned());
|
||||
Ok(AuthenticateResponse::new().meta(meta))
|
||||
AuthenticateResponse::new().meta(meta)
|
||||
}
|
||||
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
|
||||
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
|
||||
|
||||
@@ -570,6 +570,10 @@ pub enum Action {
|
||||
/// under the platform-id scope, then authenticate with the platform's
|
||||
/// method id. The key must never be logged.
|
||||
SubmitPlatformApiKey(String),
|
||||
/// A custom provider row was picked: start the URL, name, key steps.
|
||||
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
|
||||
/// Enter in a custom provider step, with the trimmed input.
|
||||
SubmitCustomProviderInput(String),
|
||||
/// Copy the auth URL to the clipboard during authentication.
|
||||
CopyAuthUrl,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
@@ -1614,6 +1618,13 @@ pub enum Effect {
|
||||
target: crate::app::app_view::PlatformLogin,
|
||||
key: String,
|
||||
},
|
||||
/// Save a custom provider (config.toml and auth.json), then send
|
||||
/// AuthenticateRequest with `custom:<name>`. SECURITY: never log the key.
|
||||
PersistCustomProviderAndAuthenticate {
|
||||
request_seq: u64,
|
||||
provider: kigi_shell::models::custom::CustomProvider,
|
||||
key: String,
|
||||
},
|
||||
/// Fetch MCP server list from the shell (kigi/mcp/list).
|
||||
FetchMcpsList {
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -262,6 +262,8 @@ pub enum AuthMode {
|
||||
/// Open-platform API-key entry: paste box for a Moonshot key selected
|
||||
/// from the welcome login picker. Esc returns to the picker (no quit).
|
||||
ApiKeyEntry(PlatformLogin),
|
||||
/// Custom provider login: one field per step; Esc returns to the picker.
|
||||
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
|
||||
}
|
||||
/// API-key platform login target, selected from the welcome picker. Wraps a
|
||||
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
|
||||
@@ -310,6 +312,11 @@ pub enum PendingMenuItem {
|
||||
target: PlatformLogin,
|
||||
label: String,
|
||||
},
|
||||
/// Custom provider login for one wire.
|
||||
CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi,
|
||||
label: String,
|
||||
},
|
||||
Quit,
|
||||
}
|
||||
impl PendingMenuItem {
|
||||
@@ -324,7 +331,9 @@ impl PendingMenuItem {
|
||||
}
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Login { label, .. } | Self::ApiKey { label, .. } => label,
|
||||
Self::Login { label, .. }
|
||||
| Self::ApiKey { label, .. }
|
||||
| Self::CustomProvider { label, .. } => label,
|
||||
Self::Quit => "Quit",
|
||||
}
|
||||
}
|
||||
@@ -366,7 +375,12 @@ pub fn pending_menu_items(
|
||||
use kigi_shell::agent::auth_method::AuthMethodKind;
|
||||
let mut items: Vec<PendingMenuItem> = Vec::new();
|
||||
for method in auth_methods {
|
||||
if let Some(target) = PlatformLogin::from_method_id(method.id()) {
|
||||
if let Some(api) = kigi_shell::agent::custom_providers::picker_api(method.id().0.as_ref()) {
|
||||
items.push(PendingMenuItem::CustomProvider {
|
||||
api,
|
||||
label: method.name().to_string(),
|
||||
});
|
||||
} else if let Some(target) = PlatformLogin::from_method_id(method.id()) {
|
||||
items.push(PendingMenuItem::ApiKey {
|
||||
target,
|
||||
label: method.name().to_string(),
|
||||
@@ -908,6 +922,8 @@ pub struct AppView {
|
||||
pub auth_start_mode: AuthMode,
|
||||
/// Text buffer for manual auth token paste (loopback mode).
|
||||
pub auth_code_input: String,
|
||||
/// Values collected by the custom provider login steps.
|
||||
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
|
||||
/// Monotonically increasing sequence number for auth requests.
|
||||
pub next_auth_request_seq: u64,
|
||||
/// Every session/chat/worktree/prompt action deferred behind startup gates.
|
||||
@@ -1111,6 +1127,7 @@ impl AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -2648,7 +2665,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_),
|
||||
mode: mode @ (AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_)),
|
||||
..
|
||||
} => {
|
||||
// Esc cancels BACK TO THE PICKER (unlike the OAuth flows,
|
||||
@@ -2664,6 +2681,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
}
|
||||
if key!(Enter).matches(key) {
|
||||
let trimmed = ctx.auth_code_input.trim().to_string();
|
||||
if matches!(mode, AuthMode::CustomProviderEntry(_)) {
|
||||
return InputOutcome::Action(Action::SubmitCustomProviderInput(trimmed));
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed));
|
||||
}
|
||||
@@ -2726,7 +2746,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
return InputOutcome::ActionThenForward(Action::NewSession);
|
||||
}
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_),
|
||||
mode:
|
||||
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||
..
|
||||
} => {
|
||||
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
|
||||
@@ -2889,6 +2910,9 @@ fn dispatch_pending_menu_action(
|
||||
Some(PendingMenuItem::ApiKey { target, .. }) => {
|
||||
InputOutcome::Action(Action::BeginPlatformKeyEntry(*target))
|
||||
}
|
||||
Some(PendingMenuItem::CustomProvider { api, .. }) => {
|
||||
InputOutcome::Action(Action::BeginCustomProviderEntry(*api))
|
||||
}
|
||||
Some(PendingMenuItem::Quit) if mid_session => InputOutcome::Action(Action::CancelLogin),
|
||||
Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit),
|
||||
None => InputOutcome::Unchanged,
|
||||
@@ -3223,6 +3247,7 @@ impl AppView {
|
||||
auth_methods: &self.auth_methods,
|
||||
login_label: self.login_label.as_deref(),
|
||||
auth_code_input: &self.auth_code_input,
|
||||
custom_entry: &self.custom_entry,
|
||||
clipboard_copied: self.auth_clipboard_copied,
|
||||
show_raw_url: self.auth_show_raw_url,
|
||||
tip,
|
||||
@@ -4358,6 +4383,7 @@ pub(crate) mod tests {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
@@ -7005,7 +7031,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||
assert_eq!(items.len(), 30, "29 login rows + Quit, got {items:?}");
|
||||
assert_eq!(items.len(), 32, "31 login rows + Quit, got {items:?}");
|
||||
assert_login_row(&items[0], "kimi-code", "Kimi Code (OAuth)");
|
||||
assert_login_row(&items[1], "xai-grok", "xAI Grok (subscription) (OAuth)");
|
||||
assert_login_row(
|
||||
@@ -7192,7 +7218,28 @@ pub(crate) mod tests {
|
||||
label: "MiniMax China (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[29], PendingMenuItem::Quit);
|
||||
assert_eq!(
|
||||
items[29],
|
||||
PendingMenuItem::CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi::OpenAi,
|
||||
label: "Custom provider (OpenAI compatible)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[30],
|
||||
PendingMenuItem::CustomProvider {
|
||||
api: kigi_shell::models::custom::CustomApi::Anthropic,
|
||||
label: "Custom provider (Anthropic compatible)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[31], PendingMenuItem::Quit);
|
||||
assert!(!items[29].connected(&fresh_user_auth_methods()));
|
||||
assert!(matches!(
|
||||
dispatch_pending_menu_action(&items, 30, false),
|
||||
InputOutcome::Action(Action::BeginCustomProviderEntry(
|
||||
kigi_shell::models::custom::CustomApi::Anthropic
|
||||
))
|
||||
));
|
||||
// The non-interactive methods must never appear as rows.
|
||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
@@ -7203,8 +7250,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
30,
|
||||
"xai.api_key / cached_token must not add rows (29 login rows + Quit)"
|
||||
32,
|
||||
"xai.api_key / cached_token must not add rows (31 login rows + Quit)"
|
||||
);
|
||||
}
|
||||
/// Startup lands on the picker only when there is a real choice: the
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The base URL, name, and key steps of the custom provider login.
|
||||
|
||||
use kigi_shell::models::custom::{
|
||||
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CustomEntryField {
|
||||
BaseUrl,
|
||||
Name,
|
||||
Key,
|
||||
}
|
||||
|
||||
/// Which field the input box holds, for which wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CustomEntryStep {
|
||||
pub api: CustomApi,
|
||||
pub field: CustomEntryField,
|
||||
}
|
||||
|
||||
impl CustomEntryStep {
|
||||
pub fn first(api: CustomApi) -> Self {
|
||||
Self {
|
||||
api,
|
||||
field: CustomEntryField::BaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the key is a secret; URL and name show as typed.
|
||||
pub fn masks_input(self) -> bool {
|
||||
self.field == CustomEntryField::Key
|
||||
}
|
||||
|
||||
pub fn placeholder(self) -> &'static str {
|
||||
match self.field {
|
||||
CustomEntryField::BaseUrl => "https://host/v1",
|
||||
CustomEntryField::Name => "provider name",
|
||||
CustomEntryField::Key => "Paste your API key here...",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one Enter press did.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CustomEntryOutcome {
|
||||
/// The input stays; `CustomEntryDraft::error` says why, if it does.
|
||||
Stay,
|
||||
/// Move to the next field with this text in the box.
|
||||
Next {
|
||||
field: CustomEntryField,
|
||||
prefill: String,
|
||||
},
|
||||
/// Every field is valid.
|
||||
Done {
|
||||
provider: CustomProvider,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Values collected so far, and the last refusal.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CustomEntryDraft {
|
||||
base_url: String,
|
||||
name: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomEntryDraft {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
name: String::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
pub fn instruction(&self, step: CustomEntryStep) -> String {
|
||||
match step.field {
|
||||
CustomEntryField::BaseUrl => format!(
|
||||
"Base URL of the {} API, for example https://host/v1",
|
||||
step.api.label()
|
||||
),
|
||||
CustomEntryField::Name => {
|
||||
"Name for this provider. Its models appear as name/model".to_owned()
|
||||
}
|
||||
CustomEntryField::Key => format!(
|
||||
"API key for {}. A local server that ignores auth takes any value",
|
||||
self.name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, step: CustomEntryStep, input: &str) -> CustomEntryOutcome {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
self.error = None;
|
||||
return CustomEntryOutcome::Stay;
|
||||
}
|
||||
self.error = None;
|
||||
match step.field {
|
||||
CustomEntryField::BaseUrl => match normalize_base_url(input) {
|
||||
Ok(url) => {
|
||||
let prefill = default_name(&url);
|
||||
self.base_url = url;
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Name,
|
||||
prefill,
|
||||
}
|
||||
}
|
||||
Err(e) => self.refuse(e),
|
||||
},
|
||||
CustomEntryField::Name => match validate_name(input) {
|
||||
Ok(()) => {
|
||||
self.name = input.to_owned();
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Key,
|
||||
prefill: String::new(),
|
||||
}
|
||||
}
|
||||
Err(e) => self.refuse(e),
|
||||
},
|
||||
CustomEntryField::Key => {
|
||||
match CustomProvider::new(&self.name, step.api, &self.base_url) {
|
||||
Ok(provider) => CustomEntryOutcome::Done {
|
||||
provider,
|
||||
key: input.to_owned(),
|
||||
},
|
||||
Err(e) => self.refuse(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse(&mut self, reason: impl std::fmt::Display) -> CustomEntryOutcome {
|
||||
self.error = Some(reason.to_string());
|
||||
CustomEntryOutcome::Stay
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn step(field: CustomEntryField) -> CustomEntryStep {
|
||||
CustomEntryStep {
|
||||
api: CustomApi::Anthropic,
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_valid_steps_build_the_provider() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
let next = draft.submit(
|
||||
step(CustomEntryField::BaseUrl),
|
||||
" https://api.gw.example/v1/ ",
|
||||
);
|
||||
assert_eq!(
|
||||
next,
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Name,
|
||||
prefill: "gw-example".to_owned()
|
||||
}
|
||||
);
|
||||
let next = draft.submit(step(CustomEntryField::Name), "gw");
|
||||
assert_eq!(
|
||||
next,
|
||||
CustomEntryOutcome::Next {
|
||||
field: CustomEntryField::Key,
|
||||
prefill: String::new()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
draft
|
||||
.instruction(step(CustomEntryField::Key))
|
||||
.contains("API key for gw")
|
||||
);
|
||||
|
||||
let done = draft.submit(step(CustomEntryField::Key), " sk-1 ");
|
||||
let CustomEntryOutcome::Done { provider, key } = done else {
|
||||
panic!("expected Done, got {done:?}");
|
||||
};
|
||||
assert_eq!(provider.name, "gw");
|
||||
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-1");
|
||||
assert!(draft.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_stays_on_the_field_with_a_reason() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "host.example"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.as_deref().is_some_and(|e| e.contains("http")));
|
||||
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "https://h.example/v1");
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::Name), "OpenAI"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.is_some());
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::Name), "openai"),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(
|
||||
draft
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|e| e.contains("built-in"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_stays_and_clears_the_last_error() {
|
||||
let mut draft = CustomEntryDraft::default();
|
||||
draft.submit(step(CustomEntryField::BaseUrl), "nope");
|
||||
assert!(draft.error.is_some());
|
||||
assert_eq!(
|
||||
draft.submit(step(CustomEntryField::BaseUrl), " "),
|
||||
CustomEntryOutcome::Stay
|
||||
);
|
||||
assert!(draft.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_key_is_masked() {
|
||||
assert!(step(CustomEntryField::Key).masks_input());
|
||||
assert!(!step(CustomEntryField::BaseUrl).masks_input());
|
||||
assert!(!step(CustomEntryField::Name).masks_input());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::app::actions::{Action, Effect};
|
||||
use crate::app::agent::AgentId;
|
||||
use crate::app::agent_view::AgentView;
|
||||
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin};
|
||||
use crate::app::custom_entry::{CustomEntryOutcome, CustomEntryStep};
|
||||
use crate::scrollback::block::RenderBlock;
|
||||
use crate::scrollback::blocks::SessionEvent;
|
||||
|
||||
@@ -325,6 +326,73 @@ pub(super) fn dispatch_begin_platform_key_entry(
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// A custom provider row was selected: open the first step (base URL).
|
||||
pub(super) fn dispatch_begin_custom_provider_entry(
|
||||
app: &mut AppView,
|
||||
api: kigi_shell::models::custom::CustomApi,
|
||||
) -> Vec<Effect> {
|
||||
let request_seq = app.next_auth_request_seq;
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep::first(api)),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter in a custom provider step: advance, stay with a reason, or save.
|
||||
pub(super) fn dispatch_submit_custom_provider_input(
|
||||
app: &mut AppView,
|
||||
input: String,
|
||||
) -> Vec<Effect> {
|
||||
let (request_seq, step) = match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => (*request_seq, *step),
|
||||
_ => return vec![],
|
||||
};
|
||||
match app.custom_entry.submit(step, &input) {
|
||||
CustomEntryOutcome::Stay => vec![],
|
||||
CustomEntryOutcome::Next { field, prefill } => {
|
||||
app.auth_code_input = prefill;
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::CustomProviderEntry(CustomEntryStep {
|
||||
api: step.api,
|
||||
field,
|
||||
}),
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
CustomEntryOutcome::Done { provider, key } => {
|
||||
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
|
||||
&provider.name,
|
||||
));
|
||||
app.custom_entry.clear();
|
||||
app.auth_code_input.clear();
|
||||
app.auth_state = AuthState::Authenticating {
|
||||
request_seq,
|
||||
handle: None,
|
||||
auth_url: None,
|
||||
mode: AuthMode::Pending,
|
||||
};
|
||||
vec![Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc in the API-key paste box: back to the login picker (no error line).
|
||||
/// Bumps the request seq so any stale in-flight auth result is dropped by
|
||||
/// the `AuthComplete`/`AuthFailed` guards.
|
||||
@@ -332,7 +400,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
if !matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::ApiKeyEntry(_),
|
||||
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
|
||||
..
|
||||
}
|
||||
) {
|
||||
@@ -340,6 +408,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
|
||||
}
|
||||
app.next_auth_request_seq += 1;
|
||||
app.auth_code_input.clear();
|
||||
app.custom_entry.clear();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
vec![]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Top-level action router: maps actions and action results to handlers.
|
||||
use super::auth::{
|
||||
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
|
||||
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_open_login_picker,
|
||||
dispatch_submit_auth_code, dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
|
||||
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
|
||||
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
|
||||
dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -889,6 +890,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||
Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app),
|
||||
Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key),
|
||||
Action::BeginCustomProviderEntry(api) => dispatch_begin_custom_provider_entry(app, api),
|
||||
Action::SubmitCustomProviderInput(input) => {
|
||||
dispatch_submit_custom_provider_input(app, input)
|
||||
}
|
||||
Action::CopyAuthUrl => {
|
||||
if let AuthState::Authenticating {
|
||||
auth_url: Some(url),
|
||||
|
||||
@@ -571,3 +571,141 @@ fn auth_complete_marks_the_authenticated_method_connected() {
|
||||
"other methods must stay unmarked"
|
||||
);
|
||||
}
|
||||
|
||||
fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
|
||||
match &app.auth_state {
|
||||
AuthState::Authenticating {
|
||||
mode: AuthMode::CustomProviderEntry(step),
|
||||
..
|
||||
} => step.field,
|
||||
other => panic!("expected a custom provider step, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom provider flow: URL, then name (prefilled from the host), then
|
||||
/// key; the last Enter emits ONE persist+authenticate effect for the provider.
|
||||
#[test]
|
||||
fn custom_provider_steps_end_in_one_persist_effect() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
|
||||
let effects = dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::Anthropic),
|
||||
&mut app,
|
||||
);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
let seq = match &app.auth_state {
|
||||
AuthState::Authenticating { request_seq, .. } => *request_seq,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://api.gw.example/v1/".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert_eq!(
|
||||
app.auth_code_input, "gw-example",
|
||||
"the name is prefilled from the host"
|
||||
);
|
||||
|
||||
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Key);
|
||||
assert!(app.auth_code_input.is_empty(), "the key box starts empty");
|
||||
|
||||
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
|
||||
match effects.as_slice() {
|
||||
[
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*request_seq, seq);
|
||||
assert_eq!(provider.name, "gw");
|
||||
assert_eq!(provider.api, CustomApi::Anthropic);
|
||||
assert_eq!(provider.base_url, "https://api.gw.example/v1");
|
||||
assert_eq!(key, "sk-gw");
|
||||
}
|
||||
other => panic!("expected exactly the persist effect, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
app.auth_state,
|
||||
AuthState::Authenticating {
|
||||
request_seq,
|
||||
mode: AuthMode::Pending,
|
||||
..
|
||||
} if request_seq == seq
|
||||
));
|
||||
assert_eq!(
|
||||
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
|
||||
Some("custom:gw")
|
||||
);
|
||||
assert!(app.auth_code_input.is_empty());
|
||||
}
|
||||
|
||||
/// A refused field keeps the user on it and does not emit an effect.
|
||||
#[test]
|
||||
fn custom_provider_refusals_stay_on_the_field() {
|
||||
use crate::app::custom_entry::CustomEntryField;
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("host.example".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::BaseUrl);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
dispatch(
|
||||
Action::SubmitCustomProviderInput("anthropic".into()),
|
||||
&mut app
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(custom_field(&app), CustomEntryField::Name);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
}
|
||||
|
||||
/// Esc at any step returns to the picker and drops the draft.
|
||||
#[test]
|
||||
fn cancel_custom_provider_entry_returns_to_picker() {
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
dispatch(
|
||||
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
|
||||
&mut app,
|
||||
);
|
||||
dispatch(Action::SubmitCustomProviderInput("nope".into()), &mut app);
|
||||
assert!(app.custom_entry.error.is_some());
|
||||
let seq_before = app.next_auth_request_seq;
|
||||
|
||||
let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(matches!(app.auth_state, AuthState::Pending { error: None }));
|
||||
assert!(app.custom_entry.error.is_none());
|
||||
assert!(app.next_auth_request_seq > seq_before);
|
||||
}
|
||||
@@ -126,6 +126,7 @@ fn test_app() -> AppView {
|
||||
login_method_id: None,
|
||||
auth_start_mode: AuthMode::Pending,
|
||||
auth_code_input: String::new(),
|
||||
custom_entry: Default::default(),
|
||||
next_auth_request_seq: 1,
|
||||
deferred_startup: Default::default(),
|
||||
auth_use_oauth: false,
|
||||
|
||||
@@ -1911,6 +1911,35 @@ pub(crate) fn execute(
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::PersistCustomProviderAndAuthenticate {
|
||||
request_seq,
|
||||
provider,
|
||||
key,
|
||||
} => {
|
||||
let tx = acp_tx.clone();
|
||||
let abort_handle = tasks.spawn(async move {
|
||||
// Persist first so authenticate finds the definition and key.
|
||||
if let Err(e) =
|
||||
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
|
||||
.await
|
||||
{
|
||||
let error = format!("Couldn't save the provider: {e:#}");
|
||||
ulog::error(
|
||||
"custom provider persist failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "error" : & error })),
|
||||
);
|
||||
return TaskResult::AuthFailed {
|
||||
request_seq,
|
||||
error,
|
||||
};
|
||||
}
|
||||
let method_id =
|
||||
kigi_shell::agent::custom_providers::login_method_id(&provider.name);
|
||||
send_authenticate(&tx, request_seq, method_id, false, false).await
|
||||
});
|
||||
meta.auth_abort_handle = Some((request_seq, abort_handle));
|
||||
}
|
||||
Effect::SubmitAuthCode { request_seq, code } => {
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod agent_view;
|
||||
pub mod app_view;
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod custom_entry;
|
||||
pub use crate::link_opener;
|
||||
/// Off-thread full-file syntax highlight upgrade for edit diffs.
|
||||
pub mod edit_highlight_worker;
|
||||
|
||||
@@ -461,6 +461,7 @@ pub struct WelcomeRenderParams<'a> {
|
||||
pub auth_methods: &'a [acp::AuthMethod],
|
||||
pub login_label: Option<&'a str>,
|
||||
pub auth_code_input: &'a str,
|
||||
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
|
||||
pub clipboard_copied: bool,
|
||||
pub show_raw_url: bool,
|
||||
pub tip: Option<&'a str>,
|
||||
@@ -603,6 +604,7 @@ pub fn render_welcome(
|
||||
auth_url.as_deref(),
|
||||
*mode,
|
||||
params.auth_code_input,
|
||||
params.custom_entry,
|
||||
params.clipboard_copied,
|
||||
params.show_raw_url,
|
||||
);
|
||||
@@ -1176,6 +1178,7 @@ fn render_welcome_authenticating(
|
||||
auth_url: Option<&str>,
|
||||
mode: AuthMode,
|
||||
auth_code_input: &str,
|
||||
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
|
||||
clipboard_copied: bool,
|
||||
show_raw_url: bool,
|
||||
) -> (Option<Rect>, Option<Rect>) {
|
||||
@@ -1270,6 +1273,7 @@ fn render_welcome_authenticating(
|
||||
theme,
|
||||
auth_code_input,
|
||||
"Paste your token here...",
|
||||
true,
|
||||
);
|
||||
|
||||
// Hints
|
||||
@@ -1290,81 +1294,45 @@ fn render_welcome_authenticating(
|
||||
}
|
||||
|
||||
AuthMode::ApiKeyEntry(target) => {
|
||||
// Platform API-key paste box: instruction + input + hints. No
|
||||
// auth-URL machinery — the key comes from the platform console.
|
||||
let h_pad: u16 = content_area.width / 6;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
// Platform API-key paste box: the key comes from the platform console.
|
||||
let instruction = format!(
|
||||
"Paste your {} API key (from {})",
|
||||
target.vendor(),
|
||||
target.console_host()
|
||||
);
|
||||
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
let msg = Line::from(Span::styled(
|
||||
instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(msg)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(msg_area, buf);
|
||||
|
||||
let [_, prompt_centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
render_entry_box(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
auth_code_input,
|
||||
"Paste your API key here...",
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
&EntryBox {
|
||||
instruction: &instruction,
|
||||
error: None,
|
||||
input: auth_code_input,
|
||||
placeholder: "Paste your API key here...",
|
||||
masked: true,
|
||||
},
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
|
||||
AuthMode::CustomProviderEntry(step) => {
|
||||
let instruction = custom_entry.instruction(step);
|
||||
render_entry_box(
|
||||
content_area,
|
||||
buf,
|
||||
theme,
|
||||
top_pad,
|
||||
logo_line_count,
|
||||
&EntryBox {
|
||||
instruction: &instruction,
|
||||
error: custom_entry.error.as_deref(),
|
||||
input: auth_code_input,
|
||||
placeholder: step.placeholder(),
|
||||
masked: step.masks_input(),
|
||||
},
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
|
||||
@@ -1971,12 +1939,109 @@ pub(crate) fn render_session_picker(
|
||||
}
|
||||
|
||||
/// Render the auth token input box (loopback mode).
|
||||
/// One text field under the logo: instruction, optional error, input box, hints.
|
||||
struct EntryBox<'a> {
|
||||
instruction: &'a str,
|
||||
error: Option<&'a str>,
|
||||
input: &'a str,
|
||||
placeholder: &'a str,
|
||||
masked: bool,
|
||||
}
|
||||
|
||||
fn render_entry_box(
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
top_pad: u16,
|
||||
logo_line_count: u16,
|
||||
entry: &EntryBox<'_>,
|
||||
) {
|
||||
let h_pad: u16 = content_area.width / 6;
|
||||
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
|
||||
let rows = |text: &str| (text.len() as u16).div_ceil(inner_width);
|
||||
let msg_height = rows(entry.instruction) + entry.error.map_or(0, rows);
|
||||
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_line_count),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// instruction
|
||||
Constraint::Length(msg_height),
|
||||
// gap
|
||||
Constraint::Min(1),
|
||||
// prompt box
|
||||
Constraint::Length(5),
|
||||
// gap
|
||||
Constraint::Length(1),
|
||||
// hints
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
render_logo(logo_area, buf, theme, content_area.height);
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
entry.instruction,
|
||||
Style::default().fg(theme.gray_bright),
|
||||
))
|
||||
.alignment(Alignment::Center),
|
||||
];
|
||||
if let Some(error) = entry.error {
|
||||
lines.push(
|
||||
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
|
||||
.alignment(Alignment::Center),
|
||||
);
|
||||
}
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(Block::default().padding(Padding::horizontal(h_pad)))
|
||||
.render(msg_area, buf);
|
||||
|
||||
let [_, prompt_centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_area.width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(prompt_area);
|
||||
render_auth_input_box(
|
||||
prompt_centered,
|
||||
buf,
|
||||
theme,
|
||||
entry.input,
|
||||
entry.placeholder,
|
||||
entry.masked,
|
||||
);
|
||||
|
||||
let hints = Line::from(vec![
|
||||
Span::styled(
|
||||
"enter",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" submit ", Style::default().fg(theme.gray)),
|
||||
Span::styled(
|
||||
"esc",
|
||||
Style::default()
|
||||
.fg(theme.accent_user)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" back", Style::default().fg(theme.gray)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
Paragraph::new(hints).render(hint_area, buf);
|
||||
}
|
||||
|
||||
fn render_auth_input_box(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
input: &str,
|
||||
placeholder: &str,
|
||||
masked: bool,
|
||||
) {
|
||||
let prompt_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -1993,8 +2058,10 @@ fn render_auth_input_box(
|
||||
if inner.height > 0 && inner.width > 2 {
|
||||
let display = if input.is_empty() {
|
||||
placeholder.to_string()
|
||||
} else {
|
||||
} else if masked {
|
||||
mask_auth_token_for_display(input)
|
||||
} else {
|
||||
input.to_string()
|
||||
};
|
||||
|
||||
let style = if input.is_empty() {
|
||||
@@ -2078,6 +2145,8 @@ fn mask_auth_token_for_display(input: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
static EMPTY_CUSTOM_ENTRY: crate::app::custom_entry::CustomEntryDraft =
|
||||
crate::app::custom_entry::CustomEntryDraft::new();
|
||||
use crate::app::app_view::SessionPickerEntry;
|
||||
use crate::views::picker::PickerState;
|
||||
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
|
||||
@@ -2127,6 +2196,7 @@ mod tests {
|
||||
auth_methods: &[],
|
||||
login_label: None,
|
||||
auth_code_input: "",
|
||||
custom_entry: &EMPTY_CUSTOM_ENTRY,
|
||||
clipboard_copied: false,
|
||||
show_raw_url: false,
|
||||
tip: None,
|
||||
@@ -2325,6 +2395,71 @@ mod tests {
|
||||
|
||||
/// The Moonshot API-key entry arm renders the platform copy, the paste
|
||||
/// box, and the esc-back hint — and no OAuth-URL affordances.
|
||||
fn render_custom_entry(
|
||||
step: crate::app::custom_entry::CustomEntryStep,
|
||||
draft: &crate::app::custom_entry::CustomEntryDraft,
|
||||
input: &str,
|
||||
) -> String {
|
||||
let area = Rect::new(0, 0, 100, 40);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_welcome_authenticating(
|
||||
area,
|
||||
&mut buf,
|
||||
&Theme::current(),
|
||||
logo_line_count(area.height),
|
||||
None,
|
||||
AuthMode::CustomProviderEntry(step),
|
||||
input,
|
||||
draft,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
buffer_text(&buf)
|
||||
}
|
||||
|
||||
/// The URL and name steps show typed text; only the key step masks it.
|
||||
#[test]
|
||||
fn custom_entry_arm_masks_only_the_key() {
|
||||
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryField, CustomEntryStep};
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
let step = |field| CustomEntryStep {
|
||||
api: CustomApi::OpenAi,
|
||||
field,
|
||||
};
|
||||
let draft = CustomEntryDraft::new();
|
||||
|
||||
let url = render_custom_entry(
|
||||
step(CustomEntryField::BaseUrl),
|
||||
&draft,
|
||||
"https://very-long-host.example/v1",
|
||||
);
|
||||
assert!(url.contains("https://very-long-host.example/v1"), "{url}");
|
||||
assert!(url.contains("OpenAI compatible"), "{url}");
|
||||
|
||||
let key = render_custom_entry(step(CustomEntryField::Key), &draft, "sk-abcdefghijklmnop");
|
||||
assert!(
|
||||
!key.contains("sk-abcdefghij"),
|
||||
"the key prefix must stay hidden: {key}"
|
||||
);
|
||||
assert!(key.contains("mnop"), "{key}");
|
||||
|
||||
let empty = render_custom_entry(step(CustomEntryField::Name), &draft, "");
|
||||
assert!(empty.contains("provider name"), "{empty}");
|
||||
}
|
||||
|
||||
/// A refusal shows under the instruction so the user sees what to fix.
|
||||
#[test]
|
||||
fn custom_entry_arm_shows_the_refusal() {
|
||||
use crate::app::custom_entry::{CustomEntryDraft, CustomEntryStep};
|
||||
use kigi_shell::models::custom::CustomApi;
|
||||
let step = CustomEntryStep::first(CustomApi::Anthropic);
|
||||
let mut draft = CustomEntryDraft::new();
|
||||
draft.submit(step, "host.example");
|
||||
let text = render_custom_entry(step, &draft, "host.example");
|
||||
assert!(text.contains("Anthropic compatible"), "{text}");
|
||||
assert!(text.contains("must start with http"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_entry_arm_shows_platform_copy_and_paste_box() {
|
||||
let area = Rect::new(0, 0, 80, 40);
|
||||
@@ -2343,6 +2478,7 @@ mod tests {
|
||||
)),
|
||||
// auth_code_input
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3010,6 +3146,7 @@ mod tests {
|
||||
AuthMode::Device,
|
||||
// auth_code_input — unused in device mode
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
@@ -3066,6 +3203,7 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3093,6 +3231,7 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3131,6 +3270,7 @@ mod tests {
|
||||
Some(url),
|
||||
AuthMode::Device,
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
false,
|
||||
// show_raw_url
|
||||
true,
|
||||
@@ -3172,6 +3312,7 @@ mod tests {
|
||||
AuthMode::Command,
|
||||
// auth_code_input — unused
|
||||
"",
|
||||
&crate::app::custom_entry::CustomEntryDraft::new(),
|
||||
// clipboard_copied
|
||||
false,
|
||||
// show_raw_url
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const CUSTOM_KEY: &str = "sk-custom-provider-key";
|
||||
const HOUSE_KEY: &str = "test-key-for-ci";
|
||||
|
||||
/// Declares `[platforms.proxy]` in the pager's isolated home.
|
||||
fn declare_provider(content: &ContentController, api: &str) {
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create .kigi");
|
||||
std::fs::write(
|
||||
kigi_home.join("config.toml"),
|
||||
format!(
|
||||
"[platforms.proxy]\nbase_url = \"{}\"\napi = \"{api}\"\napi_key = \"{CUSTOM_KEY}\"\n",
|
||||
content.url()
|
||||
),
|
||||
)
|
||||
.expect("write config.toml");
|
||||
}
|
||||
|
||||
fn turn_requests(
|
||||
content: &ContentController,
|
||||
path: &str,
|
||||
) -> Vec<kigi_test_support::mock_server::LogEntry> {
|
||||
content
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.path == path
|
||||
&& e.body
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("model"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("custom-m1")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn run_turn(content: &ContentController) -> PtyHarness {
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} from the custom provider."
|
||||
));
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[])
|
||||
.expect("spawn pager with content");
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response on screen");
|
||||
harness
|
||||
}
|
||||
|
||||
/// A declared OpenAI-compatible provider supplies the catalog and the turn:
|
||||
/// the request goes to `/chat/completions` for the fetched model with the
|
||||
/// provider's own key. The house key, which is set in this environment on the
|
||||
/// same loopback URL as the session's coding endpoint, never rides it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn custom_openai_provider_lists_models_and_uses_its_own_key() {
|
||||
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
|
||||
.await
|
||||
.expect("start content");
|
||||
declare_provider(&content, "openai");
|
||||
|
||||
let mut harness = run_turn(&content).await;
|
||||
|
||||
let turns = turn_requests(&content, "/v1/chat/completions");
|
||||
assert!(
|
||||
!turns.is_empty(),
|
||||
"no chat completion for custom-m1: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
for turn in &turns {
|
||||
assert_eq!(
|
||||
turn.authorization.as_deref(),
|
||||
Some(format!("Bearer {CUSTOM_KEY}").as_str()),
|
||||
"the custom provider's own key must ride, never {HOUSE_KEY}"
|
||||
);
|
||||
}
|
||||
|
||||
harness
|
||||
.inject_keys(b"/model ")
|
||||
.expect("open the /model list");
|
||||
harness
|
||||
.wait_for_text("proxy", Duration::from_secs(10))
|
||||
.expect("the fetched model is listed with its provider name");
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
/// An Anthropic-compatible provider is reached on `/messages` with
|
||||
/// `x-api-key` and `anthropic-version`, and no Bearer header.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn custom_anthropic_provider_uses_messages_and_x_api_key() {
|
||||
let content = ContentController::start_with_models(vec![MockModel::new("custom-m1")])
|
||||
.await
|
||||
.expect("start content");
|
||||
declare_provider(&content, "anthropic");
|
||||
|
||||
let mut harness = run_turn(&content).await;
|
||||
|
||||
let turns = turn_requests(&content, "/v1/messages");
|
||||
assert!(
|
||||
!turns.is_empty(),
|
||||
"no messages request for custom-m1: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
for turn in &turns {
|
||||
assert_eq!(
|
||||
turn.header("x-api-key"),
|
||||
Some(CUSTOM_KEY),
|
||||
"request headers: {:?}",
|
||||
turn.headers
|
||||
);
|
||||
assert!(turn.header("anthropic-version").is_some());
|
||||
assert_eq!(
|
||||
turn.authorization, None,
|
||||
"an x-api-key wire carries no Bearer"
|
||||
);
|
||||
}
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -15,6 +15,8 @@ mod agent_response;
|
||||
mod auto_compact_top_row;
|
||||
#[path = "pty_e2e/basename_path_demo_pty.rs"]
|
||||
mod basename_path_demo_pty;
|
||||
#[path = "pty_e2e/custom_provider.rs"]
|
||||
mod custom_provider;
|
||||
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
|
||||
mod doubled_lines_out_of_band_repro;
|
||||
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]
|
||||
|
||||
Reference in new issue
Block a user