agent_framework_anthropic/
vertex.rs1use std::sync::Arc;
53
54use agent_framework_core::client::{ChatClient, ChatStream};
55use agent_framework_core::error::{Error, Result};
56use agent_framework_core::types::{
57 ChatOptions, ChatResponse, ChatResponseUpdate, Content, Message, Role, UsageContent,
58};
59use futures::stream::{self, StreamExt};
60use serde_json::Value;
61
62use crate::convert;
63
64pub const ANTHROPIC_VERTEX_VERSION: &str = "vertex-2023-10-16";
68
69const DEFAULT_MAX_TOKENS: u32 = 1024;
73
74const DEFAULT_REGION: &str = "us-east5";
79
80pub trait VertexTokenProvider: Send + Sync {
90 fn access_token(&self) -> Result<String>;
94}
95
96#[derive(Debug, Clone)]
99pub struct StaticVertexToken(String);
100
101impl StaticVertexToken {
102 pub fn new(token: impl Into<String>) -> Self {
104 Self(token.into())
105 }
106}
107
108impl VertexTokenProvider for StaticVertexToken {
109 fn access_token(&self) -> Result<String> {
110 Ok(self.0.clone())
111 }
112}
113
114fn classify_vertex_error(status: u16, message: impl Into<String>) -> Error {
122 let message = message.into();
123 match status {
124 401 | 403 => Error::service_invalid_auth(message),
125 400 => Error::service_invalid_request(message),
126 _ => Error::service_status(status, message, None),
127 }
128}
129
130#[derive(Clone)]
137pub struct AnthropicVertexClient {
138 inner: Arc<Inner>,
139}
140
141#[derive(Clone)]
142struct Inner {
143 http: reqwest::Client,
144 project_id: String,
145 region: String,
146 model: String,
147 token_provider: Arc<dyn VertexTokenProvider>,
148 max_tokens: u32,
149}
150
151impl std::fmt::Debug for AnthropicVertexClient {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("AnthropicVertexClient")
154 .field("project_id", &self.inner.project_id)
155 .field("region", &self.inner.region)
156 .field("model", &self.inner.model)
157 .field("max_tokens", &self.inner.max_tokens)
158 .finish_non_exhaustive()
159 }
160}
161
162impl AnthropicVertexClient {
163 pub fn new(
166 project_id: impl Into<String>,
167 region: impl Into<String>,
168 model: impl Into<String>,
169 token_provider: Arc<dyn VertexTokenProvider>,
170 ) -> Self {
171 Self {
172 inner: Arc::new(Inner {
173 http: reqwest::Client::new(),
174 project_id: project_id.into(),
175 region: region.into(),
176 model: model.into(),
177 token_provider,
178 max_tokens: DEFAULT_MAX_TOKENS,
179 }),
180 }
181 }
182
183 pub fn from_env(
188 model: impl Into<String>,
189 token_provider: Arc<dyn VertexTokenProvider>,
190 ) -> Result<Self> {
191 let project_id = std::env::var("GOOGLE_CLOUD_PROJECT")
192 .or_else(|_| std::env::var("ANTHROPIC_VERTEX_PROJECT_ID"))
193 .map_err(|_| {
194 Error::Configuration(
195 "neither GOOGLE_CLOUD_PROJECT nor ANTHROPIC_VERTEX_PROJECT_ID is set".into(),
196 )
197 })?;
198 let region = std::env::var("CLOUD_ML_REGION")
199 .or_else(|_| std::env::var("ANTHROPIC_VERTEX_REGION"))
200 .unwrap_or_else(|_| DEFAULT_REGION.to_string());
201 Ok(Self::new(project_id, region, model, token_provider))
202 }
203
204 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
207 Arc::make_mut(&mut self.inner).max_tokens = max_tokens;
208 self
209 }
210
211 pub fn model(&self) -> &str {
213 &self.inner.model
214 }
215
216 pub fn project_id(&self) -> &str {
218 &self.inner.project_id
219 }
220
221 pub fn region(&self) -> &str {
223 &self.inner.region
224 }
225
226 fn effective_model(&self, options: &ChatOptions) -> String {
227 options
228 .model
229 .clone()
230 .unwrap_or_else(|| self.inner.model.clone())
231 }
232
233 fn url(&self, model: &str, streaming: bool) -> String {
235 let action = if streaming {
236 "streamRawPredict"
237 } else {
238 "rawPredict"
239 };
240 format!(
241 "https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/anthropic/models/{model}:{action}",
242 region = self.inner.region,
243 project = self.inner.project_id,
244 )
245 }
246
247 async fn send(&self, model: &str, body: &Value) -> Result<reqwest::Response> {
248 let token = self.inner.token_provider.access_token()?;
249 let resp = self
250 .inner
251 .http
252 .post(self.url(model, false))
253 .bearer_auth(token)
254 .json(body)
255 .send()
256 .await
257 .map_err(|e| Error::service(format!("request failed: {e}")))?;
258 if !resp.status().is_success() {
259 let status = resp.status();
260 let text = resp.text().await.unwrap_or_default();
261 return Err(classify_vertex_error(
262 status.as_u16(),
263 format!("Vertex AI rawPredict error {status}: {text}"),
264 ));
265 }
266 Ok(resp)
267 }
268}
269
270#[async_trait::async_trait]
271impl ChatClient for AnthropicVertexClient {
272 async fn get_response(
273 &self,
274 messages: Vec<Message>,
275 options: ChatOptions,
276 ) -> Result<ChatResponse> {
277 let model = self.effective_model(&options);
278 let max_tokens = options.max_tokens.unwrap_or(self.inner.max_tokens);
279 let body = convert::build_cloud_request(
280 &messages,
281 &options,
282 max_tokens,
283 false,
284 ANTHROPIC_VERTEX_VERSION,
285 );
286 let resp = self.send(&model, &body).await?;
287 let value: Value = resp
288 .json()
289 .await
290 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
291 Ok(convert::parse_response(&value))
292 }
293
294 async fn get_streaming_response(
311 &self,
312 messages: Vec<Message>,
313 options: ChatOptions,
314 ) -> Result<ChatStream> {
315 let response = self.get_response(messages, options).await?;
316
317 let mut contents: Vec<Content> = response
318 .messages
319 .iter()
320 .flat_map(|m| m.contents.iter().cloned())
321 .collect();
322 if let Some(usage) = response.usage_details.clone() {
323 contents.push(Content::Usage(UsageContent { details: usage }));
324 }
325
326 let update = ChatResponseUpdate {
327 contents,
328 role: Some(Role::assistant()),
329 response_id: response.response_id.clone(),
330 model: response.model.clone(),
331 finish_reason: response.finish_reason.clone(),
332 ..Default::default()
333 };
334 Ok(stream::once(async move { Ok(update) }).boxed())
335 }
336
337 fn model(&self) -> Option<&str> {
338 Some(&self.inner.model)
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn client() -> AnthropicVertexClient {
347 AnthropicVertexClient::new(
348 "my-project",
349 "us-east5",
350 "claude-sonnet-4-5@20250929",
351 Arc::new(StaticVertexToken::new("test-token")),
352 )
353 }
354
355 #[test]
356 fn static_vertex_token_returns_configured_token() {
357 let provider = StaticVertexToken::new("abc123");
358 assert_eq!(provider.access_token().unwrap(), "abc123");
359 }
360
361 #[test]
362 fn url_contains_publishers_anthropic_models_and_raw_predict() {
363 let c = client();
364 let url = c.url("claude-sonnet-4-5@20250929", false);
365 assert!(url.contains("/projects/my-project/"));
366 assert!(url.contains("/locations/us-east5/"));
367 assert!(url.contains("/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict"));
368 assert_eq!(
369 url,
370 "https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict"
371 );
372 }
373
374 #[test]
375 fn url_streaming_uses_stream_raw_predict() {
376 let c = client();
377 let url = c.url("claude-sonnet-4-5@20250929", true);
378 assert!(url.ends_with(":streamRawPredict"));
379 }
380
381 #[test]
382 fn accessors_expose_project_region_and_model() {
383 let c = client();
384 assert_eq!(c.project_id(), "my-project");
385 assert_eq!(c.region(), "us-east5");
386 assert_eq!(c.model(), "claude-sonnet-4-5@20250929");
387 assert_eq!(ChatClient::model(&c), Some("claude-sonnet-4-5@20250929"));
388 }
389
390 #[test]
391 fn request_body_has_vertex_anthropic_version_and_no_model_key() {
392 let body = convert::build_cloud_request(
393 &[Message::user("hi")],
394 &ChatOptions::new(),
395 1024,
396 false,
397 ANTHROPIC_VERTEX_VERSION,
398 );
399 assert_eq!(
400 body["anthropic_version"],
401 serde_json::json!("vertex-2023-10-16")
402 );
403 assert!(body.get("model").is_none());
404 }
405
406 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
409
410 fn clear_vertex_env() {
411 unsafe {
414 std::env::remove_var("GOOGLE_CLOUD_PROJECT");
415 std::env::remove_var("ANTHROPIC_VERTEX_PROJECT_ID");
416 std::env::remove_var("CLOUD_ML_REGION");
417 std::env::remove_var("ANTHROPIC_VERTEX_REGION");
418 }
419 }
420
421 #[test]
422 fn from_env_errors_without_project() {
423 let _guard = ENV_MUTEX.lock().unwrap();
424 clear_vertex_env();
425 let result =
426 AnthropicVertexClient::from_env("claude-x", Arc::new(StaticVertexToken::new("t")));
427 assert!(matches!(result, Err(Error::Configuration(_))));
428 clear_vertex_env();
429 }
430
431 #[test]
432 fn from_env_reads_project_and_region() {
433 let _guard = ENV_MUTEX.lock().unwrap();
434 clear_vertex_env();
435 unsafe {
437 std::env::set_var("GOOGLE_CLOUD_PROJECT", "proj-1");
438 std::env::set_var("CLOUD_ML_REGION", "europe-west1");
439 }
440 let client =
441 AnthropicVertexClient::from_env("claude-x", Arc::new(StaticVertexToken::new("t")))
442 .unwrap();
443 assert_eq!(client.project_id(), "proj-1");
444 assert_eq!(client.region(), "europe-west1");
445 clear_vertex_env();
446 }
447
448 #[test]
449 fn from_env_falls_back_to_anthropic_specific_vars_and_default_region() {
450 let _guard = ENV_MUTEX.lock().unwrap();
451 clear_vertex_env();
452 unsafe {
454 std::env::set_var("ANTHROPIC_VERTEX_PROJECT_ID", "proj-2");
455 }
456 let client =
457 AnthropicVertexClient::from_env("claude-x", Arc::new(StaticVertexToken::new("t")))
458 .unwrap();
459 assert_eq!(client.project_id(), "proj-2");
460 assert_eq!(client.region(), DEFAULT_REGION);
461 clear_vertex_env();
462 }
463
464 }