feat - updating icons and glyphs, added claude code subscription integration

This commit is contained in:
2026-10-04 18:20:58 -05:00
parent 188489c890
commit b29882d56c
49 changed files with 2374 additions and 288 deletions
+9 -67
View File
@@ -26,73 +26,15 @@ pub struct AcpKnownAgent {
pub args: &'static [&'static str],
}
/// Curated ACP Registry catalog. Launch commands are intentionally local-only:
/// Galaxy never installs or downloads an agent on the user's behalf.
pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[
AcpKnownAgent {
id: "codex",
name: "Codex",
description: "OpenAI's coding assistant",
command: "codex",
args: &[],
},
AcpKnownAgent {
id: "opencode",
name: "OpenCode",
description: "Open source coding agent",
command: "opencode",
args: &["acp"],
},
AcpKnownAgent {
id: "claude-acp",
name: "Claude Agent",
description: "Anthropic's coding agent",
command: "claude-agent-acp",
args: &[],
},
AcpKnownAgent {
id: "gemini",
name: "Gemini CLI",
description: "Google's coding agent",
command: "gemini",
args: &["--acp"],
},
AcpKnownAgent {
id: "cline",
name: "Cline",
description: "Autonomous coding agent",
command: "cline",
args: &["--acp"],
},
AcpKnownAgent {
id: "cursor",
name: "Cursor",
description: "Cursor's coding agent",
command: "cursor-agent",
args: &["acp"],
},
AcpKnownAgent {
id: "github-copilot-cli",
name: "GitHub Copilot",
description: "GitHub's AI pair programmer",
command: "copilot",
args: &["--acp"],
},
AcpKnownAgent {
id: "goose",
name: "Goose",
description: "Block's open source AI agent",
command: "goose",
args: &["acp"],
},
AcpKnownAgent {
id: "auggie",
name: "Auggie CLI",
description: "Augment Code's coding agent",
command: "auggie",
args: &["--acp"],
},
];
/// ACP agents offered by Galaxy's connection setup. Existing explicit launch
/// configurations remain supported independently of this catalog.
pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[AcpKnownAgent {
id: "claude-acp",
name: "Claude Code",
description: "Claude Code with your Claude subscription or API account",
command: "claude-agent-acp",
args: &[],
}];
pub fn known_acp_agents() -> &'static [AcpKnownAgent] {
KNOWN_ACP_AGENTS
+8
View File
@@ -3,6 +3,14 @@ use std::time::Duration;
use super::*;
#[test]
fn setup_catalog_offers_only_claude_code() {
let agents = known_acp_agents();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].id, "claude-acp");
assert_eq!(agents[0].name, "Claude Code");
}
#[test]
fn codex_preset_uses_the_adapter_with_npx() {
let launch = AcpAgentPreset::Codex.launch_config();
@@ -225,7 +225,7 @@ fn read_bitmap_bits(
/// GDI does not populate the alpha channel for `BI_RGB`, so we force it to `0xFF` to produce a
/// fully opaque RGBA image.
fn convert_bgra_to_rgba(mut buffer: Vec<u8>) -> Vec<u8> {
for chunk in buffer.chunks_exact_mut(4) {
for chunk in buffer.as_chunks_mut::<4>().0 {
// Swap B and R channels so the 4-byte BGRA pixel becomes RGBA.
chunk.swap(0, 2);
chunk[3] = 0xFF;
+1
View File
@@ -12,6 +12,7 @@ aws-sdk-bedrockruntime.workspace = true
base64.workspace = true
futures.workspace = true
galaxy_agent_core.workspace = true
http.workspace = true
rig-core.workspace = true
rig-bedrock.workspace = true
rig-vertexai.workspace = true
@@ -0,0 +1,254 @@
//! Explicit Claude subscription tokens for the direct, Galaxy-owned runtime.
//! This is an unsupported authentication path; it never reads Claude Code's credentials.
use std::collections::HashMap;
use galaxy_agent_core::{AgentError, AgentErrorKind, AgentEvent, ToolEvent};
use http::{HeaderMap, HeaderValue};
use rig_core::OneOrMany;
use rig_core::client::{ApiKey, Client, ClientBuilder, Provider, ProviderBuilder};
use rig_core::completion::{AssistantContent, CompletionRequest, Message};
use rig_core::http_client::{self, HttpClientExt};
use rig_core::message::UserContent;
use rig_core::providers::anthropic;
use rig_core::providers::anthropic::client::AnthropicExt;
use serde_json::json;
pub fn is_claude_subscription_token(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
}
// Pi OAuth compatibility profile, reviewed 2026-10-03:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/api/anthropic-messages.ts
// Keep this independent of the version of the separately installed ACP CLI.
const PI_CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
pub(crate) const CLAUDE_CODE_IDENTITY: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
// Use Rig's native Messages translation and streaming, but replace its API-key
// header at client construction rather than sending both authentication methods.
#[derive(Clone, Default)]
struct SubscriptionBuilder;
struct SubscriptionProvider;
struct SubscriptionToken(String);
impl ApiKey for SubscriptionToken {
fn into_header(self) -> Option<http_client::Result<(http::HeaderName, HeaderValue)>> {
Some(
http_client::make_auth_header(self.0).map(|(name, mut value)| {
value.set_sensitive(true);
(name, value)
}),
)
}
}
impl Provider for SubscriptionProvider {
type Builder = SubscriptionBuilder;
const VERIFY_PATH: &'static str = "/v1/models";
}
impl ProviderBuilder for SubscriptionBuilder {
type Extension<H>
= AnthropicExt
where
H: HttpClientExt;
type ApiKey = SubscriptionToken;
const BASE_URL: &'static str = "https://api.anthropic.com";
fn build<H>(_: &ClientBuilder<Self, Self::ApiKey, H>) -> http_client::Result<Self::Extension<H>>
where
H: HttpClientExt,
{
Ok(AnthropicExt)
}
}
pub(crate) fn anthropic_client(credential: &str) -> Result<anthropic::Client, String> {
let credential = credential.trim();
if !is_claude_subscription_token(credential) {
return anthropic::Client::new(credential).map_err(|error| error.to_string());
}
let headers = subscription_headers();
Client::<SubscriptionProvider>::builder()
.api_key(SubscriptionToken(credential.to_string()))
.http_headers(headers)
.build()
// Never include a credential or raw header-validation error in diagnostics.
.map_err(|_| "Could not configure Claude subscription authentication.".to_string())
}
const CLAUDE_CODE_TOOLS: &[&str] = &[
"Read",
"Write",
"Edit",
"Bash",
"Grep",
"Glob",
"AskUserQuestion",
"EnterPlanMode",
"ExitPlanMode",
"KillShell",
"NotebookEdit",
"Skill",
"Task",
"TaskOutput",
"TodoWrite",
"WebFetch",
"WebSearch",
];
fn wire_tool_name(name: &str) -> String {
CLAUDE_CODE_TOOLS
.iter()
.find(|candidate| candidate.eq_ignore_ascii_case(name))
.copied()
.unwrap_or(name)
.to_string()
}
fn normalize_call_id(id: &mut String) {
*id = id
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
character
} else {
'_'
}
})
.take(64)
.collect();
}
pub(crate) fn prepare_subscription_request(
request: &mut CompletionRequest,
) -> Result<HashMap<String, String>, AgentError> {
let mut aliases = HashMap::new();
let mut tools = Vec::new();
let tool_count = request.tools.len();
for (index, tool) in request.tools.drain(..).enumerate() {
let name = wire_tool_name(&tool.name);
if aliases.insert(name.clone(), tool.name.clone()).is_some() {
return Err(AgentError::new(
AgentErrorKind::InvalidRequest,
"Claude subscription tool names collide after canonical casing.",
));
}
// Pi's default non-strict schema preserves properties and required.
let mut definition = json!({
"name": name,
"description": tool.description,
"input_schema": {
"type": "object",
"properties": tool.parameters.get("properties").cloned().unwrap_or(json!({})),
"required": tool.parameters.get("required").cloned().unwrap_or(json!([])),
},
"eager_input_streaming": true,
});
if index + 1 == tool_count {
definition["cache_control"] = json!({"type": "ephemeral"});
}
tools.push(definition);
}
let mut history = Vec::new();
let mut galaxy_system = Vec::new();
for mut message in request.chat_history.iter().cloned() {
match &mut message {
Message::System { content } => {
galaxy_system.push(content.clone());
continue;
}
Message::Assistant { content, .. } => {
for part in content.iter_mut() {
if let AssistantContent::ToolCall(call) = part {
call.function.name = wire_tool_name(&call.function.name);
normalize_call_id(&mut call.id);
if let Some(id) = call.call_id.as_mut() {
normalize_call_id(id);
}
}
}
}
Message::User { content } => {
for part in content.iter_mut() {
if let UserContent::ToolResult(result) = part {
normalize_call_id(&mut result.id);
if let Some(id) = result.call_id.as_mut() {
normalize_call_id(id);
}
}
}
}
}
history.push(message);
}
request.chat_history = OneOrMany::many(history)
.map_err(|error| AgentError::new(AgentErrorKind::InvalidRequest, error.to_string()))?;
let mut system = vec![json!({
"type": "text", "text": CLAUDE_CODE_IDENTITY,
"cache_control": {"type": "ephemeral"},
})];
if !galaxy_system.is_empty() {
system.push(json!({
"type": "text", "text": galaxy_system.join("\n\n"),
"cache_control": {"type": "ephemeral"},
}));
}
let mut parameters = request.additional_params.take().unwrap_or(json!({}));
let Some(parameters_map) = parameters.as_object_mut() else {
return Err(AgentError::new(
AgentErrorKind::InvalidRequest,
"Claude subscription request parameters must be an object.",
));
};
parameters_map.insert("system".to_string(), system.into());
if !tools.is_empty() {
parameters_map.insert("tools".to_string(), tools.into());
}
request.additional_params = Some(parameters);
Ok(aliases)
}
pub(crate) fn restore_tool_names(event: &mut AgentEvent, aliases: &HashMap<String, String>) {
let name = match event {
AgentEvent::Tool {
event: ToolEvent::Proposed { call },
} => Some(&mut call.name),
AgentEvent::ToolCallProgress { name, .. } => name.as_mut(),
_ => None,
};
if let Some(name) = name {
// Pi matches against the advertised tools case-insensitively on return.
if let Some(original) = aliases
.iter()
.find_map(|(wire, original)| wire.eq_ignore_ascii_case(name).then_some(original))
{
name.clone_from(original);
}
}
}
fn subscription_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
headers.insert(
"anthropic-beta",
HeaderValue::from_static("claude-code-20250219,oauth-2025-04-20"),
);
headers.insert(
"user-agent",
HeaderValue::from_static(PI_CLAUDE_CODE_USER_AGENT),
);
headers.insert(
"anthropic-dangerous-direct-browser-access",
HeaderValue::from_static("true"),
);
headers.insert("x-app", HeaderValue::from_static("cli"));
headers.insert("accept", HeaderValue::from_static("application/json"));
headers
}
#[cfg(test)]
#[path = "anthropic_auth_tests.rs"]
mod tests;
@@ -0,0 +1,311 @@
use galaxy_agent_core::{
ConversationMessage, MessageContent, MessageRole, ToolDefinition, TurnRequest,
};
use super::*;
use crate::request::build_completion_request;
#[test]
fn subscription_auth_is_bearer_only_and_redacted() {
let token = "sk-ant-oat01-test-subscription-token";
let client = anthropic_client(token).expect("subscription client");
assert_eq!(client.headers()["authorization"], format!("Bearer {token}"));
assert!(client.headers()["authorization"].is_sensitive());
assert!(!client.headers().contains_key("x-api-key"));
assert_eq!(
client.headers()["anthropic-beta"],
"claude-code-20250219,oauth-2025-04-20"
);
assert_eq!(client.headers()["user-agent"], "claude-cli/2.1.280");
assert_eq!(
client.headers()["anthropic-dangerous-direct-browser-access"],
"true"
);
assert_eq!(client.headers()["x-app"], "cli");
assert_eq!(client.headers()["accept"], "application/json");
assert!(!format!("{client:?}").contains(token));
let config = crate::AnthropicRuntimeConfig {
api_key: token.to_string(),
model: "claude-test".to_string(),
max_output_tokens: None,
};
assert!(!format!("{config:?}").contains(token));
}
#[test]
fn api_key_keeps_normal_authentication() {
let client = anthropic_client("sk-ant-api03-test-key").expect("API client");
assert_eq!(client.headers()["x-api-key"], "sk-ant-api03-test-key");
assert!(!client.headers().contains_key("authorization"));
}
#[test]
fn subscription_request_keeps_galaxy_prompt_and_tool_names() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Fix the bug".to_string()),
}],
);
turn.system_prompt = Some("Use Galaxy discovery and permissions.".to_string());
turn.tools = vec![ToolDefinition {
name: "search_codebase".to_string(),
description: "Search Galaxy's index".to_string(),
input_schema: serde_json::json!({"type":"object"}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
let history = request.chat_history.iter().collect::<Vec<_>>();
let parameters = request.additional_params.as_ref().unwrap();
assert_eq!(parameters["system"][0]["text"], CLAUDE_CODE_IDENTITY);
assert_eq!(
parameters["system"][1]["text"],
"Use Galaxy discovery and permissions."
);
assert_eq!(parameters["tools"][0]["name"], "search_codebase");
assert_eq!(parameters["tools"][0]["eager_input_streaming"], true);
assert!(request.tools.is_empty());
assert_eq!(request.max_tokens, Some(4096));
assert_eq!(history.len(), 1);
}
#[test]
fn pi_tool_casing_round_trips_without_changing_galaxy_execution_identity() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Inspect".to_string()),
}],
);
turn.tools = vec![ToolDefinition {
name: "grep".to_string(),
description: "Search".to_string(),
input_schema: serde_json::json!({"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
let aliases = prepare_subscription_request(&mut request).unwrap();
assert_eq!(
request.additional_params.as_ref().unwrap()["tools"][0]["name"],
"Grep"
);
let mut event = AgentEvent::Tool {
event: ToolEvent::Proposed {
call: galaxy_agent_core::ToolCall {
id: "toolu_123".to_string(),
name: "Grep".to_string(),
arguments: json!({"query":"foo"}),
},
},
};
restore_tool_names(&mut event, &aliases);
assert!(
matches!(event, AgentEvent::Tool { event: ToolEvent::Proposed { call } }
if call.name == "grep" && call.id == "toolu_123")
);
let mut progress = AgentEvent::ToolCallProgress {
call_id: "toolu_123".to_string(),
name: Some("GREP".to_string()),
arguments_bytes: 42,
};
restore_tool_names(&mut progress, &aliases);
assert!(
matches!(progress, AgentEvent::ToolCallProgress { name: Some(name), .. } if name == "grep")
);
}
#[test]
fn pi_call_id_normalization_preserves_tool_result_pairing() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "tool.call/1".to_string(),
name: "grep".to_string(),
input: json!({}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "tool.call/1".to_string(),
content: "found".to_string(),
is_error: false,
},
},
],
);
turn.system_prompt = Some("Galaxy".to_string());
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
for message in request.chat_history.iter() {
match message {
Message::Assistant { content, .. } => {
let AssistantContent::ToolCall(call) = content.first() else {
panic!("tool call");
};
assert_eq!(call.function.name, "Grep");
assert_eq!(call.call_id.as_deref(), Some("tool_call_1"));
}
Message::User { content } => {
let UserContent::ToolResult(result) = content.first() else {
panic!("tool result");
};
assert_eq!(result.call_id.as_deref(), Some("tool_call_1"));
}
_ => panic!("unexpected system message on wire history"),
}
}
}
#[tokio::test]
async fn serialized_subscription_request_matches_pi_auth_and_tool_profile() {
use futures::StreamExt;
use rig_core::client::CompletionClient;
use rig_core::completion::CompletionModel;
let http_client = RecordingStreamingClient::new(
r#"event: message_start
data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-test","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}
event: message_stop
data: {"type":"message_stop"}
"#,
);
let client = Client::<SubscriptionProvider>::builder()
.api_key(SubscriptionToken("sk-ant-oat01-test".to_string()))
.http_headers(subscription_headers())
.http_client(http_client.clone())
.build()
.unwrap();
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Inspect".to_string()),
}],
);
turn.system_prompt = Some("Galaxy discovery rules".to_string());
turn.tools = vec![ToolDefinition {
name: "read".to_string(),
description: "Read".to_string(),
input_schema: json!({"type":"object","properties":{},"required":[]}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
let mut stream = client
.completion_model("claude-test")
.with_prompt_caching()
.stream(request)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
let requests = http_client.recording.requests();
assert_eq!(requests.len(), 1);
let sent = &requests[0];
assert_eq!(sent.headers["user-agent"], "claude-cli/2.1.280");
assert_eq!(sent.headers["accept"], "application/json");
assert_eq!(
sent.headers["anthropic-dangerous-direct-browser-access"],
"true"
);
assert_eq!(
sent.headers["anthropic-beta"],
"claude-code-20250219,oauth-2025-04-20"
);
assert!(!sent.headers.contains_key("x-api-key"));
let body: serde_json::Value = serde_json::from_slice(&sent.body).unwrap();
assert_eq!(body["stream"], true);
assert_eq!(body["system"].as_array().unwrap().len(), 2);
assert_eq!(body["system"][0]["text"], CLAUDE_CODE_IDENTITY);
assert_eq!(body["system"][1]["text"], "Galaxy discovery rules");
assert_eq!(body["tools"].as_array().unwrap().len(), 1);
assert_eq!(body["tools"][0]["name"], "Read");
assert_eq!(body["tools"][0]["eager_input_streaming"], true);
assert_eq!(body["tools"][0]["cache_control"]["type"], "ephemeral");
}
// Rig's unary recording client does not implement streaming. Compose it with the
// SSE fixture client to capture the actual streaming payload and headers.
#[derive(Clone, Default)]
struct RecordingStreamingClient {
recording: rig_core::test_utils::RecordingHttpClient,
streaming: rig_core::test_utils::MockStreamingClient,
}
impl RecordingStreamingClient {
fn new(sse: &'static str) -> Self {
Self {
recording: rig_core::test_utils::RecordingHttpClient::default(),
streaming: rig_core::test_utils::MockStreamingClient {
sse_bytes: bytes::Bytes::from_static(sse.as_bytes()),
},
}
}
}
impl HttpClientExt for RecordingStreamingClient {
fn send<T, U>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send(request)
}
fn send_multipart<U>(
&self,
request: http_client::Request<http_client::MultipartForm>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send_multipart(request)
}
fn send_streaming<T>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<Output = http_client::Result<http_client::StreamingResponse>>
+ rig_core::wasm_compat::WasmCompatSend
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
{
let recorded = self
.recording
.send::<bytes::Bytes, bytes::Bytes>(request.map(Into::into));
let streaming = self.streaming.clone();
async move {
recorded.await?;
streaming
.send_streaming(http_client::Request::new(bytes::Bytes::new()))
.await
}
}
}
+2
View File
@@ -1,5 +1,6 @@
//! Rig-backed implementations of Galaxy's provider-neutral agent runtime.
mod anthropic_auth;
mod bedrock;
mod chatgpt;
mod native;
@@ -7,6 +8,7 @@ mod openai_compatible;
mod request;
mod stream;
pub use anthropic_auth::is_claude_subscription_token;
pub use bedrock::*;
pub use chatgpt::*;
pub use native::*;
+78 -11
View File
@@ -1,11 +1,16 @@
use std::sync::Arc;
use async_trait::async_trait;
use futures::StreamExt;
use futures::future::BoxFuture;
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest,
};
use rig_core::client::{CompletionClient, ModelListingClient};
use rig_core::providers::{anthropic, gemini};
use rig_core::providers::gemini;
use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names};
use crate::request::build_completion_request;
use crate::stream::{start_model_completion, start_model_turn};
@@ -17,7 +22,7 @@ pub struct RigModelInfo {
}
pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
let client = anthropic::Client::new(api_key).map_err(|error| error.to_string())?;
let client = anthropic_client(api_key)?;
let models = client
.list_models()
.await
@@ -84,23 +89,62 @@ pub fn validate_vertex_ai_credentials(project_id: &str, location: &str) -> Resul
.map_err(|error| error.to_string())
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AnthropicRuntimeConfig {
pub api_key: String,
pub model: String,
pub max_output_tokens: Option<u64>,
}
#[derive(Clone, Debug)]
impl std::fmt::Debug for AnthropicRuntimeConfig {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicRuntimeConfig")
.field("credential_configured", &!self.api_key.is_empty())
.field(
"subscription",
&crate::is_claude_subscription_token(&self.api_key),
)
.field("model", &self.model)
.field("max_output_tokens", &self.max_output_tokens)
.finish()
}
}
pub type AnthropicCredentialResolver =
Arc<dyn Fn() -> BoxFuture<'static, Result<String, String>> + Send + Sync>;
#[derive(Clone)]
pub struct AnthropicRuntime {
config: AnthropicRuntimeConfig,
descriptor: RuntimeDescriptor,
credential_resolver: Option<AnthropicCredentialResolver>,
}
impl std::fmt::Debug for AnthropicRuntime {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AnthropicRuntime")
.field("config", &self.config)
.field("descriptor", &self.descriptor)
.finish()
}
}
impl AnthropicRuntime {
pub fn new(config: AnthropicRuntimeConfig) -> Self {
let descriptor = native_descriptor("anthropic", &config.model);
Self { config, descriptor }
Self {
config,
descriptor,
credential_resolver: None,
}
}
/// Resolves renewable credentials before each turn without owning model retries.
pub fn with_credential_resolver(mut self, resolver: AnthropicCredentialResolver) -> Self {
self.credential_resolver = Some(resolver);
self
}
}
@@ -115,19 +159,42 @@ impl AgentRuntime for AnthropicRuntime {
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
if self.config.api_key.trim().is_empty() {
let credential = match &self.credential_resolver {
Some(resolve) => resolve()
.await
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error))?,
None => self.config.api_key.clone(),
};
if credential.trim().is_empty() {
return Err(AgentError::new(
AgentErrorKind::Configuration,
"An Anthropic API key is required",
"An Anthropic API key or Claude subscription token is required",
));
}
let client = anthropic::Client::new(&self.config.api_key)
let client = anthropic_client(&credential)
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model);
let mut model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let completion_request =
let mut completion_request =
build_completion_request(request, max_output_tokens, true, true, None)?;
start_model_turn(model, completion_request, control, max_output_tokens).await
let aliases = if crate::is_claude_subscription_token(&credential) {
model = model.with_prompt_caching();
Some(prepare_subscription_request(&mut completion_request)?)
} else {
None
};
let stream =
start_model_turn(model, completion_request, control, max_output_tokens).await?;
if let Some(aliases) = aliases {
Ok(Box::pin(stream.map(move |result| {
result.map(|mut event| {
restore_tool_names(&mut event, &aliases);
event
})
})))
} else {
Ok(stream)
}
}
}
@@ -1,5 +1,3 @@
use std::io::Write as _;
use super::*;
#[test]
@@ -74,7 +74,7 @@ pub fn capture_frame(
#[cfg(test)]
pub(crate) fn convert_bgra_to_rgba(data: &mut [u8]) {
for chunk in data.chunks_exact_mut(4) {
for chunk in data.as_chunks_mut::<4>().0 {
chunk.swap(0, 2);
}
}
@@ -934,7 +934,9 @@ fn caret_positions_for_line(
// edges and RTL text.
positions.sort_unstable_by_key(|position| position.utf16_index);
let mut carets: Vec<_> = positions
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
.map(|edges| {
// Guaranteed by chunks_exact that there are 2 elements.
let first = &edges[0];
@@ -265,7 +265,7 @@ fn capture_surface_texture(
staging_buffer.unmap();
if format == wgpu::TextureFormat::Bgra8Unorm || format == wgpu::TextureFormat::Bgra8UnormSrgb {
for chunk in rgba_data.chunks_exact_mut(4) {
for chunk in rgba_data.as_chunks_mut::<4>().0 {
chunk.swap(0, 2);
}
}
@@ -14,7 +14,7 @@ use string_offset::CharOffset;
use crate::color::ColorU;
pub use crate::elements::shimmering_text::config::ShimmerConfig;
use crate::elements::shimmering_text::glyph_index::GlyphIndex;
use crate::elements::{Axis, Point, DEFAULT_UI_LINE_HEIGHT_RATIO};
use crate::elements::{Axis, Icon, Point, DEFAULT_UI_LINE_HEIGHT_RATIO};
use crate::fonts::{FamilyId, Properties};
use crate::geometry::rect::RectF;
use crate::geometry::vector::{vec2f, Vector2F};
@@ -90,6 +90,7 @@ pub struct ShimmeringTextElement {
origin: Option<Point>,
handle: ShimmeringTextStateHandle,
leading_icon: Option<(Icon, f32, f32)>,
}
impl ShimmeringTextElement {
@@ -112,9 +113,16 @@ impl ShimmeringTextElement {
size: None,
origin: None,
handle: state_handle,
leading_icon: None,
}
}
/// Includes a leading icon in the same shimmer track as the text.
pub fn with_leading_icon(mut self, path: &'static str, size: f32, gap: f32) -> Self {
self.leading_icon = Some((Icon::new(path, self.base_color), size, gap));
self
}
/// Returns the center of the shimmer as a fractional glyph index along the "track".
fn shimmer_center(&self, number_of_glyphs: usize, state: &StateInternal) -> GlyphIndex<f32> {
if number_of_glyphs <= 1 {
@@ -165,11 +173,12 @@ impl ShimmeringTextElement {
return PaintStyleOverride::default();
}
let center = self.shimmer_center(n, &state);
let icon_offset = usize::from(self.leading_icon.is_some());
let center = self.shimmer_center(n + icon_offset, &state);
let mut overrides = RangeMap::new();
for (glyph_index, char_index) in glyph_indices_in_order.iter() {
let intensity = self.intensity_at(*glyph_index, center);
let intensity = self.intensity_at(GlyphIndex(glyph_index.0 + icon_offset), center);
let color = self
.base_color
.to_f32()
@@ -189,6 +198,20 @@ impl Element for ShimmeringTextElement {
ctx: &mut crate::LayoutContext,
app: &AppContext,
) -> Vector2F {
let icon_width = self
.leading_icon
.as_ref()
.map_or(0., |(_, size, gap)| size + gap);
if let Some((icon, size, _)) = &mut self.leading_icon {
icon.layout(
SizeConstraint {
min: vec2f(*size, *size),
max: vec2f(*size, *size),
},
ctx,
app,
);
}
let mut state = self.handle.get();
let text_len = self.text.chars().count();
@@ -197,7 +220,7 @@ impl Element for ShimmeringTextElement {
StyleAndFont::new(self.font_family, Properties::default(), TextStyle::new()),
)];
let max_width = constraint.max_along(Axis::Horizontal);
let max_width = (constraint.max_along(Axis::Horizontal) - icon_width).max(0.);
let layout_key = LayoutKey {
text: self.text.clone(),
@@ -247,15 +270,22 @@ impl Element for ShimmeringTextElement {
};
let size = vec2f(
line.width.max(constraint.min.x()).min(constraint.max.x()),
line.height(),
(line.width + icon_width)
.max(constraint.min.x())
.min(constraint.max.x()),
line.height()
.max(self.leading_icon.as_ref().map_or(0., |(_, size, _)| *size)),
);
self.size = Some(size);
size
}
fn after_layout(&mut self, _: &mut crate::AfterLayoutContext, _: &AppContext) {}
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &AppContext) {
if let Some((icon, _, _)) = &mut self.leading_icon {
icon.after_layout(ctx, app);
}
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
/// Duration, in ms, for which to repaint. Approximately 30fps.
@@ -273,7 +303,31 @@ impl Element for ShimmeringTextElement {
ctx.repaint_after(Duration::from_millis(REPAINT_DURATION));
let bounds = RectF::from_points(origin, origin + size);
let mut text_origin = origin;
let icon_color = {
let state = self.handle.get();
let center = self.shimmer_center(state.glyph_indices_in_order.len() + 1, &state);
self.base_color
.to_f32()
.lerp(
self.shimmer_color.to_f32(),
self.intensity_at(GlyphIndex(0), center),
)
.to_u8()
};
if let Some((icon, icon_size, gap)) = &mut self.leading_icon {
*icon = icon.with_color(icon_color);
icon.paint(origin + vec2f(0., (size.y() - *icon_size) / 2.), ctx, app);
text_origin += vec2f(*icon_size + *gap, 0.);
}
text_origin += vec2f(0., (size.y() - line.height()) / 2.);
let bounds = RectF::new(
text_origin,
vec2f(
(origin.x() + size.x() - text_origin.x()).max(0.),
line.height(),
),
);
let style_overrides = self.build_color_overrides();
line.paint(
@@ -475,9 +475,7 @@ impl TableStateHandle {
/// and you want all columns to resize to fit their new content.
pub fn invalidate_all_intrinsic_widths(&self) {
let mut state = self.inner.borrow_mut();
for width in &mut state.intrinsic_column_widths {
*width = None;
}
state.intrinsic_column_widths.fill(None);
}
/// Scroll to a specific row with an optional offset.
@@ -497,7 +497,9 @@ fn test_svg_text_rasterizes_with_loaded_system_fonts() {
assert!(image
.rgba_bytes()
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.any(|pixel| pixel[3] != 0));
}
@@ -534,7 +536,9 @@ fn test_svg_text_rasterizes_with_bundled_sans_serif_fallback() {
assert!(image
.rgba_bytes()
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.any(|pixel| pixel[3] != 0));
}
@@ -382,7 +382,7 @@ cfg_if::cfg_if! {
use crate::platform::CapturedFrameFormat;
let pixel_count = data.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3);
for chunk in data.chunks_exact(4) {
for chunk in data.as_chunks::<4>().0 {
match format {
CapturedFrameFormat::Rgba => {
rgb.push(chunk[0]);
@@ -334,7 +334,7 @@ pub async fn run_capture_loop(app: crate::App, state: CaptureLoopState) {
fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let pixel_count = rgba.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3);
for chunk in rgba.chunks_exact(4) {
for chunk in rgba.as_chunks::<4>().0 {
rgb.push(chunk[0]);
rgb.push(chunk[1]);
rgb.push(chunk[2]);
+1 -1
View File
@@ -529,7 +529,7 @@ impl CapturedFrame {
pub fn ensure_rgba(&mut self) {
if self.format == CapturedFrameFormat::Bgra {
for chunk in self.data.chunks_exact_mut(4) {
for chunk in self.data.as_chunks_mut::<4>().0 {
chunk.swap(0, 2);
}
self.format = CapturedFrameFormat::Rgba;
-3
View File
@@ -1,6 +1,3 @@
#[cfg(unix)]
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use chrono::Duration;
use chrono::Utc;