Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/arcan-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ pub enum CoreError {
Middleware(String),
#[error("state patch failed: {0}")]
State(String),
#[error("auth error: {0}")]
Auth(String),
}
7 changes: 7 additions & 0 deletions crates/arcan-provider/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ workspace = true

[dependencies]
arcan-core = { path = "../arcan-core", version = "0.2.0" }
base64 = "0.22"
dirs = "6"
open = "5"
rand = "0.9"
reqwest.workspace = true
rig-core.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2 = "0.10"
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
url = "2"
uuid.workspace = true
60 changes: 35 additions & 25 deletions crates/arcan-provider/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,29 @@ use arcan_core::protocol::{
use arcan_core::runtime::{Provider, ProviderRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;

use crate::credential::{AnthropicApiKeyCredential, Credential};

/// Configuration for the Anthropic provider.
#[derive(Debug, Clone)]
pub struct AnthropicConfig {
pub api_key: String,
pub credential: Arc<dyn Credential>,
pub model: String,
pub max_tokens: u32,
pub base_url: String,
}

impl std::fmt::Debug for AnthropicConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnthropicConfig")
.field("credential", &self.credential.kind())
.field("model", &self.model)
.field("max_tokens", &self.max_tokens)
.field("base_url", &self.base_url)
.finish()
}
}

