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
+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)
}
}
}