feat - updating icons and glyphs, added claude code subscription integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#[cfg(unix)]
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
#[cfg(unix)]
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
|
||||
Reference in New Issue
Block a user