From 8b9d835db93e196ba0cd00bc459a2a0ef1d8d04b Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 18 Oct 2026 00:04:16 +0100 Subject: [PATCH 1/5] feat: add direct Anthropic API authentication and completions --- Cargo.lock | 19 + Cargo.toml | 1 + README.md | 8 +- .../user/getting-started-and-configuration.md | 48 +- docs/user/provider-usage.md | 2 + src/main.rs | 63 ++- src/protocols/acp.rs | 19 +- src/protocols/acp/v2.rs | 11 +- src/provider/adapter.rs | 424 +++++++++++++- src/provider/anthropic_auth.rs | 522 ++++++++++++++++++ src/provider/mod.rs | 7 + src/runtime.rs | 1 + 12 files changed, 1112 insertions(+), 13 deletions(-) create mode 100644 src/provider/anthropic_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 1163f2f3..f4291410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,6 +328,24 @@ dependencies = [ "url", ] +[[package]] +name = "agentkit-provider-anthropic" +version = "0.10.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6845da26faadc0b4cd7c709e7d6a7eb85397f7dbc39fd43f3e0c04aeb1505af3" +dependencies = [ + "agentkit-core", + "agentkit-http", + "agentkit-loop", + "agentkit-tools-core", + "async-trait", + "base64 0.22.1", + "futures-util", + "reqwest", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "agentkit-provider-openai" version = "0.10.13" @@ -3115,6 +3133,7 @@ dependencies = [ "agentkit-loop", "agentkit-mcp", "agentkit-plugins", + "agentkit-provider-anthropic", "agentkit-provider-openai", "agentkit-provider-openrouter", "agentkit-task-manager", diff --git a/Cargo.toml b/Cargo.toml index 5d47bc58..4b0ea93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ agentkit-loop = { version = "=0.10.13", features = ["otel"] } agentkit-mcp = "=0.11.2" agentkit-http = "=0.10.7" agentkit-plugins = "=0.10.7" +agentkit-provider-anthropic = "=0.10.10" agentkit-provider-openai = "=0.10.13" agentkit-provider-openrouter = "=0.10.8" agentkit-task-manager = "=0.10.7" diff --git a/README.md b/README.md index 11c0c5f4..04b961d6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Fewer model round trips repeat less context and complete more work in each reque - **Reusable subagents.** A subagent is a reusable value. You can continue, fork, inspect, and close a subagent. You can require JSON that matches a schema. You can also use Claude Code, Codex, Cursor, or Kit as the subagent harness over ACP. - **Open protocols.** Kit supports ACP v1 and v2 over stdio, HTTP/SSE, and WebSocket. Kit supports A2A v1 in both directions. It also supports MCP, Agent Skills, and Agent Plugin packages. - **Long sessions.** Kit synchronizes each append-only JSONL transcript item to disk before it accepts the item. It compacts context automatically at 80% of the context window. You can resume a session from `tui`, `prompt`, or any ACP client. Kit also uses crash-safe locks. It retries eligible `openai-subscription` failures for up to 24 hours. -- **Model choice.** Kit supports ChatGPT subscriptions through native OAuth. It also supports models through OpenRouter and the Speakeasy AI Control Plane. Use `/model` to change the model during a session. +- **Model choice.** Kit supports ChatGPT subscriptions through native OAuth. It also supports the Anthropic API directly and models through OpenRouter and the Speakeasy AI Control Plane. Use `/model` to change the model during a session. - **Small runtime.** Kit has no permissions framework, sandbox, or web UI. The `--root` option sets the working directory. Kit is a runtime, not a security boundary. Run Kit inside a security boundary that you trust. See the [trust model](docs/user/security-limits-and-troubleshooting.md). ## Install @@ -165,6 +165,12 @@ kit serve --root /path/to/project --remote-acp --no-a2a --no-stdio --http 0.0.0. OpenRouter and Speakeasy use the same login process. Run `kit auth login openrouter` or `kit auth login speakeasy`. Then add `--provider openrouter --model anthropic/claude-sonnet-5` to the Kit command that you run. Providers and MCP servers share one credential backend (`--credential-store keychain|file|memory`). +For direct Anthropic API usage, run `kit auth login anthropic`, then select +`--provider anthropic --model claude-sonnet-5-5`. Use a single-workspace Console +API key; `ANTHROPIC_API_KEY` is also supported. Eligible Max subscribers can claim +API credits into the Console organization that owns the key. See +[Anthropic setup and credit billing](docs/user/getting-started-and-configuration.md#direct-anthropic-api). + ### Coming from Claude Code or Codex Read [Migrate from Claude Code or Codex](docs/user/migrating-from-claude-code-and-codex.md) for the file mappings, safety boundaries, and settings that need manual review. Or start `kit tui` in the existing project and paste this prompt: diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index 2e6a91b8..857c8d2e 100644 --- a/docs/user/getting-started-and-configuration.md +++ b/docs/user/getting-started-and-configuration.md @@ -40,7 +40,7 @@ tarball with the full feature set, not a static musl tarball. ## Choose a provider and authenticate -Kit supports `openai-subscription`, `openrouter`, and `speakeasy`. The default provider is `openai-subscription`, and the default model is `gpt-5.4`. A `--provider` or `--model` command-line value overrides the corresponding value in `~/.kit/config.toml`. +Kit supports `openai-subscription`, `anthropic`, `openrouter`, and `speakeasy`. The default provider is `openai-subscription`, and the default model is `gpt-5.4`. A `--provider` or `--model` command-line value overrides the corresponding value in `~/.kit/config.toml`. ### ChatGPT subscription with native OpenAI login @@ -70,6 +70,48 @@ Use `kit auth status openai` to check the credential. the local credential if revocation fails. `kit auth logout openai --local-only` skips revocation and prints a warning. +### Direct Anthropic API + +Use a **single-workspace API key** from [Claude Console](https://platform.claude.com/settings/keys). +Kit uses the Anthropic API directly, not Claude Max subscription OAuth: + +```sh +kit auth login anthropic --credential-store keychain +kit auth status anthropic --credential-store keychain +kit prompt --provider anthropic --model claude-sonnet-5-5 \ + --credential-store keychain "Summarize this project" +kit auth logout anthropic --credential-store keychain +``` + +Login reads the key at a hidden prompt, validates it with the non-inference Models +API, and saves it only on success. Use the same persistent credential store for +login and runtime commands. File storage is also supported with +`--credential-store file --credential-dir /path/to/private-directory`. +Alternatively, set `ANTHROPIC_API_KEY`; a nonempty environment key overrides the +saved key. Do not put API keys in command-line arguments or `config.toml`. +Multi-workspace keys that require an explicit workspace header are not supported. + +Use direct Anthropic model IDs with `--model`, not OpenRouter's +`anthropic/…` IDs. Responses have a 16,384-token output limit. Leave +`reasoning_effort` unset for the provider default, or select `low`, `medium`, or +`high` for a model that supports extended thinking. Logout deletes the locally saved key; revoke it in Claude +Console to disable it remotely. An environment key remains active after logout. + +#### Using Claude Max API credits + +Eligible Max subscribers can claim monthly API credits into a linked Console +organization. Create the key in that organization to use the credits with Kit. +Anthropic applies the credits automatically; Kit does not access your Claude +subscription credentials or manage the credit balance. + +Anthropic currently lists $100 per billing cycle for Max 5x and $200 for Max 20x, +claimable after seven days on an eligible plan. Credits expire each cycle and do +not roll over. Requests can continue against purchased credits, auto-reload, or +invoiced billing after the included credits run out. Check Console billing and +workspace spend limits before use. See Anthropic's +[eligibility and credit terms](https://support.claude.com/en/articles/17154008-monthly-api-credits-for-max-and-team-plans) +for current details. Credits in Claude Console do not pay for OpenRouter usage. + ### OpenRouter API key and model Set `OPENROUTER_API_KEY`, select `openrouter`, and use an OpenRouter model identifier: @@ -286,7 +328,7 @@ subdir = "agent-plugins/example" `root`, `provider`, `model`, and credential settings apply to all four runtime commands. Subagent model aliases and explicit-override allowlists are scoped by fully qualified harness under `[subagent.harnesses."acp.name"]`. Omitting `allow_model_overrides` permits all explicit model selections accepted by that harness; an empty list disables explicit model overrides. This policy does not restrict the harness's inherited or default model. -`a2a` applies to `serve` and `tui`. Configured plugins can provide MCP servers without `mcp_config`; supported `stdio` and `streamable-http` declarations are registered, while `sse` declarations are skipped with a stderr diagnostic. MCP servers merge by name in this order: plugins, configured `mcp_config`, `/.mcp.json`, then `--mcp-config`. Higher layers replace whole conflicts while preserving non-conflicting lower entries; live removal reveals the next lower layer. Plugin data is stored under `/plugin-data/`. See [Agent Plugins](agent-plugins.md) for placeholders, collision rules, and ACP child behavior. `otel_endpoint` enables OTLP export of AgentKit's GenAI trace spans. `otel_protocol` accepts exactly `grpc`, `http/protobuf`, or `http/json`; gRPC is the default. For either HTTP protocol, Kit appends `/v1/traces` to a base endpoint unless it already ends with that path. `credential_store` selects one backend for OpenAI, Speakeasy, and MCP and defaults to `memory`; selecting `file` requires `credential_dir`, while a credential directory is invalid with `memory` or `keychain`. Memory credentials are process-local and are not shared with the TUI server process or nested Kit children. Standalone OpenAI and Speakeasy login requires persistent `keychain` or `file` storage. ACP profiles are direct executable-and-argument configurations, not shell command strings. `[subagent].harness` must name an available fully qualified profile such as `acp.review`; otherwise startup reports `unknown subagent ACP harness`. When no subagent harness is selected, the built-in `acp.kit` profile is used. +`a2a` applies to `serve` and `tui`. Configured plugins can provide MCP servers without `mcp_config`; supported `stdio` and `streamable-http` declarations are registered, while `sse` declarations are skipped with a stderr diagnostic. MCP servers merge by name in this order: plugins, configured `mcp_config`, `/.mcp.json`, then `--mcp-config`. Higher layers replace whole conflicts while preserving non-conflicting lower entries; live removal reveals the next lower layer. Plugin data is stored under `/plugin-data/`. See [Agent Plugins](agent-plugins.md) for placeholders, collision rules, and ACP child behavior. `otel_endpoint` enables OTLP export of AgentKit's GenAI trace spans. `otel_protocol` accepts exactly `grpc`, `http/protobuf`, or `http/json`; gRPC is the default. For either HTTP protocol, Kit appends `/v1/traces` to a base endpoint unless it already ends with that path. `credential_store` selects one backend for model providers and MCP and defaults to `memory`; selecting `file` requires `credential_dir`, while a credential directory is invalid with `memory` or `keychain`. Memory credentials are process-local and are not shared with the TUI server process or nested Kit children. Standalone provider login requires persistent `keychain` or `file` storage. ACP profiles are direct executable-and-argument configurations, not shell command strings. `[subagent].harness` must name an available fully qualified profile such as `acp.review`; otherwise startup reports `unknown subagent ACP harness`. When no subagent harness is selected, the built-in `acp.kit` profile is used. Agent hooks are configured in `~/.agents/hooks.json` and `/.agents/hooks.json`. See [Agent Hooks Protocol](agent-hooks-protocol.md). @@ -376,7 +418,7 @@ The hidden-tool catalog is captured when Kit creates the session's compose sourc ## Troubleshoot startup and configuration -- **`invalid config ...`**: validate known values and TOML types. Unknown fields are ignored; provider values are exactly `openai-subscription`, `openrouter`, and `speakeasy`, and credential-store values are `memory`, `keychain`, and `file`. +- **`invalid config ...`**: validate known values and TOML types. Unknown fields are ignored; provider values are exactly `openai-subscription`, `anthropic`, `openrouter`, and `speakeasy`, and credential-store values are `memory`, `keychain`, and `file`. - **`could not read config ...`**: check permissions and that `$HOME/.kit/config.toml` is a readable regular file. Deleting an unwanted config is valid because a missing file falls back to defaults. - **`credential_dir is required when credential_store is file`**: add the directory or choose another store. - **`credential_dir requires credential_store to be file`**: remove the directory or select `file`. diff --git a/docs/user/provider-usage.md b/docs/user/provider-usage.md index 6aca7315..e026f75f 100644 --- a/docs/user/provider-usage.md +++ b/docs/user/provider-usage.md @@ -10,6 +10,8 @@ kit usage openrouter --credential-store keychain ``` Supported providers are `openai` (also `openai-subscription`) and `openrouter`. +Direct Anthropic API credit balances are not available through `kit usage`; check +Billing in Claude Console, including any claimed Max credits. Omit the provider to check authenticated supported providers. The command uses the effective credential storage from configuration and CLI overrides (`--credential-store` and `--credential-dir`). OpenRouter also uses the effective `--openrouter-api-key` or diff --git a/src/main.rs b/src/main.rs index 731e6628..13cfd2fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -596,7 +596,7 @@ tables, and quoted strings). Use --string to force literal string input. Root settings: root Working directory and project context model Default model ID - provider Model provider: openai-subscription, openrouter, speakeasy + provider Model provider: openai-subscription, openrouter, speakeasy, anthropic reasoning_effort Reasoning effort: low, medium, high; unset for provider default a2a A2A listener bind address (e.g. 127.0.0.1:7331) mcp_config MCP server configuration path @@ -1282,6 +1282,7 @@ impl ValueEnum for AuthProvider { Self::Openrouter, Self::Speakeasy, Self::Typesafe, + Self::Anthropic, ] } fn to_possible_value(&self) -> Option { @@ -1290,6 +1291,7 @@ impl ValueEnum for AuthProvider { Self::Openrouter => "openrouter", Self::Speakeasy => "speakeasy", Self::Typesafe => "typesafe", + Self::Anthropic => "anthropic", })) } } @@ -1616,6 +1618,7 @@ enum AuthProvider { Openrouter, Speakeasy, Typesafe, + Anthropic, } enum AuthAction { @@ -1869,11 +1872,15 @@ async fn execute_auth( OpenRouter(kit::provider::OpenRouterAuthCommand), Speakeasy(kit::provider::SpeakeasyAuthCommand), TypeSafe(kit::provider::TypeSafeAuthCommand), + Anthropic(kit::provider::AnthropicAuthCommand), Logout(kit::ProviderKind, bool), } let command = match action { AuthAction::Login { provider } => match provider { AuthProvider::Openai => Execution::OpenAi(kit::provider::OpenAiAuthCommand::Login), + AuthProvider::Anthropic => { + Execution::Anthropic(kit::provider::AnthropicAuthCommand::Login) + } AuthProvider::Openrouter => { Execution::OpenRouter(kit::provider::OpenRouterAuthCommand::Login) } @@ -1886,6 +1893,9 @@ async fn execute_auth( }, AuthAction::Status { provider } => match provider { AuthProvider::Openai => Execution::OpenAi(kit::provider::OpenAiAuthCommand::Status), + AuthProvider::Anthropic => { + Execution::Anthropic(kit::provider::AnthropicAuthCommand::Status) + } AuthProvider::Openrouter => { Execution::OpenRouter(kit::provider::OpenRouterAuthCommand::Status) } @@ -1906,6 +1916,7 @@ async fn execute_auth( AuthProvider::Openrouter => { Execution::Logout(kit::ProviderKind::OpenRouter, *local_only) } + AuthProvider::Anthropic => Execution::Logout(kit::ProviderKind::Anthropic, *local_only), AuthProvider::Speakeasy => Execution::Logout(kit::ProviderKind::Speakeasy, *local_only), AuthProvider::Typesafe => { Execution::TypeSafe(kit::provider::TypeSafeAuthCommand::Logout { @@ -1924,6 +1935,7 @@ async fn execute_auth( .map(|(key, source)| (key, *source)), ), Execution::Speakeasy(command) => kit::provider::execute_speakeasy_auth(command, &storage), + Execution::Anthropic(command) => kit::provider::execute_anthropic_auth(command, &storage), Execution::TypeSafe(command) => kit::provider::execute_typesafe_auth(command, &storage), Execution::Logout(provider, local_only) => kit::provider::execute_provider_logout( provider, @@ -3073,6 +3085,33 @@ mod tests { assert!(plugins.snapshot().package_roots.is_empty()); } + #[test] + fn config_accepts_anthropic_without_changing_existing_provider_defaults() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + for provider in [ + "openai-subscription", + "openrouter", + "speakeasy", + "anthropic", + ] { + fs::write( + &path, + format!("provider = {provider:?}\nmodel = \"claude-sonnet-4-6\"\n"), + ) + .unwrap(); + let config = Config::load(&path).unwrap(); + assert_eq!(config.provider(None).as_str(), provider); + } + fs::write(&path, "provider = \"unknown\"\n").unwrap(); + assert!(Config::load(&path).is_err()); + fs::write(&path, "").unwrap(); + assert_eq!( + Config::load(&path).unwrap().provider(None), + kit::ProviderKind::OpenAiSubscription + ); + } + #[test] fn config_file_supplies_defaults_and_cli_values_win() { let directory = tempfile::tempdir().unwrap(); @@ -3897,7 +3936,7 @@ future_option = true assert_eq!(help.kind(), clap::error::ErrorKind::DisplayHelp); for setting in [ "Default model ID", - "Model provider: openai-subscription, openrouter, speakeasy", + "Model provider: openai-subscription, openrouter, speakeasy, anthropic", "Reasoning effort: low, medium, high; unset for provider default", "A2A listener bind address (e.g. 127.0.0.1:7331)", "Credential backend: memory, keychain, file", @@ -4111,7 +4150,26 @@ future_option = true #[test] fn auth_commands_parse_without_runtime_arguments() { + assert!( + Cli::try_parse_from(["kit", "auth", "logout", "anthropic", "--local-only"]).is_ok() + ); + assert!( + Cli::try_parse_from([ + "kit", + "acp", + "--provider", + "anthropic", + "--model", + "claude-sonnet-4-6" + ]) + .is_ok() + ); + assert!( + Cli::try_parse_from(["kit", "auth", "login", "anthropic", "--api-key", "secret"]) + .is_err() + ); for action in ["login", "status", "logout"] { + assert!(Cli::try_parse_from(["kit", "auth", action, "anthropic"]).is_ok()); assert!(Cli::try_parse_from(["kit", "auth", action, "typesafe"]).is_ok()); } assert!(Cli::try_parse_from(["kit", "auth", "logout", "typesafe", "--local-only"]).is_ok()); @@ -4165,6 +4223,7 @@ future_option = true AuthProvider::Openrouter, AuthProvider::Speakeasy, AuthProvider::Typesafe, + AuthProvider::Anthropic, ] { let login = AuthAction::Login { provider }; assert!(validate_auth_storage(&login, &CredentialStorage::Memory).is_err()); diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 9347facf..d27462bc 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -127,6 +127,12 @@ const TERMINAL_AUTH_METHODS: &[TerminalAuthMethodSpec] = &[ description: "Authenticate Kit with Speakeasy", provider: ProviderKind::Speakeasy, }, + TerminalAuthMethodSpec { + method_id: "anthropic", + name: "Sign in with Anthropic", + description: "Authenticate Kit with an Anthropic API key", + provider: ProviderKind::Anthropic, + }, ]; pub(crate) fn terminal_auth_method_specs() -> &'static [TerminalAuthMethodSpec] { @@ -2190,6 +2196,7 @@ pub(super) fn config_options( crate::ProviderKind::OpenAiSubscription => "OpenAI subscription", crate::ProviderKind::OpenRouter => "OpenRouter", crate::ProviderKind::Speakeasy => "Speakeasy", + crate::ProviderKind::Anthropic => "Anthropic", }; let options = group .models @@ -3407,7 +3414,7 @@ pub(super) mod tests { .auth(agentkit_acp::AuthCapabilities::new().terminal(true)); assert!(terminal_auth_methods(&capabilities, |_| false).is_empty()); let methods = terminal_auth_methods(&capabilities, |_| true); - assert_eq!(methods.len(), 3); + assert_eq!(methods.len(), 4); assert!(matches!( &methods[0], AuthMethod::Terminal(method) @@ -3437,7 +3444,13 @@ pub(super) mod tests { _ => unreachable!("only terminal methods are advertised"), }) .collect::>(); - assert_eq!(method_ids, ["openai", "speakeasy"]); + assert_eq!(method_ids, ["openai", "speakeasy", "anthropic"]); + assert!( + terminal_auth_method_specs() + .iter() + .any(|method| method.method_id == "anthropic" + && method.provider == ProviderKind::Anthropic) + ); } #[test] @@ -3934,7 +3947,7 @@ pub(super) mod tests { meta.insert("terminal-auth".into(), serde_json::Value::Bool(true)); let capabilities = agentkit_acp::ClientCapabilities::new().meta(meta); - assert_eq!(terminal_auth_methods(&capabilities, |_| true).len(), 3); + assert_eq!(terminal_auth_methods(&capabilities, |_| true).len(), 4); } struct CompletionOnDrop(watch::Sender); diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 5ddf4430..4571662a 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -2100,6 +2100,7 @@ fn v2_config_options( crate::ProviderKind::OpenAiSubscription => "OpenAI subscription", crate::ProviderKind::OpenRouter => "OpenRouter", crate::ProviderKind::Speakeasy => "Speakeasy", + crate::ProviderKind::Anthropic => "Anthropic", }; let options = group .models @@ -6023,7 +6024,7 @@ mod tests { .unwrap(); assert_eq!(response.protocol_version, wire::ProtocolVersion::V2); - assert_eq!(response.auth_methods.len(), 3); + assert_eq!(response.auth_methods.len(), 4); assert!(matches!( &response.auth_methods[0], wire::AuthMethod::Terminal(method) @@ -6056,7 +6057,13 @@ mod tests { provider != ProviderKind::OpenRouter }); let method_ids = terminal_auth_method_ids(&methods); - assert_eq!(method_ids, ["openai", "speakeasy"]); + assert_eq!(method_ids, ["openai", "speakeasy", "anthropic"]); + assert!( + terminal_auth_method_specs() + .iter() + .any(|method| method.method_id == "anthropic" + && method.provider == ProviderKind::Anthropic) + ); let metadata_only = wire::ClientCapabilities::new().meta(serde_json::Map::from_iter([( "terminal-auth".into(), diff --git a/src/provider/adapter.rs b/src/provider/adapter.rs index ce389a16..4f45bfdb 100644 --- a/src/provider/adapter.rs +++ b/src/provider/adapter.rs @@ -10,6 +10,10 @@ use agentkit_core::{ use agentkit_loop::{ LoopError, ModelAdapter, ModelSession, ModelTurn, ModelTurnEvent, SessionConfig, TurnRequest, }; +use agentkit_provider_anthropic::{ + AnthropicAdapter, AnthropicConfig, AnthropicSession, AnthropicTurn, OutputEffort, + ThinkingConfig, +}; use agentkit_provider_openrouter::{ OpenRouterAdapter, OpenRouterConfig, OpenRouterProvider, OpenRouterRequestConfig, OpenRouterSession, OpenRouterTurn, ReasoningEffort as OpenRouterReasoningEffort, @@ -35,6 +39,7 @@ const SPEAKEASY_AUTH_REQUIRED: &str = pub(crate) fn authentication_method_id(detail: &str) -> Option<&'static str> { [ ("openai_auth_required:", "openai"), + ("anthropic_auth_required:", "anthropic"), ("openrouter_auth_required:", "openrouter"), ("speakeasy_auth_required:", "speakeasy"), ] @@ -51,11 +56,18 @@ pub enum ProviderKind { OpenRouter, #[serde(rename = "speakeasy")] Speakeasy, + #[serde(rename = "anthropic")] + Anthropic, } impl ValueEnum for ProviderKind { fn value_variants<'a>() -> &'a [Self] { - &[Self::OpenAiSubscription, Self::OpenRouter, Self::Speakeasy] + &[ + Self::OpenAiSubscription, + Self::OpenRouter, + Self::Speakeasy, + Self::Anthropic, + ] } fn to_possible_value(&self) -> Option { @@ -63,6 +75,7 @@ impl ValueEnum for ProviderKind { Self::OpenAiSubscription => "openai-subscription", Self::OpenRouter => "openrouter", Self::Speakeasy => "speakeasy", + Self::Anthropic => "anthropic", })) } } @@ -75,6 +88,7 @@ impl std::str::FromStr for ProviderKind { "openai-subscription" => Ok(Self::OpenAiSubscription), "openrouter" => Ok(Self::OpenRouter), "speakeasy" => Ok(Self::Speakeasy), + "anthropic" => Ok(Self::Anthropic), _ => Err(format!("unknown model provider {value:?}")), } } @@ -86,6 +100,7 @@ impl ProviderKind { Self::OpenAiSubscription => "openai-subscription", Self::OpenRouter => "openrouter", Self::Speakeasy => "speakeasy", + Self::Anthropic => "anthropic", } } } @@ -488,6 +503,7 @@ pub enum KitAdapter { OpenAiSubscription(OpenAiSubscriptionAdapter), OpenRouter(OpenRouterKitAdapter), Speakeasy(Box), + Anthropic(AnthropicAdapter), } #[derive(Clone)] @@ -655,6 +671,18 @@ impl KitAdapter { context_window: Arc::new(tokio::sync::OnceCell::new()), })) } + ProviderKind::Anthropic => { + let credentials = super::anthropic_auth::credentials(&credential_storage)? + .ok_or_else(|| "anthropic_auth_required: set ANTHROPIC_API_KEY or run `kit auth login anthropic` before using Anthropic".to_string())?; + let config = anthropic_config(credentials.api_key(), model, reasoning_effort)?; + AnthropicAdapter::new(config) + .map(|adapter| { + Self::Anthropic(adapter.with_resilience( + crate::request_budget::RequestBudget::current().resilience(), + )) + }) + .map_err(|error| error.to_string()) + } ProviderKind::Speakeasy => { let credentials = speakeasy_auth::load(&credential_storage)? .ok_or_else(|| SPEAKEASY_AUTH_REQUIRED.to_string())?; @@ -824,6 +852,10 @@ impl ModelAdapter for KitAdapter { context_window, })) } + Self::Anthropic(adapter) => adapter + .start_session(config) + .await + .map(KitSession::Anthropic), Self::Speakeasy(adapter) => { let mut provider = adapter.provider.clone(); provider.chat_id = Some(gram_chat_id(&config.session_id.to_string())); @@ -844,6 +876,7 @@ impl ModelAdapter for KitAdapter { Self::OpenAiSubscription(_) => "openai-subscription", Self::OpenRouter(_) => "openrouter", Self::Speakeasy(_) => "speakeasy", + Self::Anthropic(_) => "anthropic", }) } } @@ -852,6 +885,7 @@ pub enum KitSession { OpenAiSubscription(OpenAiSubscriptionSession), OpenRouter(OpenRouterKitSession), Speakeasy(SpeakeasyKitSession), + Anthropic(AnthropicSession), } pub struct OpenRouterKitSession { @@ -1116,6 +1150,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.set_retry_observer(observer), Self::OpenRouter(session) => session.inner.set_retry_observer(observer), Self::Speakeasy(session) => session.inner.set_retry_observer(observer), + Self::Anthropic(session) => session.set_retry_observer(observer), } } @@ -1127,7 +1162,7 @@ impl ModelSession for KitSession { let request = if matches!(self, Self::OpenAiSubscription(_)) { request } else { - project_tool_output_images(request, false)? + project_tool_output_images(request, matches!(self, Self::Anthropic(_)))? }; match self { Self::OpenAiSubscription(session) => session @@ -1146,6 +1181,14 @@ impl ModelSession for KitSession { next_media: 0, }) .map(KitTurn::OpenRouter), + Self::Anthropic(session) => { + let request = project_anthropic_reasoning(request); + let context_window = session.model_name().and_then(anthropic_context_window); + session + .begin_turn(request, cancellation) + .await + .map(|turn| KitTurn::Anthropic(Box::new(turn), context_window)) + } Self::Speakeasy(session) => session .inner .begin_turn(request, cancellation) @@ -1165,6 +1208,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.model_name(), Self::OpenRouter(session) => session.inner.model_name(), Self::Speakeasy(session) => session.inner.model_name(), + Self::Anthropic(session) => session.model_name(), } } @@ -1173,6 +1217,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.provider_name(), Self::OpenRouter(session) => session.inner.provider_name(), Self::Speakeasy(_) => Some("speakeasy"), + Self::Anthropic(_) => Some("anthropic"), } } } @@ -1181,6 +1226,7 @@ pub enum KitTurn { OpenAiSubscription(Box), OpenRouter(OpenRouterKitTurn), Speakeasy(OpenRouterKitTurn), + Anthropic(Box, Option), } pub struct OpenRouterKitTurn { @@ -1195,6 +1241,7 @@ impl ModelTurn for KitTurn { fn on_cancelled(&mut self) { match self { Self::OpenAiSubscription(turn) => turn.on_cancelled(), + Self::Anthropic(turn, _) => turn.on_cancelled(), Self::OpenRouter(turn) | Self::Speakeasy(turn) => turn.inner.on_cancelled(), } } @@ -1205,6 +1252,13 @@ impl ModelTurn for KitTurn { ) -> Result, LoopError> { match self { Self::OpenAiSubscription(turn) => turn.next_event(cancellation).await, + Self::Anthropic(turn, context_window) => { + let mut event = turn.next_event(cancellation).await?; + if let Some(window) = context_window { + stamp_context_window(&mut event, *window, "anthropic.context_length"); + } + Ok(event) + } Self::OpenRouter(turn) | Self::Speakeasy(turn) => { let mut event = turn.inner.next_event(cancellation).await?; if let Some(ModelTurnEvent::Delta(delta)) = &mut event { @@ -1491,7 +1545,139 @@ async fn model_catalog_with_openai( models: speakeasy, context_windows: speakeasy_windows, }, + anthropic_model_group(current), + ] +} + +fn project_anthropic_reasoning(mut request: TurnRequest) -> TurnRequest { + // Project the outbound copy only. Foreign summaries lack the opaque proof + // required to replay Anthropic thinking and must never be sent unsigned. + for item in &mut request.transcript { + item.parts.retain(|part| match part { + Part::Reasoning(reasoning) => { + let key = if reasoning.redacted { + "anthropic.redacted_data" + } else { + "anthropic.thinking_signature" + }; + reasoning + .metadata + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + } + _ => true, + }); + } + // The native encoder skips empty assistant items and merges adjacent users. + request +} + +// Match a published alias or its eight-digit dated snapshot, not arbitrary suffixes. +fn anthropic_model_matches(model: &str, alias: &str) -> bool { + model == alias + || model + .strip_prefix(alias) + .and_then(|suffix| suffix.strip_prefix('-')) + .is_some_and(|date| date.len() == 8 && date.bytes().all(|byte| byte.is_ascii_digit())) +} + +const ANTHROPIC_ADAPTIVE_MODELS: &[&str] = &[ + "claude-sonnet-5-5", + "claude-opus-5-5", + "claude-haiku-5-5", + "claude-sonnet-4-6", + "claude-opus-4-6", +]; + +fn anthropic_config( + api_key: &str, + model: String, + effort: Option, +) -> Result { + let mut config = + AnthropicConfig::new(api_key, model.clone(), 16384).map_err(|error| error.to_string())?; + let Some(effort) = effort else { + return Ok(config); + }; + if ANTHROPIC_ADAPTIVE_MODELS + .iter() + .any(|alias| anthropic_model_matches(&model, alias)) + { + config = config + .with_thinking(ThinkingConfig::Adaptive) + .with_output_effort(match effort { + ReasoningEffort::Low => OutputEffort::Low, + ReasoningEffort::Medium => OutputEffort::Medium, + ReasoningEffort::High => OutputEffort::High, + }); + } else if [ + "claude-haiku-4-5", + "claude-sonnet-4-5", + "claude-opus-4-5", + "claude-sonnet-4", + "claude-opus-4", + "claude-opus-4-1", + "claude-3-7-sonnet", + ] + .iter() + .any(|alias| anthropic_model_matches(&model, alias)) + { + config = config.with_thinking(ThinkingConfig::Enabled { + budget_tokens: match effort { + ReasoningEffort::Low => 1024, + ReasoningEffort::Medium => 4096, + ReasoningEffort::High => 8192, + }, + }); + } else { + return Err(format!( + "reasoning effort is not configured for Anthropic model {model:?}; select default reasoning effort to use this model" + )); + } + Ok(config) +} + +fn anthropic_context_window(model: &str) -> Option { + if ANTHROPIC_ADAPTIVE_MODELS + .iter() + .any(|alias| anthropic_model_matches(model, alias)) + { + Some(1_000_000) + } else if anthropic_model_matches(model, "claude-haiku-4-5") { + Some(200_000) + } else { + None + } +} + +fn anthropic_model_group(current: &ModelSelection) -> ModelGroup { + let mut models = [ + "claude-sonnet-5-5", + "claude-opus-5-5", + "claude-haiku-5-5", + "claude-sonnet-4-6", + "claude-opus-4-6", + "claude-haiku-4-5-20251001", ] + .into_iter() + .map(str::to_owned) + .collect::>(); + if current.provider == ProviderKind::Anthropic + && valid_model_id(¤t.model) + && !models.contains(¤t.model) + { + models.push(current.model.clone()); + } + let context_windows = models + .iter() + .filter_map(|model| anthropic_context_window(model).map(|window| (model.clone(), window))) + .collect(); + ModelGroup { + provider: ProviderKind::Anthropic, + models, + context_windows, + } } fn openrouter_fallback() -> Vec { @@ -1583,6 +1769,99 @@ fn parse_discovered_models(value: &Value) -> Result { clippy::disallowed_macros )] mod tests { + #[test] + fn anthropic_production_config_maps_only_known_reasoning_modes() { + use super::{OutputEffort, ReasoningEffort, ThinkingConfig, anthropic_config}; + for model in super::ANTHROPIC_ADAPTIVE_MODELS { + for model in [model.to_string(), format!("{model}-20261001")] { + for effort in [ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ] { + let config = anthropic_config("test", model.clone(), Some(effort)).unwrap(); + assert!(matches!(config.thinking, Some(ThinkingConfig::Adaptive))); + assert!(matches!( + (effort, config.output_effort), + (ReasoningEffort::Low, Some(OutputEffort::Low)) + | (ReasoningEffort::Medium, Some(OutputEffort::Medium)) + | (ReasoningEffort::High, Some(OutputEffort::High)) + )); + assert_eq!(config.max_tokens, 16384); + assert_eq!(super::anthropic_context_window(&model), Some(1_000_000)); + } + } + } + for (effort, expected) in [ + (ReasoningEffort::Low, 1024), + (ReasoningEffort::Medium, 4096), + (ReasoningEffort::High, 8192), + ] { + let config = + anthropic_config("test", "claude-haiku-4-5-20251001".into(), Some(effort)).unwrap(); + assert!( + matches!(config.thinking, Some(ThinkingConfig::Enabled { budget_tokens }) if budget_tokens == expected) + ); + assert!(config.output_effort.is_none()); + } + assert_eq!( + super::anthropic_context_window("claude-haiku-4-5-20251001"), + Some(200_000) + ); + for model in [ + "claude-future", + "claude-sonnet-5-5-custom", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ] { + let config = anthropic_config("test", model.into(), None).unwrap(); + assert!(config.thinking.is_none()); + assert!(config.output_effort.is_none()); + } + for model in ["claude-future", "claude-sonnet-5-5-custom"] { + assert!( + anthropic_config("test", model.into(), Some(ReasoningEffort::High)) + .unwrap_err() + .contains("default reasoning effort") + ); + assert_eq!(super::anthropic_context_window(model), None); + } + } + + #[test] + fn anthropic_selection_catalog_and_authentication() { + let selection = super::ModelSelection::from_id("anthropic:claude-sonnet-4-6").unwrap(); + assert_eq!(selection.provider, super::ProviderKind::Anthropic); + assert_eq!(selection.id(), "anthropic:claude-sonnet-4-6"); + assert_eq!( + super::authentication_method_id("anthropic_auth_required: missing key"), + Some("anthropic") + ); + let group = super::anthropic_model_group(&selection); + assert!(group.models.contains(&selection.model)); + assert_eq!( + &group.models[..3], + &["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5"] + ); + for model in &group.models[..3] { + assert_eq!(group.context_windows[model], 1_000_000); + } + assert_eq!(group.context_windows["claude-haiku-4-5-20251001"], 200_000); + assert_eq!(group.context_windows[&selection.model], 1_000_000); + let custom = super::ModelSelection::new(super::ProviderKind::Anthropic, "claude-future"); + let group = super::anthropic_model_group(&custom); + assert!(group.models.contains(&custom.model)); + assert!(!group.context_windows.contains_key(&custom.model)); + assert_eq!( + serde_json::from_str::("\"anthropic\"").unwrap(), + super::ProviderKind::Anthropic + ); + assert_eq!( + super::ProviderKind::default(), + super::ProviderKind::OpenAiSubscription + ); + } + #[test] fn model_switch_catalog_retains_only_reported_positive_windows() { let catalog = super::parse_discovered_models(&serde_json::json!({"data": [ @@ -1931,6 +2210,147 @@ mod tests { ); } + #[tokio::test] + async fn anthropic_uses_native_messages_api_and_context_metadata() { + use agentkit_loop::ModelTurn; + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let count = stream.read(&mut chunk).unwrap(); + assert!(count > 0); + request.extend_from_slice(&chunk[..count]); + let Some(headers_end) = request.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers_end = headers_end + 4; + let headers = std::str::from_utf8(&request[..headers_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap(); + if request.len() >= headers_end + content_length { + break; + } + } + let response = json!({ + "id": "msg_test", "type": "message", "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", "stop_sequence": null, + "usage": {"input_tokens": 4, "output_tokens": 1} + }) + .to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + String::from_utf8(request).unwrap() + }); + let config = super::anthropic_config("test-api-key", "claude-sonnet-4-6".into(), None) + .unwrap() + .with_base_url(format!("http://{address}/v1/messages")) + .with_streaming(false); + let adapter = KitAdapter::Anthropic( + agentkit_provider_anthropic::AnthropicAdapter::new(config).unwrap(), + ); + assert_eq!(adapter.provider_name(), Some("anthropic")); + let mut session = adapter + .start_session(SessionConfig::new("anthropic-contract")) + .await + .unwrap(); + assert_eq!(session.provider_name(), Some("anthropic")); + let signed = agentkit_core::ReasoningPart::summary("genuine thinking").with_metadata( + MetadataMap::from_iter([( + "anthropic.thinking_signature".into(), + json!("signed-proof"), + )]), + ); + let mut redacted = agentkit_core::ReasoningPart::summary("unused").with_metadata( + MetadataMap::from_iter([("anthropic.redacted_data".into(), json!("opaque-data"))]), + ); + redacted.redacted = true; + let foreign = Part::Reasoning(agentkit_core::ReasoningPart::summary("foreign summary")); + let mut turn = session + .begin_turn( + TurnRequest { + session_id: SessionId::new("anthropic-contract"), + turn_id: TurnId::new("turn"), + transcript: vec![ + Item::text(ItemKind::User, "hello"), + Item::new(ItemKind::Assistant, vec![foreign.clone()]), + Item::text(ItemKind::User, "continue"), + Item::new( + ItemKind::Assistant, + vec![ + foreign, + Part::Reasoning(signed), + Part::Reasoning(redacted), + Part::text("answer"), + ], + ), + Item::text(ItemKind::User, "next"), + ], + available_tools: Vec::new(), + cache: None, + metadata: MetadataMap::new(), + }, + None, + ) + .await + .unwrap(); + let mut events = Vec::new(); + while let Some(event) = turn.next_event(None).await.unwrap() { + events.push(event); + } + let result = events + .iter() + .find_map(|event| match event { + ModelTurnEvent::Finished(result) => Some(result), + _ => None, + }) + .expect("Anthropic turn finishes"); + assert_eq!( + result.usage.as_ref().unwrap().metadata["context_window"], + json!(1_000_000) + ); + let request = server.join().unwrap(); + let (headers, body) = request.split_once("\r\n\r\n").unwrap(); + let headers = headers.to_ascii_lowercase(); + assert!(headers.starts_with("post /v1/messages ")); + assert!(headers.contains("x-api-key: test-api-key")); + assert!(headers.contains("anthropic-version: 2023-06-01")); + assert!(!headers.contains("authorization:")); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["model"], "claude-sonnet-4-6"); + assert_eq!(body["max_tokens"], 16384); + assert_eq!(body["messages"][0]["role"], "user"); + let messages = body["messages"].as_array().unwrap(); + assert_eq!( + messages.len(), + 3, + "empty assistant is omitted and adjacent users merge" + ); + assert_eq!(messages[0]["content"].as_array().unwrap().len(), 2); + assert_eq!( + messages[1]["content"], + json!([ + {"type": "thinking", "thinking": "genuine thinking", "signature": "signed-proof"}, + {"type": "redacted_thinking", "data": "opaque-data"}, + {"type": "text", "text": "answer"} + ]) + ); + assert!(!body.to_string().contains("foreign summary")); + } + #[tokio::test] async fn speakeasy_composes_openrouter_wire_format_with_gram_auth() { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); diff --git a/src/provider/anthropic_auth.rs b/src/provider/anthropic_auth.rs new file mode 100644 index 00000000..f47d3abe --- /dev/null +++ b/src/provider/anthropic_auth.rs @@ -0,0 +1,522 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use crate::credentials::CredentialStorage; + +const NAMESPACE: &str = "anthropic"; +const IDENTITY: &str = "default"; +const KEYS_URL: &str = "https://platform.claude.com/settings/keys"; +const MODELS_URL: &str = "https://api.anthropic.com/v1/models"; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_RECORD_BYTES: usize = 16 * 1024; + +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnthropicAuthCommand { + Login, + Status, + Logout { local_only: bool }, +} + +// A new, isolated credential record, not a model-provider configuration. +#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)] +#[serde(deny_unknown_fields)] +pub(crate) struct Credentials { + api_key: String, +} + +impl Credentials { + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +/// Resolve the active key without making a network request. +pub(crate) fn credentials(storage: &CredentialStorage) -> Result, String> { + let environment = match std::env::var("ANTHROPIC_API_KEY") { + Ok(value) => Some(Zeroizing::new(value)), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + return Err("Invalid ANTHROPIC_API_KEY. Check the key and try again.".into()); + } + }; + resolve_key(storage, environment.as_deref().map(String::as_str)) +} + +fn resolve_key( + storage: &CredentialStorage, + environment: Option<&str>, +) -> Result, String> { + if let Some(key) = environment.filter(|key| !key.is_empty()) { + let record = Credentials { + api_key: key.into(), + }; + validate(&record)?; + Ok(Some(record)) + } else { + load(storage) + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AnthropicCredentials([REDACTED])") + } +} + +#[doc(hidden)] +pub fn execute_anthropic_auth( + command: AnthropicAuthCommand, + storage: &CredentialStorage, +) -> Result { + let environment_active = + std::env::var_os("ANTHROPIC_API_KEY").is_some_and(|value| !value.is_empty()); + execute(command, storage, environment_active) +} + +fn execute( + command: AnthropicAuthCommand, + storage: &CredentialStorage, + environment_active: bool, +) -> Result { + match command { + AnthropicAuthCommand::Login => { + if !storage.is_persistent() { + return Err("To save your Anthropic key, use --credential-store keychain or --credential-store file.".into()); + } + eprintln!("Create a single-workspace Anthropic API key at {KEYS_URL}"); + let record = Credentials { + api_key: rpassword::prompt_password("Anthropic API key (hidden): ").map_err( + |_| "could not read Anthropic API key from the terminal".to_string(), + )?, + }; + complete_login( + storage, + &record, + environment_active, + MODELS_URL, + LOGIN_TIMEOUT, + ) + } + AnthropicAuthCommand::Status => { + if environment_active { + Ok("Anthropic: configured via ANTHROPIC_API_KEY.\n".into()) + } else if load(storage)?.is_some() { + Ok("Anthropic: configured.\n".into()) + } else { + Ok("Anthropic: not configured.\n".into()) + } + } + AnthropicAuthCommand::Logout { local_only } => { + let removed = storage + .entry(NAMESPACE, IDENTITY) + .delete() + .map_err(|_| "Could not remove your Anthropic key. Try again.".to_string())?; + let mut output = if removed { + "Anthropic key removed.\n".to_string() + } else { + "Anthropic: no saved key.\n".to_string() + }; + if !local_only { + output.push_str(&format!("Revoke Anthropic API keys at {KEYS_URL}.\n")); + } + if environment_active { + output.push_str("Warning: ANTHROPIC_API_KEY remains active after logout.\n"); + } + Ok(output) + } + } +} + +// Validate with the non-inference Models API; this does not spend API credits. +// Keep redirects disabled so validation cannot forward the key to another URL. +fn complete_login( + storage: &CredentialStorage, + record: &Credentials, + environment_active: bool, + models_url: &str, + timeout: Duration, +) -> Result { + validate(record)?; + let unavailable = || "Could not authenticate with Anthropic. Try again later.".to_string(); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(timeout.min(Duration::from_secs(5))) + .timeout(timeout) + .build() + .map_err(|_| unavailable())?; + let response = client + .get(models_url) + .header("x-api-key", record.api_key()) + .header("anthropic-version", "2023-06-01") + .send() + .map_err(|_| unavailable())?; + match response.status() { + reqwest::StatusCode::OK => {} + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { + return Err("Anthropic rejected this API key. Check the key and try again.".into()); + } + _ => return Err(unavailable()), + } + // Only the authenticated status matters; never read or expose response bodies. + save(storage, record)?; + let mut output = "Anthropic: authenticated. API key saved.\n".to_string(); + if environment_active { + output.push_str("Warning: ANTHROPIC_API_KEY overrides your saved key.\n"); + } + Ok(output) +} + +fn validate(record: &Credentials) -> Result<(), String> { + if record.api_key.is_empty() + || record + .api_key + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { + return Err("Invalid Anthropic API key. Copy the key and try again.".into()); + } + if record.api_key.len() > MAX_RECORD_BYTES { + return Err("Invalid Anthropic API key. Copy the key and try again.".into()); + } + Ok(()) +} + +fn load(storage: &CredentialStorage) -> Result, String> { + let Some(bytes) = storage + .entry(NAMESPACE, IDENTITY) + .load() + .map_err(|_| "Could not read your Anthropic key. Log in again.".to_string())? + else { + return Ok(None); + }; + if bytes.len() > MAX_RECORD_BYTES { + return Err("Could not read your Anthropic key. Log in again.".into()); + } + let record: Credentials = serde_json::from_slice(&bytes) + .map_err(|_| "Could not read your Anthropic key. Log in again.".to_string())?; + validate(&record)?; + Ok(Some(record)) +} + +fn save(storage: &CredentialStorage, record: &Credentials) -> Result<(), String> { + validate(record)?; + let bytes = Zeroizing::new( + serde_json::to_vec(record) + .map_err(|_| "Could not save your Anthropic key. Try again.".to_string())?, + ); + if bytes.len() > MAX_RECORD_BYTES { + return Err("Invalid Anthropic API key. Copy the key and try again.".into()); + } + storage + .entry(NAMESPACE, IDENTITY) + .save(&bytes) + .map_err(|_| "Could not save your Anthropic key. Try again.".to_string()) +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods, clippy::disallowed_macros)] +mod tests { + use super::*; + + use std::io::{Read, Write}; + use std::net::TcpListener; + + // A real HTTP boundary: capture the request and return an upstream response. + fn serve(response: String, delay: Duration) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/v1/models", listener.local_addr().unwrap()); + let task = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0]; + while !request.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).unwrap() == 0 { + break; + } + request.push(byte[0]); + assert!(request.len() < 32 * 1024); + } + std::thread::sleep(delay); + let _ = stream.write_all(response.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, task) + } + + #[test] + fn credentials_are_isolated_from_other_providers() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + let other = br#"{"api_key":"other-provider-secret"}"#; + storage.entry("typesafe", IDENTITY).save(other).unwrap(); + assert!(load(&storage).unwrap().is_none()); + save( + &storage, + &Credentials { + api_key: "anthropic-secret".into(), + }, + ) + .unwrap(); + execute( + AnthropicAuthCommand::Logout { local_only: true }, + &storage, + false, + ) + .unwrap(); + assert!(load(&storage).unwrap().is_none()); + assert_eq!( + storage + .entry("typesafe", IDENTITY) + .load() + .unwrap() + .unwrap() + .as_slice(), + other + ); + } + + #[test] + fn key_resolution_prefers_nonempty_environment_and_redacts_keys() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + assert!(resolve_key(&storage, None).unwrap().is_none()); + save( + &storage, + &Credentials { + api_key: "stored-secret".into(), + }, + ) + .unwrap(); + for environment in [None, Some("")] { + assert_eq!( + resolve_key(&storage, environment) + .unwrap() + .unwrap() + .api_key(), + "stored-secret" + ); + } + let key = resolve_key(&storage, Some("environment-secret")) + .unwrap() + .unwrap(); + assert_eq!(key.api_key(), "environment-secret"); + assert!(!format!("{key:?}").contains("environment-secret")); + assert!(resolve_key(&storage, Some("invalid secret")).is_err()); + storage.entry(NAMESPACE, IDENTITY).save(b"invalid").unwrap(); + assert!(resolve_key(&storage, Some("environment-secret")).is_ok()); + } + + #[test] + fn login_validates_before_saving_and_redacts_failures() { + for status in [200, 201, 204, 301, 302, 307, 401, 403, 429, 500, 503] { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + save( + &storage, + &Credentials { + api_key: "original".into(), + }, + ) + .unwrap(); + let body = "new-secret upstream details"; + let response = format!( + "HTTP/1.1 {status} Test\r\nLocation: /redirected\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (url, task) = serve(response, Duration::ZERO); + let result = complete_login( + &storage, + &Credentials { + api_key: "new-secret".into(), + }, + true, + &url, + Duration::from_secs(2), + ); + let request = task.join().unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /v1/models http/1.1\r\n")); + assert!(request.contains("x-api-key: new-secret\r\n")); + assert!(request.contains("anthropic-version: 2023-06-01\r\n")); + assert!(!request.contains("authorization:")); + if status == 200 { + let output = result.unwrap(); + assert!(output.starts_with("Anthropic: authenticated. API key saved.")); + assert!(output.contains("ANTHROPIC_API_KEY overrides your saved key")); + assert!(!output.contains("new-secret")); + assert_eq!(load(&storage).unwrap().unwrap().api_key, "new-secret"); + } else { + let error = result.unwrap_err(); + assert!(!error.contains("new-secret")); + assert!(!error.contains("upstream")); + assert!(!error.contains("authenticated")); + assert_eq!(error.contains("rejected"), status == 401 || status == 403); + assert_eq!(load(&storage).unwrap().unwrap().api_key, "original"); + } + } + } + + #[test] + fn successful_authentication_does_not_hide_save_failure() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("not-a-directory"); + std::fs::write(&path, b"untouched").unwrap(); + let storage = CredentialStorage::Filesystem(path.clone()); + let (url, task) = serve( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".into(), + Duration::ZERO, + ); + let error = complete_login( + &storage, + &Credentials { + api_key: "new-secret".into(), + }, + false, + &url, + Duration::from_secs(2), + ) + .unwrap_err(); + task.join().unwrap(); + assert_eq!(error, "Could not save your Anthropic key. Try again."); + assert_eq!(std::fs::read(path).unwrap(), b"untouched"); + } + + #[test] + fn network_failure_and_timeout_preserve_previous_key() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + save( + &storage, + &Credentials { + api_key: "original".into(), + }, + ) + .unwrap(); + for (response, delay) in [ + (String::new(), Duration::ZERO), + ( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".into(), + Duration::from_millis(500), + ), + ] { + let (url, task) = serve(response, delay); + let error = complete_login( + &storage, + &Credentials { + api_key: "new-secret".into(), + }, + false, + &url, + Duration::from_millis(100), + ) + .unwrap_err(); + task.join().unwrap(); + assert_eq!( + error, + "Could not authenticate with Anthropic. Try again later." + ); + assert_eq!(load(&storage).unwrap().unwrap().api_key, "original"); + } + } + + #[test] + fn stored_key_round_trip_status_and_logout() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + assert!( + execute(AnthropicAuthCommand::Status, &storage, false) + .unwrap() + .contains("not configured") + ); + let record = Credentials { + api_key: "secret-test-key".into(), + }; + save(&storage, &record).unwrap(); + assert_eq!(load(&storage).unwrap().unwrap().api_key, record.api_key); + let bytes = storage.entry(NAMESPACE, IDENTITY).load().unwrap().unwrap(); + assert_eq!(bytes.as_slice(), br#"{"api_key":"secret-test-key"}"#); + let status = execute(AnthropicAuthCommand::Status, &storage, false).unwrap(); + assert_eq!(status, "Anthropic: configured.\n"); + assert!(!status.contains(&record.api_key)); + assert!(!format!("{record:?}").contains(&record.api_key)); + let logout = execute( + AnthropicAuthCommand::Logout { local_only: false }, + &storage, + true, + ) + .unwrap(); + assert!(logout.contains(KEYS_URL)); + assert!(logout.contains("ANTHROPIC_API_KEY remains active")); + assert!(load(&storage).unwrap().is_none()); + let logout = execute( + AnthropicAuthCommand::Logout { local_only: true }, + &storage, + false, + ) + .unwrap(); + assert!(logout.contains("no saved key")); + assert!(!logout.contains(KEYS_URL)); + } + + #[test] + fn malformed_records_are_redacted_and_environment_takes_precedence() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + for bytes in [ + br#"{"api_key":"secret","extra":true}"#.as_slice(), + br#"{"api_key":""}"#, + br#"{"api_key":"secret with spaces"}"#, + br#"{"api_key":42}"#, + b"secret-not-json", + b"{}", + ] { + storage.entry(NAMESPACE, IDENTITY).save(bytes).unwrap(); + let error = load(&storage).unwrap_err(); + assert!(!error.contains("secret")); + let status = execute(AnthropicAuthCommand::Status, &storage, true).unwrap(); + assert!(status.contains("configured via ANTHROPIC_API_KEY")); + } + storage + .entry(NAMESPACE, IDENTITY) + .save(&vec![b'x'; MAX_RECORD_BYTES + 1]) + .unwrap(); + assert!(load(&storage).unwrap_err().contains("Log in again")); + } + + #[test] + fn invalid_key_does_not_overwrite_existing_credentials() { + let directory = tempfile::tempdir().unwrap(); + let storage = CredentialStorage::Filesystem(directory.path().into()); + save( + &storage, + &Credentials { + api_key: "original".into(), + }, + ) + .unwrap(); + for key in [ + String::new(), + " ".into(), + "secret\n".into(), + "secret\0".into(), + "x".repeat(MAX_RECORD_BYTES), + ] { + assert!(save(&storage, &Credentials { api_key: key }).is_err()); + assert_eq!(load(&storage).unwrap().unwrap().api_key, "original"); + } + assert!( + execute( + AnthropicAuthCommand::Login, + &CredentialStorage::Memory, + false + ) + .is_err() + ); + } +} diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 08225dca..72a130f0 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1,4 +1,8 @@ mod adapter; +pub(crate) mod anthropic_auth; + +#[doc(hidden)] +pub use anthropic_auth::{AnthropicAuthCommand, execute_anthropic_auth}; pub mod chatgpt; pub(crate) mod openai_auth; mod openrouter_auth; @@ -140,6 +144,9 @@ pub fn execute_provider_logout( active_openrouter_key: Option<(&OpenRouterApiKey, OpenRouterApiKeySource)>, ) -> Result { match provider { + ProviderKind::Anthropic => { + execute_anthropic_auth(AnthropicAuthCommand::Logout { local_only }, storage) + } ProviderKind::OpenAiSubscription => { execute_openai_auth(OpenAiAuthCommand::Logout { local_only }, storage) } diff --git a/src/runtime.rs b/src/runtime.rs index 0bbeb838..daccc42c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -791,6 +791,7 @@ impl Runtime { ProviderKind::OpenAiSubscription, ProviderKind::OpenRouter, ProviderKind::Speakeasy, + ProviderKind::Anthropic, ] .into_iter() .filter_map(|provider| { From bc2553cdd55d8b8b5ff821555c2ba79b10c3c3a4 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 18 Oct 2026 00:13:30 +0100 Subject: [PATCH 2/5] docs: keep Anthropic setup focused on Kit behavior --- README.md | 5 ++--- .../user/getting-started-and-configuration.md | 19 ++----------------- docs/user/provider-usage.md | 2 +- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 04b961d6..917e5f7d 100644 --- a/README.md +++ b/README.md @@ -167,9 +167,8 @@ OpenRouter and Speakeasy use the same login process. Run `kit auth login openrou For direct Anthropic API usage, run `kit auth login anthropic`, then select `--provider anthropic --model claude-sonnet-5-5`. Use a single-workspace Console -API key; `ANTHROPIC_API_KEY` is also supported. Eligible Max subscribers can claim -API credits into the Console organization that owns the key. See -[Anthropic setup and credit billing](docs/user/getting-started-and-configuration.md#direct-anthropic-api). +API key; `ANTHROPIC_API_KEY` is also supported. See +[Anthropic setup](docs/user/getting-started-and-configuration.md#direct-anthropic-api). ### Coming from Claude Code or Codex diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index 857c8d2e..51cab210 100644 --- a/docs/user/getting-started-and-configuration.md +++ b/docs/user/getting-started-and-configuration.md @@ -94,23 +94,8 @@ Multi-workspace keys that require an explicit workspace header are not supported Use direct Anthropic model IDs with `--model`, not OpenRouter's `anthropic/…` IDs. Responses have a 16,384-token output limit. Leave `reasoning_effort` unset for the provider default, or select `low`, `medium`, or -`high` for a model that supports extended thinking. Logout deletes the locally saved key; revoke it in Claude -Console to disable it remotely. An environment key remains active after logout. - -#### Using Claude Max API credits - -Eligible Max subscribers can claim monthly API credits into a linked Console -organization. Create the key in that organization to use the credits with Kit. -Anthropic applies the credits automatically; Kit does not access your Claude -subscription credentials or manage the credit balance. - -Anthropic currently lists $100 per billing cycle for Max 5x and $200 for Max 20x, -claimable after seven days on an eligible plan. Credits expire each cycle and do -not roll over. Requests can continue against purchased credits, auto-reload, or -invoiced billing after the included credits run out. Check Console billing and -workspace spend limits before use. See Anthropic's -[eligibility and credit terms](https://support.claude.com/en/articles/17154008-monthly-api-credits-for-max-and-team-plans) -for current details. Credits in Claude Console do not pay for OpenRouter usage. +`high` for a model that supports extended thinking. Logout deletes the locally +saved key; revoke it in Claude Console to disable it remotely. An environment key remains active after logout. ### OpenRouter API key and model diff --git a/docs/user/provider-usage.md b/docs/user/provider-usage.md index e026f75f..0a4c01fd 100644 --- a/docs/user/provider-usage.md +++ b/docs/user/provider-usage.md @@ -11,7 +11,7 @@ kit usage openrouter --credential-store keychain Supported providers are `openai` (also `openai-subscription`) and `openrouter`. Direct Anthropic API credit balances are not available through `kit usage`; check -Billing in Claude Console, including any claimed Max credits. +Billing in Claude Console. Omit the provider to check authenticated supported providers. The command uses the effective credential storage from configuration and CLI overrides (`--credential-store` and `--credential-dir`). OpenRouter also uses the effective `--openrouter-api-key` or From a8b3692b0e8a3fb2a2b501acc10b3b9432d27857 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 18 Oct 2026 00:59:31 +0100 Subject: [PATCH 3/5] fix(anthropic): adopt cache-inclusive usage accounting --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/provider/adapter.rs | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4291410..f466c71c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,9 +330,9 @@ dependencies = [ [[package]] name = "agentkit-provider-anthropic" -version = "0.10.10" +version = "0.10.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6845da26faadc0b4cd7c709e7d6a7eb85397f7dbc39fd43f3e0c04aeb1505af3" +checksum = "fa41fff5bbb3dbda6925b67fc40a005b6c4627e7a2b37d6c8a864ba4ffff02c6" dependencies = [ "agentkit-core", "agentkit-http", diff --git a/Cargo.toml b/Cargo.toml index 4b0ea93d..c84fe6ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ agentkit-loop = { version = "=0.10.13", features = ["otel"] } agentkit-mcp = "=0.11.2" agentkit-http = "=0.10.7" agentkit-plugins = "=0.10.7" -agentkit-provider-anthropic = "=0.10.10" +agentkit-provider-anthropic = "=0.10.11" agentkit-provider-openai = "=0.10.13" agentkit-provider-openrouter = "=0.10.8" agentkit-task-manager = "=0.10.7" diff --git a/src/provider/adapter.rs b/src/provider/adapter.rs index 4f45bfdb..e85d4376 100644 --- a/src/provider/adapter.rs +++ b/src/provider/adapter.rs @@ -2249,7 +2249,10 @@ mod tests { "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn", "stop_sequence": null, - "usage": {"input_tokens": 4, "output_tokens": 1} + "usage": { + "input_tokens": 4, "output_tokens": 1, + "cache_read_input_tokens": 900, "cache_creation_input_tokens": 96 + } }) .to_string(); write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); @@ -2318,6 +2321,17 @@ mod tests { _ => None, }) .expect("Anthropic turn finishes"); + let tokens = result.usage.as_ref().unwrap().tokens.as_ref().unwrap(); + assert_eq!(tokens.input_tokens, 1000); + assert_eq!(tokens.output_tokens, 1); + assert_eq!(tokens.cached_input_tokens, Some(900)); + assert_eq!(tokens.cache_write_input_tokens, Some(96)); + for event in &events { + if let ModelTurnEvent::Usage(usage) = event { + assert_eq!(usage.tokens.as_ref().unwrap(), tokens); + assert_eq!(usage.metadata["context_window"], json!(1_000_000)); + } + } assert_eq!( result.usage.as_ref().unwrap().metadata["context_window"], json!(1_000_000) From c660fc2a91ef17c78d4c9391bbfe53e07a9f45c6 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 18 Oct 2026 03:15:53 +0100 Subject: [PATCH 4/5] feat(anthropic): discover models and capabilities from the API --- .../user/getting-started-and-configuration.md | 10 +- src/provider/adapter.rs | 438 +++++++++++------- src/provider/anthropic_catalog.rs | 291 ++++++++++++ src/provider/mod.rs | 1 + 4 files changed, 559 insertions(+), 181 deletions(-) create mode 100644 src/provider/anthropic_catalog.rs diff --git a/docs/user/getting-started-and-configuration.md b/docs/user/getting-started-and-configuration.md index 51cab210..d83fb4a7 100644 --- a/docs/user/getting-started-and-configuration.md +++ b/docs/user/getting-started-and-configuration.md @@ -92,9 +92,13 @@ saved key. Do not put API keys in command-line arguments or `config.toml`. Multi-workspace keys that require an explicit workspace header are not supported. Use direct Anthropic model IDs with `--model`, not OpenRouter's -`anthropic/…` IDs. Responses have a 16,384-token output limit. Leave -`reasoning_effort` unset for the provider default, or select `low`, `medium`, or -`high` for a model that supports extended thinking. Logout deletes the locally +`anthropic/…` IDs. Kit discovers available models, context limits, and thinking +capabilities from the authenticated Anthropic Models API rather than a fixed +model list. If discovery is unavailable, an explicitly selected model remains +usable with default reasoning; Kit does not guess its context limit. Responses +have an output limit of at most 16,384 tokens, capped by the model's advertised +maximum when available. Leave `reasoning_effort` unset for the provider default, +or select `low`, `medium`, or `high` when discovery confirms support. Logout deletes the locally saved key; revoke it in Claude Console to disable it remotely. An environment key remains active after logout. ### OpenRouter API key and model diff --git a/src/provider/adapter.rs b/src/provider/adapter.rs index e85d4376..4a7dbeec 100644 --- a/src/provider/adapter.rs +++ b/src/provider/adapter.rs @@ -313,7 +313,12 @@ impl SelectableAdapter { } pub async fn model_catalog(&self, current: &ModelSelection) -> Vec { - model_catalog_with_openai(current, self.discovered_openai_models()).await + model_catalog_with_openai( + current, + self.discovered_openai_models(), + &self.credential_storage, + ) + .await } pub fn select(&self, selection: ModelSelection) -> Result<(), String> { @@ -503,7 +508,15 @@ pub enum KitAdapter { OpenAiSubscription(OpenAiSubscriptionAdapter), OpenRouter(OpenRouterKitAdapter), Speakeasy(Box), - Anthropic(AnthropicAdapter), + Anthropic(AnthropicKitAdapter), +} + +#[derive(Clone)] +pub struct AnthropicKitAdapter { + config: Box, + key: zeroize::Zeroizing, + effort: Option, + models_url: String, } #[derive(Clone)] @@ -674,14 +687,14 @@ impl KitAdapter { ProviderKind::Anthropic => { let credentials = super::anthropic_auth::credentials(&credential_storage)? .ok_or_else(|| "anthropic_auth_required: set ANTHROPIC_API_KEY or run `kit auth login anthropic` before using Anthropic".to_string())?; - let config = anthropic_config(credentials.api_key(), model, reasoning_effort)?; - AnthropicAdapter::new(config) - .map(|adapter| { - Self::Anthropic(adapter.with_resilience( - crate::request_budget::RequestBudget::current().resilience(), - )) - }) - .map_err(|error| error.to_string()) + let config = AnthropicConfig::new(credentials.api_key(), model, 16384) + .map_err(|error| error.to_string())?; + Ok(Self::Anthropic(AnthropicKitAdapter { + config: Box::new(config), + key: zeroize::Zeroizing::new(credentials.api_key().to_owned()), + effort: reasoning_effort, + models_url: super::anthropic_catalog::MODELS_URL.into(), + })) } ProviderKind::Speakeasy => { let credentials = speakeasy_auth::load(&credential_storage)? @@ -852,10 +865,28 @@ impl ModelAdapter for KitAdapter { context_window, })) } - Self::Anthropic(adapter) => adapter - .start_session(config) + Self::Anthropic(adapter) => { + let resolved = super::anthropic_catalog::retrieve( + &adapter.models_url, + &adapter.key, + &adapter.config.model, + ) .await - .map(KitSession::Anthropic), + .ok(); + let metadata = resolved.as_ref(); + let context_window = + metadata.and_then(super::anthropic_catalog::Model::context_window); + let provider_config = + anthropic_config((*adapter.config).clone(), adapter.effort, metadata) + .map_err(LoopError::InvalidState)?; + let inner = AnthropicAdapter::new(provider_config) + .map_err(|error| LoopError::InvalidState(error.to_string()))? + .with_resilience(crate::request_budget::RequestBudget::current().resilience()); + inner + .start_session(config) + .await + .map(|session| KitSession::Anthropic(session, context_window)) + } Self::Speakeasy(adapter) => { let mut provider = adapter.provider.clone(); provider.chat_id = Some(gram_chat_id(&config.session_id.to_string())); @@ -885,7 +916,7 @@ pub enum KitSession { OpenAiSubscription(OpenAiSubscriptionSession), OpenRouter(OpenRouterKitSession), Speakeasy(SpeakeasyKitSession), - Anthropic(AnthropicSession), + Anthropic(AnthropicSession, Option), } pub struct OpenRouterKitSession { @@ -1150,7 +1181,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.set_retry_observer(observer), Self::OpenRouter(session) => session.inner.set_retry_observer(observer), Self::Speakeasy(session) => session.inner.set_retry_observer(observer), - Self::Anthropic(session) => session.set_retry_observer(observer), + Self::Anthropic(session, _) => session.set_retry_observer(observer), } } @@ -1162,7 +1193,7 @@ impl ModelSession for KitSession { let request = if matches!(self, Self::OpenAiSubscription(_)) { request } else { - project_tool_output_images(request, matches!(self, Self::Anthropic(_)))? + project_tool_output_images(request, matches!(self, Self::Anthropic(_, _)))? }; match self { Self::OpenAiSubscription(session) => session @@ -1181,9 +1212,9 @@ impl ModelSession for KitSession { next_media: 0, }) .map(KitTurn::OpenRouter), - Self::Anthropic(session) => { + Self::Anthropic(session, context_window) => { let request = project_anthropic_reasoning(request); - let context_window = session.model_name().and_then(anthropic_context_window); + let context_window = *context_window; session .begin_turn(request, cancellation) .await @@ -1208,7 +1239,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.model_name(), Self::OpenRouter(session) => session.inner.model_name(), Self::Speakeasy(session) => session.inner.model_name(), - Self::Anthropic(session) => session.model_name(), + Self::Anthropic(session, _) => session.model_name(), } } @@ -1217,7 +1248,7 @@ impl ModelSession for KitSession { Self::OpenAiSubscription(session) => session.provider_name(), Self::OpenRouter(session) => session.inner.provider_name(), Self::Speakeasy(_) => Some("speakeasy"), - Self::Anthropic(_) => Some("anthropic"), + Self::Anthropic(_, _) => Some("anthropic"), } } } @@ -1445,8 +1476,12 @@ pub async fn model_catalog(current: &ModelSelection) -> Vec { match SelectableAdapter::new(current.provider, current.model.clone()) { Ok(adapter) => adapter.model_catalog(current).await, Err(_) => { - model_catalog_with_openai(current, std::future::ready(DiscoveredModels::default())) - .await + model_catalog_with_openai( + current, + std::future::ready(DiscoveredModels::default()), + &Default::default(), + ) + .await } } } @@ -1454,6 +1489,7 @@ pub async fn model_catalog(current: &ModelSelection) -> Vec { async fn model_catalog_with_openai( current: &ModelSelection, openai_catalog: impl std::future::Future, + storage: &crate::credentials::CredentialStorage, ) -> Vec { let openrouter_url = match std::env::var_os("OPENROUTER_BASE_URL") { None => catalog_models_url(None), @@ -1484,8 +1520,11 @@ async fn model_catalog_with_openai( futures_util::future::join(openrouter_catalog, speakeasy_catalog).await } }; - let (discovered_openai, (openrouter, speakeasy)) = - futures_util::future::join(openai_catalog, other_catalogs).await; + let ((discovered_openai, (openrouter, speakeasy)), anthropic) = futures_util::future::join( + futures_util::future::join(openai_catalog, other_catalogs), + discover_anthropic(storage), + ) + .await; let openai_windows = discovered_openai.context_windows; let openrouter_windows = openrouter.context_windows; let speakeasy_windows = speakeasy.context_windows; @@ -1545,7 +1584,7 @@ async fn model_catalog_with_openai( models: speakeasy, context_windows: speakeasy_windows, }, - anthropic_model_group(current), + anthropic_model_group(current, anthropic), ] } @@ -1573,106 +1612,81 @@ fn project_anthropic_reasoning(mut request: TurnRequest) -> TurnRequest { request } -// Match a published alias or its eight-digit dated snapshot, not arbitrary suffixes. -fn anthropic_model_matches(model: &str, alias: &str) -> bool { - model == alias - || model - .strip_prefix(alias) - .and_then(|suffix| suffix.strip_prefix('-')) - .is_some_and(|date| date.len() == 8 && date.bytes().all(|byte| byte.is_ascii_digit())) -} - -const ANTHROPIC_ADAPTIVE_MODELS: &[&str] = &[ - "claude-sonnet-5-5", - "claude-opus-5-5", - "claude-haiku-5-5", - "claude-sonnet-4-6", - "claude-opus-4-6", -]; - fn anthropic_config( - api_key: &str, - model: String, + mut config: AnthropicConfig, effort: Option, + metadata: Option<&super::anthropic_catalog::Model>, ) -> Result { - let mut config = - AnthropicConfig::new(api_key, model.clone(), 16384).map_err(|error| error.to_string())?; + if let Some(limit) = metadata + .and_then(|model| model.max_tokens) + .filter(|limit| *limit > 0) + { + config.max_tokens = config.max_tokens.min(limit); + } let Some(effort) = effort else { return Ok(config); }; - if ANTHROPIC_ADAPTIVE_MODELS - .iter() - .any(|alias| anthropic_model_matches(&model, alias)) - { - config = config + let unavailable = || { + format!( + "Cannot configure explicit reasoning effort for Anthropic model {:?} from discovered capabilities; select default reasoning effort or check model discovery credentials and connectivity", + config.model + ) + }; + let metadata = metadata.ok_or_else(unavailable)?; + if !metadata.supports("/thinking/supported") { + return Err(unavailable()); + } + if metadata.supports("/thinking/types/adaptive/supported") { + let (path, output) = match effort { + ReasoningEffort::Low => ("/effort/low/supported", OutputEffort::Low), + ReasoningEffort::Medium => ("/effort/medium/supported", OutputEffort::Medium), + ReasoningEffort::High => ("/effort/high/supported", OutputEffort::High), + }; + if !metadata.supports("/effort/supported") || !metadata.supports(path) { + return Err(unavailable()); + } + Ok(config .with_thinking(ThinkingConfig::Adaptive) - .with_output_effort(match effort { - ReasoningEffort::Low => OutputEffort::Low, - ReasoningEffort::Medium => OutputEffort::Medium, - ReasoningEffort::High => OutputEffort::High, - }); - } else if [ - "claude-haiku-4-5", - "claude-sonnet-4-5", - "claude-opus-4-5", - "claude-sonnet-4", - "claude-opus-4", - "claude-opus-4-1", - "claude-3-7-sonnet", - ] - .iter() - .any(|alias| anthropic_model_matches(&model, alias)) - { - config = config.with_thinking(ThinkingConfig::Enabled { - budget_tokens: match effort { - ReasoningEffort::Low => 1024, - ReasoningEffort::Medium => 4096, - ReasoningEffort::High => 8192, - }, - }); + .with_output_effort(output)) + } else if metadata.supports("/thinking/types/enabled/supported") { + // Manual thinking requires a known output ceiling and at least 1024 tokens. + let limit = metadata + .max_tokens + .filter(|limit| *limit > 1024) + .ok_or_else(unavailable)?; + let desired = match effort { + ReasoningEffort::Low => 1024, + ReasoningEffort::Medium => 4096, + ReasoningEffort::High => 8192, + }; + let budget_tokens = desired.min(limit - 1).min(config.max_tokens - 1); + Ok(config.with_thinking(ThinkingConfig::Enabled { budget_tokens })) } else { - return Err(format!( - "reasoning effort is not configured for Anthropic model {model:?}; select default reasoning effort to use this model" - )); + Err(unavailable()) } - Ok(config) } -fn anthropic_context_window(model: &str) -> Option { - if ANTHROPIC_ADAPTIVE_MODELS +fn anthropic_model_group( + current: &ModelSelection, + discovered: Vec, +) -> ModelGroup { + let context_windows = discovered .iter() - .any(|alias| anthropic_model_matches(model, alias)) - { - Some(1_000_000) - } else if anthropic_model_matches(model, "claude-haiku-4-5") { - Some(200_000) - } else { - None - } -} - -fn anthropic_model_group(current: &ModelSelection) -> ModelGroup { - let mut models = [ - "claude-sonnet-5-5", - "claude-opus-5-5", - "claude-haiku-5-5", - "claude-sonnet-4-6", - "claude-opus-4-6", - "claude-haiku-4-5-20251001", - ] - .into_iter() - .map(str::to_owned) - .collect::>(); + .filter_map(|model| { + model + .context_window() + .map(|limit| (model.id.clone(), limit)) + }) + .collect(); + let mut models: Vec<_> = discovered.into_iter().map(|model| model.id).collect(); + models.sort(); + models.dedup(); if current.provider == ProviderKind::Anthropic && valid_model_id(¤t.model) && !models.contains(¤t.model) { models.push(current.model.clone()); } - let context_windows = models - .iter() - .filter_map(|model| anthropic_context_window(model).map(|window| (model.clone(), window))) - .collect(); ModelGroup { provider: ProviderKind::Anthropic, models, @@ -1680,6 +1694,20 @@ fn anthropic_model_group(current: &ModelSelection) -> ModelGroup { } } +async fn discover_anthropic( + storage: &crate::credentials::CredentialStorage, +) -> Vec { + match super::anthropic_auth::credentials(storage) { + Ok(Some(credentials)) => super::anthropic_catalog::discover( + super::anthropic_catalog::MODELS_URL, + credentials.api_key(), + ) + .await + .unwrap_or_default(), + _ => Vec::new(), + } +} + fn openrouter_fallback() -> Vec { OPENROUTER_FALLBACK .iter() @@ -1770,62 +1798,51 @@ fn parse_discovered_models(value: &Value) -> Result { )] mod tests { #[test] - fn anthropic_production_config_maps_only_known_reasoning_modes() { - use super::{OutputEffort, ReasoningEffort, ThinkingConfig, anthropic_config}; - for model in super::ANTHROPIC_ADAPTIVE_MODELS { - for model in [model.to_string(), format!("{model}-20261001")] { - for effort in [ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ] { - let config = anthropic_config("test", model.clone(), Some(effort)).unwrap(); - assert!(matches!(config.thinking, Some(ThinkingConfig::Adaptive))); - assert!(matches!( - (effort, config.output_effort), - (ReasoningEffort::Low, Some(OutputEffort::Low)) - | (ReasoningEffort::Medium, Some(OutputEffort::Medium)) - | (ReasoningEffort::High, Some(OutputEffort::High)) - )); - assert_eq!(config.max_tokens, 16384); - assert_eq!(super::anthropic_context_window(&model), Some(1_000_000)); - } - } - } - for (effort, expected) in [ - (ReasoningEffort::Low, 1024), - (ReasoningEffort::Medium, 4096), - (ReasoningEffort::High, 8192), - ] { - let config = - anthropic_config("test", "claude-haiku-4-5-20251001".into(), Some(effort)).unwrap(); - assert!( - matches!(config.thinking, Some(ThinkingConfig::Enabled { budget_tokens }) if budget_tokens == expected) - ); - assert!(config.output_effort.is_none()); - } - assert_eq!( - super::anthropic_context_window("claude-haiku-4-5-20251001"), - Some(200_000) + fn anthropic_reasoning_uses_capabilities_not_names() { + use super::{AnthropicConfig, ReasoningEffort, ThinkingConfig, anthropic_config}; + let config = || AnthropicConfig::new("test", "completely-custom", 16384).unwrap(); + assert!( + anthropic_config(config(), None, None) + .unwrap() + .thinking + .is_none() ); - for model in [ - "claude-future", - "claude-sonnet-5-5-custom", - "claude-sonnet-4-6", - "claude-haiku-4-5", - ] { - let config = anthropic_config("test", model.into(), None).unwrap(); - assert!(config.thinking.is_none()); - assert!(config.output_effort.is_none()); - } - for model in ["claude-future", "claude-sonnet-5-5-custom"] { - assert!( - anthropic_config("test", model.into(), Some(ReasoningEffort::High)) - .unwrap_err() - .contains("default reasoning effort") - ); - assert_eq!(super::anthropic_context_window(model), None); + assert!( + anthropic_config(config(), Some(ReasoningEffort::High), None) + .unwrap_err() + .contains("default reasoning") + ); + let mut model: super::super::anthropic_catalog::Model = serde_json::from_value(serde_json::json!({ + "id":"completely-custom", "max_input_tokens":77777,"max_tokens":2048, + "capabilities":{"thinking":{"supported":true,"types":{"enabled":{"supported":true}}}} + })).unwrap(); + let manual = anthropic_config(config(), Some(ReasoningEffort::High), Some(&model)).unwrap(); + assert_eq!(manual.max_tokens, 2048); + assert!(matches!( + manual.thinking, + Some(ThinkingConfig::Enabled { + budget_tokens: 2047 + }) + )); + model.max_tokens = Some(1024); + assert!(anthropic_config(config(), Some(ReasoningEffort::Low), Some(&model)).is_err()); + model.max_tokens = None; + assert!(anthropic_config(config(), Some(ReasoningEffort::Low), Some(&model)).is_err()); + model.capabilities = serde_json::json!({"thinking":{"supported":true,"types":{"adaptive":{"supported":true}}}, + "effort":{"supported":true,"low":{"supported":true},"medium":{"supported":true},"high":{"supported":false}}}); + for effort in [ReasoningEffort::Low, ReasoningEffort::Medium] { + let adaptive = anthropic_config(config(), Some(effort), Some(&model)).unwrap(); + assert!(matches!(adaptive.thinking, Some(ThinkingConfig::Adaptive))); + assert!(adaptive.output_effort.is_some()); + assert_eq!(adaptive.max_tokens, 16384); } + assert!(anthropic_config(config(), Some(ReasoningEffort::High), Some(&model)).is_err()); + let selection = + super::ModelSelection::new(super::ProviderKind::Anthropic, "another-custom"); + let group = super::anthropic_model_group(&selection, vec![model]); + assert_eq!(group.context_windows["completely-custom"], 77777); + assert!(group.models.contains(&selection.model)); + assert!(!group.context_windows.contains_key(&selection.model)); } #[test] @@ -1837,19 +1854,12 @@ mod tests { super::authentication_method_id("anthropic_auth_required: missing key"), Some("anthropic") ); - let group = super::anthropic_model_group(&selection); + let group = super::anthropic_model_group(&selection, Vec::new()); assert!(group.models.contains(&selection.model)); - assert_eq!( - &group.models[..3], - &["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5"] - ); - for model in &group.models[..3] { - assert_eq!(group.context_windows[model], 1_000_000); - } - assert_eq!(group.context_windows["claude-haiku-4-5-20251001"], 200_000); - assert_eq!(group.context_windows[&selection.model], 1_000_000); + assert_eq!(group.models, vec![selection.model.clone()]); + assert!(group.context_windows.is_empty()); let custom = super::ModelSelection::new(super::ProviderKind::Anthropic, "claude-future"); - let group = super::anthropic_model_group(&custom); + let group = super::anthropic_model_group(&custom, Vec::new()); assert!(group.models.contains(&custom.model)); assert!(!group.context_windows.contains_key(&custom.model)); assert_eq!( @@ -2210,12 +2220,79 @@ mod tests { ); } + #[tokio::test] + async fn anthropic_discovery_failure_preserves_custom_session() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut byte = [0]; + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + write!( + stream, + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + } + }); + let config = super::AnthropicConfig::new("test", "custom-id", 16384).unwrap(); + let mut provider = super::AnthropicKitAdapter { + config: Box::new(config), + key: zeroize::Zeroizing::new("test".into()), + effort: None, + models_url: format!("http://{address}/v1/models"), + }; + let session = KitAdapter::Anthropic(provider.clone()) + .start_session(SessionConfig::new("custom")) + .await + .unwrap(); + assert_eq!(session.model_name(), Some("custom-id")); + assert!(matches!(session, super::KitSession::Anthropic(_, None))); + provider.effort = Some(super::ReasoningEffort::High); + let result = KitAdapter::Anthropic(provider) + .start_session(SessionConfig::new("explicit")) + .await; + assert!( + matches!(result, Err(agentkit_loop::LoopError::InvalidState(message)) if message.contains("default reasoning effort")) + ); + server.join().unwrap(); + } + #[tokio::test] async fn anthropic_uses_native_messages_api_and_context_metadata() { use agentkit_loop::ModelTurn; let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let address = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { + let (mut discovery, _) = listener.accept().unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + discovery.read_exact(&mut byte).unwrap(); + headers.push(byte[0]); + } + let discovery_request = String::from_utf8(headers).unwrap().to_ascii_lowercase(); + assert!(discovery_request.starts_with("get /v1/models/custom-alias ")); + assert!(discovery_request.contains("x-api-key: test-api-key")); + assert!(discovery_request.contains("anthropic-version: 2023-06-01")); + let catalog = json!({"id":"canonical-snapshot", "max_input_tokens":1000000, "max_tokens":8192, + "capabilities":{"thinking":{"supported":true,"types":{"adaptive":{"supported":true}}}, + "effort":{"supported":true,"high":{"supported":true}}}}).to_string(); + write!( + discovery, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{catalog}", + catalog.len() + ) + .unwrap(); + drop(discovery); let (mut stream, _) = listener.accept().unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(5))) @@ -2258,13 +2335,16 @@ mod tests { write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); String::from_utf8(request).unwrap() }); - let config = super::anthropic_config("test-api-key", "claude-sonnet-4-6".into(), None) + let config = super::AnthropicConfig::new("test-api-key", "custom-alias", 16384) .unwrap() .with_base_url(format!("http://{address}/v1/messages")) .with_streaming(false); - let adapter = KitAdapter::Anthropic( - agentkit_provider_anthropic::AnthropicAdapter::new(config).unwrap(), - ); + let adapter = KitAdapter::Anthropic(super::AnthropicKitAdapter { + config: Box::new(config), + key: zeroize::Zeroizing::new("test-api-key".into()), + effort: Some(super::ReasoningEffort::High), + models_url: format!("http://{address}/v1/models"), + }); assert_eq!(adapter.provider_name(), Some("anthropic")); let mut session = adapter .start_session(SessionConfig::new("anthropic-contract")) @@ -2344,8 +2424,10 @@ mod tests { assert!(headers.contains("anthropic-version: 2023-06-01")); assert!(!headers.contains("authorization:")); let body: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(body["model"], "claude-sonnet-4-6"); - assert_eq!(body["max_tokens"], 16384); + assert_eq!(body["model"], "custom-alias"); + assert_eq!(body["thinking"]["type"], "adaptive"); + assert_eq!(body["output_config"]["effort"], "high"); + assert_eq!(body["max_tokens"], 8192); assert_eq!(body["messages"][0]["role"], "user"); let messages = body["messages"].as_array().unwrap(); assert_eq!( diff --git a/src/provider/anthropic_catalog.rs b/src/provider/anthropic_catalog.rs new file mode 100644 index 00000000..16344dcd --- /dev/null +++ b/src/provider/anthropic_catalog.rs @@ -0,0 +1,291 @@ +//! Authenticated, bounded discovery with no shared, cross-credential cache. +use futures_util::StreamExt as _; +use serde::Deserialize; +use serde_json::Value; +use std::time::Duration; + +pub(super) const MODELS_URL: &str = "https://api.anthropic.com/v1/models"; +const MAX_BYTES: usize = 2 * 1024 * 1024; +const MAX_MODELS: usize = 2_000; +const MAX_PAGES: usize = 20; + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct Model { + pub id: String, + pub max_input_tokens: Option, + pub max_tokens: Option, + #[serde(default)] + pub capabilities: Value, +} +impl Model { + pub fn context_window(&self) -> Option { + self.max_input_tokens.filter(|value| *value > 0) + } + pub fn supports(&self, pointer: &str) -> bool { + self.capabilities.pointer(pointer).and_then(Value::as_bool) == Some(true) + } +} +#[derive(Deserialize)] +struct Page { + data: Vec, + has_more: bool, + last_id: Option, +} +pub(super) async fn discover(url: &str, key: &str) -> Result, String> { + tokio::time::timeout(Duration::from_secs(5), fetch(url, key)) + .await + .map_err(|_| "Anthropic model discovery timed out".to_owned())? +} +fn client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(5)) + .build() + .map_err(|_| "Could not build Anthropic discovery client".into()) +} + +/// Retrieve accepts aliases as well as canonical IDs. Keep the requested completion +/// model unchanged: the returned ID only describes the resolved metadata. +pub(super) async fn retrieve(url: &str, key: &str, model: &str) -> Result { + tokio::time::timeout(Duration::from_secs(5), async { + let mut url = reqwest::Url::parse(url).map_err(|_| "Invalid Anthropic catalog URL")?; + url.path_segments_mut() + .map_err(|_| "Invalid Anthropic catalog URL")? + .pop_if_empty() + .push(model); + let mut bytes_left = MAX_BYTES; + let bytes = read_model_response(&client()?, url, key, &mut bytes_left).await?; + serde_json::from_slice(&bytes).map_err(|_| "Invalid Anthropic model metadata".into()) + }) + .await + .map_err(|_| "Anthropic model discovery timed out".to_owned())? +} + +async fn read_model_response( + client: &reqwest::Client, + url: reqwest::Url, + key: &str, + bytes_left: &mut usize, +) -> Result, String> { + let response = client + .get(url) + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + .send() + .await + .map_err(|_| "Anthropic discovery request failed")?; + if !response.status().is_success() { + return Err(format!( + "Anthropic discovery returned HTTP {}", + response.status() + )); + } + if response + .content_length() + .is_some_and(|length| length > *bytes_left as u64) + { + return Err("Anthropic catalog exceeds byte limit".into()); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Could not read Anthropic catalog")?; + if chunk.len() > *bytes_left { + return Err("Anthropic catalog exceeds byte limit".into()); + } + *bytes_left -= chunk.len(); + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +async fn fetch(url: &str, key: &str) -> Result, String> { + let client = client()?; + let mut models = Vec::new(); + let mut cursor: Option = None; + let mut model_count = 0; + let mut cursors = std::collections::HashSet::new(); + let mut bytes_left = MAX_BYTES; + for _ in 0..MAX_PAGES { + let mut page_url = reqwest::Url::parse(url).map_err(|_| "Invalid Anthropic catalog URL")?; + page_url.query_pairs_mut().append_pair("limit", "1000"); + if let Some(cursor) = &cursor { + page_url.query_pairs_mut().append_pair("after_id", cursor); + } + let bytes = read_model_response(&client, page_url, key, &mut bytes_left).await?; + let page: Page = + serde_json::from_slice(&bytes).map_err(|_| "Invalid Anthropic model catalog")?; + model_count += page.data.len(); + if model_count > MAX_MODELS { + return Err("Anthropic catalog exceeds model limit".into()); + } + models.extend( + page.data + .into_iter() + .filter(|model| super::adapter::valid_model_id(&model.id)), + ); + if !page.has_more { + return Ok(models); + } + let next = page + .last_id + .filter(|id| !id.is_empty()) + .ok_or("Anthropic catalog missing pagination cursor")?; + if !cursors.insert(next.clone()) { + return Err("Anthropic catalog repeated pagination cursor".into()); + } + cursor = Some(next); + } + Err("Anthropic catalog exceeds page limit".into()) +} + +#[cfg(test)] +#[allow( + clippy::panic, + clippy::unwrap_used, + clippy::expect_used, + clippy::disallowed_methods, + clippy::disallowed_macros +)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + + fn server(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}/v1/models", listener.local_addr().unwrap()); + let handle = std::thread::spawn(move || { + responses.into_iter().map(|(status, body)| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let (mut stream, _) = loop { + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)), + Err(error) => panic!("fixture accept failed: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nLocation: http://127.0.0.1:1/leak\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + String::from_utf8(request).unwrap() + }).collect() + }); + (url, handle) + } + + #[tokio::test] + async fn retrieve_encodes_alias_and_accepts_canonical_identity() { + let (url, handle) = server(vec![( + 200, + json!({"id":"canonical-id", "max_input_tokens":77777, + "max_tokens":8192,"capabilities":{"thinking":{"supported":true}}}) + .to_string(), + )]); + let model = retrieve(&url, "alias-key", "alias/with?#space name") + .await + .unwrap(); + assert_eq!(model.id, "canonical-id"); + assert_eq!(model.context_window(), Some(77777)); + let requests = handle.join().unwrap(); + assert!(requests[0].starts_with("GET /v1/models/alias%2Fwith%3F%23space%20name HTTP/1.1")); + assert!(requests[0].contains("x-api-key: alias-key")); + assert!(requests[0].contains("anthropic-version: 2023-06-01")); + assert!(!requests[0].contains("limit=")); + } + + #[tokio::test] + async fn retrieve_rejects_redirects_errors_and_oversized_metadata() { + for (status, body) in [ + (302, "{}".to_owned()), + (401, "{}".to_owned()), + (200, "invalid".to_owned()), + (200, " ".repeat(MAX_BYTES + 1)), + ] { + let (url, handle) = server(vec![(status, body)]); + assert!(retrieve(&url, "test", "alias").await.is_err()); + let _ = handle.join(); + } + } + + #[tokio::test] + async fn paginated_authenticated_capabilities_and_limits() { + let (url, handle) = server(vec![ + (200, json!({"data":[{"id":"custom-first","max_input_tokens":77777,"max_tokens":2048, + "capabilities":{"thinking":{"supported":true,"types":{"enabled":{"supported":true}}}},"future_field":true}], + "has_more":true,"last_id":"cursor /+"}).to_string()), + (200, json!({"data":[{"id":"custom-second","max_input_tokens":null,"max_tokens":null}],"has_more":false,"last_id":null}).to_string()), + ]); + let models = discover(&url, "secret-one").await.unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].context_window(), Some(77777)); + assert_eq!(models[0].max_tokens, Some(2048)); + assert!(models[0].supports("/thinking/types/enabled/supported")); + assert!(!models[1].supports("/thinking/supported")); + assert_eq!(models[1].context_window(), None); + let requests = handle.join().unwrap(); + for request in &requests { + let request = request.to_ascii_lowercase(); + assert!(request.contains("x-api-key: secret-one\r\n")); + assert!(request.contains("anthropic-version: 2023-06-01\r\n")); + assert!(request.contains("limit=1000")); + } + assert!(requests[1].contains("after_id=cursor+%2F%2B")); + } + + #[tokio::test] + async fn failures_are_bounded_and_do_not_follow_redirects() { + for (status, body) in [ + (401, "{}".to_owned()), + (302, "{}".to_owned()), + (200, "not json".to_owned()), + ( + 200, + json!({"data":[],"has_more":true,"last_id":null}).to_string(), + ), + (200, " ".repeat(MAX_BYTES + 1)), + ( + 200, + json!({"data":vec![json!({"id":"x"}); MAX_MODELS + 1],"has_more":false}) + .to_string(), + ), + ] { + let (url, handle) = server(vec![(status, body)]); + assert!(discover(&url, "test").await.is_err()); + // An over-limit response can close while the fixture is still writing. + let _ = handle.join(); + } + let body = json!({"data":[],"has_more":true,"last_id":"same"}).to_string(); + let (url, handle) = server(vec![(200, body.clone()), (200, body)]); + assert!( + discover(&url, "test") + .await + .unwrap_err() + .contains("repeated") + ); + handle.join().unwrap(); + } + + #[tokio::test] + async fn discovery_is_not_cached_across_credentials() { + let body = json!({"data":[],"has_more":false}).to_string(); + let (url, handle) = server(vec![(200, body.clone()), (200, body)]); + discover(&url, "key-one").await.unwrap(); + discover(&url, "key-two").await.unwrap(); + let requests = handle.join().unwrap(); + assert!(requests[0].contains("key-one")); + assert!(requests[1].contains("key-two")); + } +} diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 72a130f0..522f1fd6 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1,5 +1,6 @@ mod adapter; pub(crate) mod anthropic_auth; +mod anthropic_catalog; #[doc(hidden)] pub use anthropic_auth::{AnthropicAuthCommand, execute_anthropic_auth}; From e455a1e34c36506bbd57d3ca3149a2b39596cb28 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 18 Oct 2026 10:10:04 +0100 Subject: [PATCH 5/5] feat(tui): split model selector into provider and model pages --- docs/user/tui-and-sessions.md | 2 +- src/tui/app.rs | 227 ++++++++++++++++++++++++++++++---- src/tui/mod.rs | 1 + src/tui/ui.rs | 175 ++++++++++++-------------- 4 files changed, 283 insertions(+), 122 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 502ebbd9..560ed45c 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -191,7 +191,7 @@ The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, ` The other commands listed above are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace. Up and Down move, Enter uses the existing resume flow, `R` opens an inline rename field, and Esc cancels renaming or closes the dialog. Submit an empty rename and confirm to clear the custom name. After a save, the picker remains open on the selected session and refreshes its displayed name. `/close` closes the current session and exits the TUI. -`/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. New sessions start from the resolved CLI/TOML default. Durable sessions remember their reasoning-effort selection, including an explicit provider `default`, and restore it on load or resume even if the process default has changed. Older transcripts without a recorded effort continue to use the process default. Forks inherit the source session’s current effort. Saving to TOML changes the default for future sessions; it is not required to retain the effort of the current session. Model selection still starts from the resolved CLI/TOML default on reload. Reasoning-effort records require a Kit version that understands transcript schema 6; older versions reject these records rather than silently discard the saved selection. +`/model` opens the model selector on a provider list; Enter on a provider shows that provider's models. Typing searches only the current page, and the search clears when you move between pages. Esc returns from the model list to the provider list, and closes the selector from the provider list. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. New sessions start from the resolved CLI/TOML default. Durable sessions remember their reasoning-effort selection, including an explicit provider `default`, and restore it on load or resume even if the process default has changed. Older transcripts without a recorded effort continue to use the process default. Forks inherit the source session’s current effort. Saving to TOML changes the default for future sessions; it is not required to retain the effort of the current session. Model selection still starts from the resolved CLI/TOML default on reload. Reasoning-effort records require a Kit version that understands transcript schema 6; older versions reject these records rather than silently discard the saved selection. Before changing models, Kit compares the latest provider-reported transcript occupancy with the target model's advertised context window. It adds a 20% tokenizer margin (`ceil(tokens × 1.20)`) and warns when that estimate is at least 80% of the target window. This is the latest request's occupancy, not accumulated session usage; cached input tokens are not counted twice. diff --git a/src/tui/app.rs b/src/tui/app.rs index 79f5df74..c4f0eb8c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -237,6 +237,8 @@ pub struct ModelDialog { pub query: String, pub selected: usize, pub save_defaults: bool, + /// `None` shows the provider page; `Some` shows that provider's models. + pub provider: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1054,9 +1056,24 @@ fn ordered_token_score(candidate: &[String], query: &[String]) -> Option Option { +fn rank_by_name(items: Vec, query: &str, name: impl Fn(&T) -> &str) -> Vec { + if query.trim().is_empty() { + return items; + } + let mut ranked = items + .into_iter() + .enumerate() + .filter_map(|(index, item)| { + model_score(name(&item), "", query).map(|score| (score, index, item)) + }) + .collect::>(); + ranked.sort_by_key(|(score, index, _)| (*score, *index)); + ranked.into_iter().map(|(_, _, item)| item).collect() +} + +fn model_score(name: &str, qualifier: &str, query: &str) -> Option { let query = query.trim().to_lowercase(); - let model = choice.model.to_lowercase(); + let model = name.to_lowercase(); let basename = model.rsplit('/').next().unwrap_or(&model); let query_tokens = model_tokens(&query); if query_tokens.is_empty() { @@ -1100,7 +1117,7 @@ fn model_score(choice: &ModelChoice, query: &str) -> Option { return Some(score); } - let mut all_tokens = model_tokens(&choice.provider); + let mut all_tokens = model_tokens(qualifier); all_tokens.extend(candidate_tokens); ordered_token_score(&all_tokens, &query_tokens).map(|score| ModelScore { tier: 4, ..score }) } @@ -3524,23 +3541,101 @@ impl App { self.effort_choices = choices; } + /// Distinct providers in catalog order. + fn model_providers(&self) -> Vec<&str> { + let mut providers = Vec::<&str>::new(); + for choice in &self.model_choices { + if !providers.contains(&choice.provider.as_str()) { + providers.push(&choice.provider); + } + } + providers + } + + fn provider_models<'a>(&'a self, provider: &'a str) -> impl Iterator { + self.model_choices + .iter() + .filter(move |choice| choice.provider == provider) + } + + /// Providers matching the search on the provider page; empty on the model page. + pub fn selected_model_providers(&self) -> Vec<&str> { + match &self.model_dialog { + Some(dialog) if dialog.provider.is_none() => { + rank_by_name(self.model_providers(), &dialog.query, |provider| provider) + } + _ => Vec::new(), + } + } + + /// Models of the chosen provider matching the search; empty on the provider page. pub fn selected_model_choices(&self) -> Vec<&ModelChoice> { let Some(dialog) = &self.model_dialog else { return Vec::new(); }; - if dialog.query.trim().is_empty() { - return self.model_choices.iter().collect(); + let Some(provider) = &dialog.provider else { + return Vec::new(); + }; + rank_by_name( + self.provider_models(provider).collect(), + &dialog.query, + |choice| &choice.model, + ) + } + + fn model_dialog_len(&self) -> usize { + match &self.model_dialog { + Some(dialog) if dialog.provider.is_some() => self.selected_model_choices().len(), + _ => self.selected_model_providers().len(), + } + } + + fn open_model_dialog(&mut self) { + let selected = self + .model_providers() + .iter() + .position(|provider| *provider == self.provider) + .unwrap_or(0); + self.model_dialog = Some(ModelDialog { + query: String::new(), + selected, + save_defaults: false, + provider: None, + }); + } + + fn open_model_provider(&mut self, provider: String) { + let selected = if provider == self.provider { + self.provider_models(&provider) + .position(|choice| choice.model == self.model) + .unwrap_or(0) + } else { + 0 + }; + if let Some(dialog) = &mut self.model_dialog { + dialog.query.clear(); + dialog.selected = selected; + dialog.provider = Some(provider); } - let mut choices = self - .model_choices + } + + fn close_model_provider(&mut self) { + let Some(provider) = self + .model_dialog + .as_mut() + .and_then(|dialog| dialog.provider.take()) + else { + return; + }; + let selected = self + .model_providers() .iter() - .enumerate() - .filter_map(|(index, choice)| { - model_score(choice, &dialog.query).map(|score| (score, index, choice)) - }) - .collect::>(); - choices.sort_by_key(|(score, index, _)| (*score, *index)); - choices.into_iter().map(|(_, _, choice)| choice).collect() + .position(|candidate| *candidate == provider) + .unwrap_or(0); + if let Some(dialog) = &mut self.model_dialog { + dialog.query.clear(); + dialog.selected = selected; + } } fn closest_model(&self, query: &str) -> Option { @@ -3548,7 +3643,8 @@ impl App { .iter() .enumerate() .filter_map(|(index, choice)| { - model_score(choice, query).map(|score| (score, index, choice)) + model_score(&choice.model, &choice.provider, query) + .map(|score| (score, index, choice)) }) .min_by_key(|(score, index, _)| (*score, *index)) .map(|(_, _, choice)| choice.clone()) @@ -3556,7 +3652,17 @@ impl App { fn handle_model_key(&mut self, key: KeyEvent) -> Action { match key.code { - KeyCode::Esc => self.model_dialog = None, + KeyCode::Esc => { + if self + .model_dialog + .as_ref() + .is_some_and(|dialog| dialog.provider.is_some()) + { + self.close_model_provider(); + } else { + self.model_dialog = None; + } + } KeyCode::Tab => { if let Some(dialog) = &mut self.model_dialog { dialog.save_defaults = !dialog.save_defaults; @@ -3568,7 +3674,7 @@ impl App { } } KeyCode::Down => { - let count = self.selected_model_choices().len(); + let count = self.model_dialog_len(); if let Some(dialog) = &mut self.model_dialog { dialog.selected = (dialog.selected + 1).min(count.saturating_sub(1)); } @@ -3580,6 +3686,21 @@ impl App { } } KeyCode::Enter => { + let selected = self.model_dialog.as_ref().map_or(0, |value| value.selected); + if self + .model_dialog + .as_ref() + .is_some_and(|dialog| dialog.provider.is_none()) + { + let provider = self + .selected_model_providers() + .get(selected) + .map(|provider| (*provider).to_owned()); + if let Some(provider) = provider { + self.open_model_provider(provider); + } + return Action::None; + } let choice = self .selected_model_choices() .get(self.model_dialog.as_ref().map_or(0, |value| value.selected)) @@ -4519,11 +4640,7 @@ impl App { if self.model_choices.is_empty() { self.toast("no models are available"); } else { - self.model_dialog = Some(ModelDialog { - query: String::new(), - selected: 0, - save_defaults: false, - }); + self.open_model_dialog(); } Action::None } @@ -9146,6 +9263,7 @@ mod tests { query: "claude sonet".into(), selected: 0, save_defaults: false, + provider: Some("anthropic".into()), }); let ranked = app.selected_model_choices(); @@ -9154,13 +9272,76 @@ mod tests { .iter() .map(|choice| choice.model.as_str()) .collect::>(), - ["anthropic/claude-sonnet-4", "claude-3-7-sonnet"] + ["claude-3-7-sonnet"] ); + app.model_dialog.as_mut().unwrap().query = "gpt".into(); + assert!(app.selected_model_choices().is_empty()); + app.model_dialog.as_mut().unwrap().query = "totally unrelated".into(); assert!(app.selected_model_choices().is_empty()); } + #[test] + fn model_dialog_navigates_from_providers_to_models_and_back() { + let mut app = app(); + app.set_model_choices(similar_models()); + app.editor.insert_str("/model"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + // Opens on the provider page with the current provider selected. + let dialog = app.model_dialog.as_ref().expect("model dialog"); + assert_eq!(dialog.provider, None); + assert_eq!(dialog.selected, 0); + assert_eq!( + app.selected_model_providers(), + ["openai-subscription", "anthropic", "openrouter"] + ); + assert!(app.selected_model_choices().is_empty()); + + // Provider search matches provider names only. + for character in "openr".chars() { + app.handle_key(press(KeyCode::Char(character))); + } + assert_eq!(app.selected_model_providers(), ["openrouter"]); + app.handle_key(press(KeyCode::Enter)); + let dialog = app.model_dialog.as_ref().expect("model dialog"); + assert_eq!(dialog.provider.as_deref(), Some("openrouter")); + assert_eq!(dialog.query, ""); + assert!(app.selected_model_providers().is_empty()); + assert_eq!( + app.selected_model_choices() + .iter() + .map(|choice| choice.model.as_str()) + .collect::>(), + ["anthropic/claude-sonnet-4"] + ); + + // Esc returns to providers with a fresh search and the provider selected. + app.handle_key(press(KeyCode::Char('x'))); + app.handle_key(press(KeyCode::Esc)); + let dialog = app.model_dialog.as_ref().expect("model dialog"); + assert_eq!(dialog.provider, None); + assert_eq!(dialog.query, ""); + assert_eq!(dialog.selected, 2); + + // The current model is preselected within the current provider. + app.model_dialog.as_mut().unwrap().selected = 0; + app.handle_key(press(KeyCode::Enter)); + assert_eq!(app.model_dialog.as_ref().unwrap().selected, 2); + let Action::SelectModel { choice, .. } = app.handle_key(press(KeyCode::Enter)) else { + panic!("expected model selection"); + }; + assert_eq!(choice.model, "gpt-5.4"); + assert!(app.model_dialog.is_none()); + + app.open_model_dialog(); + app.handle_key(press(KeyCode::Esc)); + assert!(app.model_dialog.is_none()); + } + #[test] fn model_command_uses_multi_token_relevance_and_does_not_guess_on_no_match() { let mut app = app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 07fbde69..d7f0f6bf 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6581,6 +6581,7 @@ mod tests { query: String::new(), selected: 0, save_defaults: false, + provider: None, }) } 1 => { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 30a9dca7..e2e5f21f 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -509,49 +509,15 @@ fn draw_file_picker( ); } -#[derive(Debug, PartialEq, Eq)] -enum ModelDialogRow<'a> { - Provider(&'a str), - Choice { index: usize, label: String }, -} - -fn model_dialog_rows<'a>( - choices: &[&'a crate::tui::app::ModelChoice], - grouped: bool, -) -> Vec> { - let mut rows = Vec::new(); - let mut last_provider = None; - for (index, choice) in choices.iter().enumerate() { - if grouped && last_provider != Some(choice.provider.as_str()) { - rows.push(ModelDialogRow::Provider(&choice.provider)); - last_provider = Some(&choice.provider); - } - let label = if grouped { - choice.model.clone() - } else { - format!("{} · {}", choice.provider, choice.model) - }; - rows.push(ModelDialogRow::Choice { index, label }); - } - rows -} - -fn model_dialog_viewport( - rows: &[ModelDialogRow<'_>], - selected: usize, - height: usize, -) -> std::ops::Range { - if height == 0 || rows.is_empty() { +fn model_dialog_viewport(len: usize, selected: usize, height: usize) -> Range { + if height == 0 || len == 0 { return 0..0; } - let selected_row = rows - .iter() - .position(|row| matches!(row, ModelDialogRow::Choice { index, .. } if *index == selected)) - .unwrap_or(0); - let start = selected_row + let start = selected + .min(len - 1) .saturating_sub(height / 2) - .min(rows.len().saturating_sub(height)); - start..(start + height).min(rows.len()) + .min(len.saturating_sub(height)); + start..(start + height).min(len) } fn visible_query_tail(query: &str, width: usize) -> &str { @@ -816,12 +782,31 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { width, height, ); - let choices = app.selected_model_choices(); + let (labels, title, enter, esc) = match &dialog.provider { + None => ( + app.selected_model_providers() + .into_iter() + .map(str::to_owned) + .collect::>(), + " provider ".to_owned(), + "open", + "close", + ), + Some(provider) => ( + app.selected_model_choices() + .into_iter() + .map(|choice| choice.model.clone()) + .collect(), + format!(" {provider} · model "), + "select", + "back", + ), + }; let footer = format!( - "tab defaults [{}] · enter select · esc close", + "tab defaults [{}] · enter {enter} · esc {esc}", if dialog.save_defaults { "x" } else { " " } ); - let panel = Panel::bordered().title(" model "); + let panel = Panel::bordered().title(title); let inner = panel.inner(area); let footer_rows = u16::from(inner.height >= 3); let search_rows = u16::from(inner.height >= 2); @@ -832,24 +817,19 @@ fn draw_model_dialog(frame: &mut Frame<'_>, app: &App, dialog: &ModelDialog) { ]) .areas(inner); let visible = list.height as usize; - let rows = model_dialog_rows(&choices, dialog.query.trim().is_empty()); - let viewport = model_dialog_viewport(&rows, dialog.selected, visible); - let lines = rows[viewport] + let viewport = model_dialog_viewport(labels.len(), dialog.selected, visible); + let lines = labels[viewport.clone()] .iter() - .map(|row| match row { - ModelDialogRow::Provider(provider) => { - Line::from(Span::styled((*provider).to_owned(), theme::faint())) - } - ModelDialogRow::Choice { index, label } => { - let selected = *index == dialog.selected; - let marker = if selected { "› " } else { " " }; - let style = if selected { - theme::accent() - } else { - theme::text() - }; - Line::from(Span::styled(format!("{marker}{label}"), style)) - } + .zip(viewport) + .map(|(label, index)| { + let selected = index == dialog.selected; + let marker = if selected { "› " } else { " " }; + let style = if selected { + theme::accent() + } else { + theme::text() + }; + Line::from(Span::styled(format!("{marker}{label}"), style)) }) .collect::>(); frame.render_widget(Clear, area); @@ -3358,8 +3338,8 @@ mod tests { use unicode_width::UnicodeWidthStr; use super::{ - ImageRuntime, MAX_PROMPT_ROWS, ModelDialogRow, agent_lines, body_layout, draw, draw_agents, - model_dialog_rows, model_dialog_viewport, native_links_obscured, prompt_lines, + ImageRuntime, MAX_PROMPT_ROWS, agent_lines, body_layout, draw, draw_agents, + model_dialog_viewport, native_links_obscured, prompt_lines, refresh_transcript_cache_with_images, truncate_to_width, user_block_rows, user_line, }; use crate::{ @@ -4730,42 +4710,9 @@ mod tests { #[test] fn model_viewport_keeps_a_selection_beyond_the_first_page_visible() { - let choices = (0..8) - .map(|index| model_choice("provider", &format!("model-{index}"))) - .collect::>(); - let choices = choices.iter().collect::>(); - let rows = model_dialog_rows(&choices, true); - let viewport = model_dialog_viewport(&rows, 6, 4); - - assert!( - rows[viewport] - .iter() - .any(|row| matches!(row, ModelDialogRow::Choice { index: 6, .. })) - ); - } - - #[test] - fn model_viewport_counts_provider_headers_at_group_boundaries() { - let choices = [ - model_choice("alpha", "one"), - model_choice("alpha", "two"), - model_choice("beta", "three"), - model_choice("beta", "four"), - ]; - let choices = choices.iter().collect::>(); - let rows = model_dialog_rows(&choices, true); - let viewport = model_dialog_viewport(&rows, 2, 2); - - assert_eq!( - &rows[viewport], - &[ - ModelDialogRow::Provider("beta"), - ModelDialogRow::Choice { - index: 2, - label: "three".into(), - }, - ] - ); + assert_eq!(model_dialog_viewport(8, 6, 4), 4..8); + assert_eq!(model_dialog_viewport(8, 1, 4), 0..4); + assert_eq!(model_dialog_viewport(0, 0, 4), 0..0); } #[test] @@ -4802,6 +4749,7 @@ mod tests { query: String::new(), selected: 9, save_defaults: false, + provider: Some("provider".into()), }); let frame = render(&mut app, 24, 8); @@ -4811,6 +4759,37 @@ mod tests { assert!(tiny.contains("› model-9"), "{tiny}"); } + #[test] + fn model_dialog_provider_page_lists_only_providers() { + let mut app = sample(); + app.model_choices = vec![ + model_choice("alpha", "one"), + model_choice("alpha", "two"), + model_choice("beta", "three"), + ]; + app.model_dialog = Some(ModelDialog { + query: String::new(), + selected: 1, + save_defaults: false, + provider: None, + }); + + let frame = render(&mut app, 60, 12); + assert!(frame.contains("provider"), "{frame}"); + assert!(frame.contains(" alpha"), "{frame}"); + assert!(frame.contains("› beta"), "{frame}"); + assert!(!frame.contains("three"), "{frame}"); + assert!(frame.contains("esc close"), "{frame}"); + + app.model_dialog.as_mut().unwrap().provider = Some("alpha".into()); + app.model_dialog.as_mut().unwrap().selected = 0; + let models = render(&mut app, 60, 12); + assert!(models.contains("alpha · model"), "{models}"); + assert!(models.contains("› one"), "{models}"); + assert!(!models.contains("three"), "{models}"); + assert!(models.contains("esc back"), "{models}"); + } + #[test] fn file_picker_renders_indexing_empty_and_selected_states() { let mut app = sample();