From 8dc65805c12dca73e2492f91a26b8eb907624b10 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:29:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(cli):=20dispatch=20to=20correct=20provider?= =?UTF-8?q?=20backend=20based=20on=20model=20prefix=20=E2=80=94=20closes?= =?UTF-8?q?=20ROADMAP=20#29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI entry point (build_runtime_with_plugin_state in main.rs) was hardcoded to always instantiate AnthropicRuntimeClient with an AnthropicClient, regardless of what detect_provider_kind(model) returned. This meant `--model openai/gpt-4` with OPENAI_API_KEY set and no ANTHROPIC_* vars still failed with "missing Anthropic credentials" because the CLI never dispatched to the OpenAI-compat backend that already exists in the api crate. Root cause: AnthropicRuntimeClient.client was typed as AnthropicClient (concrete) rather than ApiProviderClient (enum). The api crate already had a ProviderClient enum with Anthropic / Xai / OpenAi variants that dispatches correctly via detect_provider_kind, plus a unified MessageStream enum that wraps both anthropic::MessageStream and openai_compat::MessageStream with the same next_event() -> StreamEvent interface. The CLI just wasn't using it. Changes (1 file, +59 -7): - Import api::ProviderClient as ApiProviderClient - Change AnthropicRuntimeClient.client from AnthropicClient to ApiProviderClient - In AnthropicRuntimeClient::new(), dispatch based on detect_provider_kind(&resolved_model): * Anthropic: build AnthropicClient directly with resolve_cli_auth_source() + api::read_base_url() + PromptCache (preserves ANTHROPIC_BASE_URL override for mock test harness and the session-scoped prompt cache) * xAI / OpenAi: delegate to ApiProviderClient::from_model_with_anthropic_auth which routes to OpenAiCompatClient::from_env with the matching config (reads OPENAI_API_KEY/XAI_API_KEY/DASHSCOPE_API_KEY and their BASE_URL overrides internally) - Change push_prompt_cache_record to take &ApiProviderClient (ProviderClient::take_last_prompt_cache_record returns None for non-Anthropic variants, so the helper is a no-op on OpenAI-compat providers without extra branching) What this unlocks for users: claw --model openai/gpt-4.1-mini prompt 'hello' # OpenAI claw --model grok-3 prompt 'hello' # xAI claw --model qwen-plus prompt 'hello' # DashScope OPENAI_BASE_URL=https://openrouter.ai/api/v1 \ claw --model openai/anthropic/claude-sonnet-4 prompt 'hello' # OpenRouter All previously broken, now routed correctly by prefix. Verification: - cargo build --release -p rusty-claude-cli: clean - cargo test --release -p rusty-claude-cli: 182 tests, 0 failures (including compact_output tests that exercise the Anthropic mock) - cargo fmt --all: clean - cargo clippy --workspace: warnings-only (pre-existing) - cargo test --release --workspace: all crates green except one pre-existing race in runtime::config::tests (passes in isolation) Source: live users nicma (1491342350960562277) and Jengro (1491345009021030533) in #claw-code on 2026-04-08. --- rust/crates/rusty-claude-cli/src/main.rs | 66 +++++++++++++++++++++--- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ec464d6..06e3412 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -26,8 +26,9 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, - MessageResponse, OutputContentBlock, PromptCache, ProviderKind, StreamEvent as ApiStreamEvent, - ToolChoice, ToolDefinition, ToolResultContentBlock, + MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, + ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, + ToolResultContentBlock, }; use commands::{ @@ -6348,9 +6349,15 @@ impl runtime::PermissionPrompter for CliPermissionPrompter { } } +// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct +// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, +// OpenAI, or DashScope at construction time based on +// `detect_provider_kind(&model)`. The struct name is kept to avoid +// churning `BuiltRuntime` and every Deref/DerefMut site that references +// it. See ROADMAP #29 for the provider-dispatch routing fix. struct AnthropicRuntimeClient { runtime: tokio::runtime::Runtime, - client: AnthropicClient, + client: ApiProviderClient, session_id: String, model: String, enable_tools: bool, @@ -6370,11 +6377,51 @@ impl AnthropicRuntimeClient { tool_registry: GlobalToolRegistry, progress_reporter: Option, ) -> Result> { + // Dispatch to the correct provider at construction time. + // `ApiProviderClient` (exposed by the api crate as + // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI + // variants, where xAI and OpenAI both use the OpenAI-compat + // wire format under the hood. We consult + // `detect_provider_kind(&resolved_model)` so model-name prefix + // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over + // env-var presence. + // + // For Anthropic we build the client directly instead of going + // through `ApiProviderClient::from_model_with_anthropic_auth` + // so we can explicitly apply `api::read_base_url()` — that + // reads `ANTHROPIC_BASE_URL` and is required for the local + // mock-server test harness + // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point + // claw at its fake Anthropic endpoint. We also attach a + // session-scoped prompt cache on the Anthropic path; the + // prompt cache is Anthropic-only so non-Anthropic variants + // skip it. + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + // The api crate's `ProviderClient::from_model_with_anthropic_auth` + // with `None` for the anthropic auth routes via + // `detect_provider_kind` and builds an + // `OpenAiCompatClient::from_env` with the matching + // `OpenAiCompatConfig` (openai / xai / dashscope). + // That reads the correct API-key env var and BASE_URL + // override internally, so this one call covers OpenAI, + // OpenRouter, xAI, DashScope, Ollama, and any other + // OpenAI-compat endpoint users configure via + // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; Ok(Self { runtime: tokio::runtime::Runtime::new()?, - client: AnthropicClient::from_auth(resolve_cli_auth_source()?) - .with_base_url(api::read_base_url()) - .with_prompt_cache(PromptCache::new(session_id)), + client, session_id: session_id.to_string(), model, enable_tools, @@ -7404,7 +7451,12 @@ fn response_to_events( Ok(events) } -fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec) { +fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec) { + // `ApiProviderClient::take_last_prompt_cache_record` is a pass-through + // to the Anthropic variant and returns `None` for OpenAI-compat / + // xAI variants, which do not have a prompt cache. So this helper + // remains a no-op on non-Anthropic providers without any extra + // branching here. if let Some(record) = client.take_last_prompt_cache_record() { if let Some(event) = prompt_cache_record_to_runtime_event(record) { events.push(AssistantEvent::PromptCache(event));