feat - updating icons and glyphs, added claude code subscription integration
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user