impl AnthropicConfig {
pub fn from_env() -> Result<Self, CoreError> {
let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
Expand All @@ -34,7 +47,7 @@ impl AnthropicConfig {
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());

Ok(Self {
api_key,
credential: Arc::new(AnthropicApiKeyCredential::new(api_key)),
model,
max_tokens,
base_url,
Expand Down Expand Up @@ -73,7 +86,7 @@ impl AnthropicConfig {
.unwrap_or_else(|| "https://api.anthropic.com".to_string());

Ok(Self {
api_key,
credential: Arc::new(crate::credential::AnthropicApiKeyCredential::new(api_key)),
model,
max_tokens,
base_url,
Expand Down Expand Up @@ -214,10 +227,16 @@ impl Provider for AnthropicProvider {

let url = format!("{}/v1/messages", self.config.base_url);

let api_key = self
.config
.credential
.auth_header()
.map_err(|e| CoreError::Provider(format!("credential error: {e}")))?;

let response = self
.client
.post(&url)
.header("x-api-key", &self.config.api_key)
.header("x-api-key", &api_key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
Expand Down Expand Up @@ -310,15 +329,18 @@ enum ResponseBlock {
mod tests {
use super::*;

#[test]
fn builds_messages_with_system_prompt() {
let config = AnthropicConfig {
api_key: "test".to_string(),
fn test_config() -> AnthropicConfig {
AnthropicConfig {
credential: Arc::new(AnthropicApiKeyCredential::new("test".to_string())),
model: "test-model".to_string(),
max_tokens: 1024,
base_url: "http://localhost".to_string(),
};
let provider = AnthropicProvider::new(config);
}
}

#[test]
fn builds_messages_with_system_prompt() {
let provider = AnthropicProvider::new(test_config());

let messages = vec![
ChatMessage::system("You are helpful."),
Expand All @@ -333,13 +355,7 @@ mod tests {

#[test]
fn parses_text_response() {
let config = AnthropicConfig {
api_key: "test".to_string(),
model: "test-model".to_string(),
max_tokens: 1024,
base_url: "http://localhost".to_string(),
};
let provider = AnthropicProvider::new(config);
let provider = AnthropicProvider::new(test_config());

let response = ApiResponse {
content: vec![ResponseBlock::Text {
Expand All @@ -357,13 +373,7 @@ mod tests {

#[test]
fn parses_tool_use_response() {
let config = AnthropicConfig {
api_key: "test".to_string(),
model: "test-model".to_string(),
max_tokens: 1024,
base_url: "http://localhost".to_string(),
};
let provider = AnthropicProvider::new(config);
let provider = AnthropicProvider::new(test_config());

let response = ApiResponse {
content: vec![
Expand Down
159 changes: 159 additions & 0 deletions crates/arcan-provider/src/credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use arcan_core::error::CoreError;
use std::fmt;

/// A credential that can produce HTTP authorization headers.
///
/// Implementations handle API keys, OAuth tokens with refresh, etc.
pub trait Credential: Send + Sync + fmt::Debug {
/// Returns the authorization header value (e.g. `"Bearer <token>"`).
fn auth_header(&self) -> Result<String, CoreError>;

/// Returns the credential kind for display/logging.
fn kind(&self) -> &str;

/// Whether this credential needs periodic refresh (OAuth tokens do, API keys don't).
fn needs_refresh(&self) -> bool {
false
}

/// Refresh the credential if needed. No-op for static credentials.
fn refresh(&self) -> Result<(), CoreError> {
Ok(())
}
}

/// A static API key credential that produces `Bearer <key>` headers.
///
/// Used for OpenAI, Ollama, and other Bearer-token APIs.
#[derive(Clone)]
pub struct ApiKeyCredential {
api_key: String,
}

impl fmt::Debug for ApiKeyCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKeyCredential")
.field("api_key", &"[REDACTED]")
.finish()
}
}

impl ApiKeyCredential {
pub fn new(api_key: String) -> Self {
Self { api_key }
}

/// Returns the raw API key (for providers that need non-Bearer auth).
pub fn raw_key(&self) -> &str {
&self.api_key
}

/// Whether the underlying key is empty (e.g. Ollama local servers).
pub fn is_empty(&self) -> bool {
self.api_key.is_empty()
}
}

impl Credential for ApiKeyCredential {
fn auth_header(&self) -> Result<String, CoreError> {
if self.api_key.is_empty() {
return Err(CoreError::Auth("API key is empty".to_string()));
}
Ok(format!("Bearer {}", self.api_key))
}

fn kind(&self) -> &str {
"api_key"
}
}

/// A static API key credential that produces `x-api-key` style headers.
///
/// Used specifically for Anthropic which uses a custom header instead of Bearer.
#[derive(Clone)]
pub struct AnthropicApiKeyCredential {
api_key: String,
}

impl fmt::Debug for AnthropicApiKeyCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AnthropicApiKeyCredential")
.field("api_key", &"[REDACTED]")
.finish()
}
}

impl AnthropicApiKeyCredential {
pub fn new(api_key: String) -> Self {
Self { api_key }
}

/// Returns the raw API key for direct use in `x-api-key` header.
pub fn raw_key(&self) -> &str {
&self.api_key
}
}

impl Credential for AnthropicApiKeyCredential {
fn auth_header(&self) -> Result<String, CoreError> {
if self.api_key.is_empty() {
return Err(CoreError::Auth("Anthropic API key is empty".to_string()));
}
// Anthropic uses `x-api-key` header directly, but we return the raw value
// so the provider can set it on the appropriate header.
Ok(self.api_key.clone())
}

fn kind(&self) -> &str {
"anthropic_api_key"
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn api_key_credential_bearer_header() {
let cred = ApiKeyCredential::new("sk-test-123".to_string());
assert_eq!(cred.auth_header().unwrap(), "Bearer sk-test-123");
assert_eq!(cred.kind(), "api_key");
assert!(!cred.needs_refresh());
assert!(!cred.is_empty());
}

#[test]
fn api_key_credential_empty_returns_error() {
let cred = ApiKeyCredential::new(String::new());
assert!(cred.auth_header().is_err());
assert!(cred.is_empty());
}

#[test]
fn anthropic_credential_raw_key() {
let cred = AnthropicApiKeyCredential::new("sk-ant-test".to_string());
assert_eq!(cred.auth_header().unwrap(), "sk-ant-test");
assert_eq!(cred.kind(), "anthropic_api_key");
assert_eq!(cred.raw_key(), "sk-ant-test");
}

#[test]
fn anthropic_credential_empty_returns_error() {
let cred = AnthropicApiKeyCredential::new(String::new());
assert!(cred.auth_header().is_err());
}

#[test]
fn api_key_debug_redacts_key() {
let cred = ApiKeyCredential::new("secret-key".to_string());
let debug_output = format!("{cred:?}");
assert!(!debug_output.contains("secret-key"));
assert!(debug_output.contains("REDACTED"));
}

#[test]
fn refresh_is_noop_for_static_credentials() {
let cred = ApiKeyCredential::new("test".to_string());
assert!(cred.refresh().is_ok());
}
}
2 changes: 2 additions & 0 deletions crates/arcan-provider/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod anthropic;
pub mod credential;
pub mod oauth;
pub mod openai;
pub mod rig_bridge;
Loading
Loading