Skip to main content

agent_sdk_providers/impls/
vertex.rs

1//! Google Vertex AI provider implementation.
2//!
3//! This module provides an implementation of `LlmProvider` for the Google Vertex AI
4//! platform. It supports both Gemini models (using the Gemini API format) and
5//! Claude models (using the Anthropic Messages API format via `rawPredict`).
6//!
7//! Publisher detection is automatic based on the model name:
8//! - `claude-*` models route to `publishers/anthropic` using `rawPredict`
9//! - All other models route to `publishers/google` using `generateContent`
10
11use crate::attachments::validate_request_attachments;
12use crate::impls::anthropic::{
13    MODEL_FABLE_5, MODEL_OPUS_46, MODEL_OPUS_47, MODEL_OPUS_48, MODEL_SONNET_5, MODEL_SONNET_46,
14    data as anthropic_data,
15};
16use crate::impls::gemini::data::{
17    ApiContent, ApiFunctionCallingConfig, ApiGenerateContentRequest, ApiGenerateContentResponse,
18    ApiGenerationConfig, ApiPart, ApiUsageMetadata, build_api_contents, build_content_blocks,
19    convert_tools_to_config, gemini_response_schema, map_finish_reason, map_thinking_config,
20    stream_gemini_response,
21};
22use crate::provider::LlmProvider;
23use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
24use agent_sdk_foundation::llm::{
25    ChatOutcome, ChatRequest, ChatResponse, ResponseFormat, ThinkingConfig, ThinkingMode, Usage,
26};
27use anyhow::Result;
28use async_trait::async_trait;
29use futures::StreamExt;
30use reqwest::StatusCode;
31
32pub const MODEL_GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
33pub const MODEL_GEMINI_31_PRO: &str = "gemini-3.1-pro-preview";
34
35// Legacy Gemini 3.0 Pro model kept for explicit opt-in.
36pub const MODEL_GEMINI_3_PRO: &str = "gemini-3.0-pro";
37
38/// The Anthropic API version used for Claude models on Vertex AI.
39const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16";
40const DEFAULT_SAFE_MAX_OUTPUT_TOKENS: u32 = 32_000;
41
42/// Connect timeout for the HTTP client (matches Anthropic).
43const CONNECT_TIMEOUT_SECS: u64 = 30;
44/// TCP keepalive interval to keep long streaming connections from dropping.
45const TCP_KEEPALIVE_SECS: u64 = 30;
46/// Per-request read timeout for the **non-streaming** chat paths. Bounds a
47/// black-holed endpoint so a single turn cannot hang the agent loop forever.
48/// Streaming requests intentionally have no overall timeout.
49const CHAT_READ_TIMEOUT_SECS: u64 = 300;
50
51const fn vertex_cache_control() -> anthropic_data::ApiCacheControl {
52    anthropic_data::ApiCacheControl::ephemeral_with_ttl(None)
53}
54
55/// Build the Claude-on-Vertex tool list, caching the tool prefix with the
56/// Vertex ephemeral breakpoint.
57fn build_vertex_claude_tools(request: &ChatRequest) -> Option<Vec<anthropic_data::ApiTool>> {
58    anthropic_data::build_api_tools_with_cache(request, Some(vertex_cache_control()))
59}
60
61/// Google Vertex AI LLM provider.
62///
63/// Uses the same Gemini request/response format as `GeminiProvider` but
64/// authenticates via `OAuth2` Bearer tokens and routes through the Vertex AI
65/// regional endpoint.
66///
67/// Claude models are also supported — the provider detects the publisher from
68/// the model name and uses the appropriate API format automatically.
69#[derive(Clone)]
70pub struct VertexProvider {
71    client: reqwest::Client,
72    access_token: String,
73    project_id: String,
74    region: String,
75    model: String,
76    thinking: Option<ThinkingConfig>,
77}
78
79impl VertexProvider {
80    /// Create a new Vertex AI provider with full control over all parameters.
81    #[must_use]
82    pub fn new(access_token: String, project_id: String, region: String, model: String) -> Self {
83        let client = reqwest::Client::builder()
84            .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
85            .tcp_keepalive(std::time::Duration::from_secs(TCP_KEEPALIVE_SECS))
86            .build()
87            .unwrap_or_else(|error| {
88                log::warn!(
89                    "failed to build Vertex HTTP client with timeouts ({error}); using default client"
90                );
91                reqwest::Client::new()
92            });
93
94        Self {
95            client,
96            access_token,
97            project_id,
98            region,
99            model,
100            thinking: None,
101        }
102    }
103
104    /// Create a provider using Gemini 3 Flash Preview on Vertex AI.
105    #[must_use]
106    pub fn flash(access_token: String, project_id: String, region: String) -> Self {
107        Self::new(
108            access_token,
109            project_id,
110            region,
111            MODEL_GEMINI_3_FLASH.to_owned(),
112        )
113    }
114
115    /// Create a provider using Gemini 3.1 Pro Preview on Vertex AI.
116    #[must_use]
117    pub fn pro(access_token: String, project_id: String, region: String) -> Self {
118        Self::new(
119            access_token,
120            project_id,
121            region,
122            MODEL_GEMINI_31_PRO.to_owned(),
123        )
124    }
125
126    /// Detect whether the model is a Claude model (Anthropic publisher).
127    fn is_claude_model(&self) -> bool {
128        self.model.starts_with("claude-")
129    }
130
131    /// Build the base URL for the given publisher and model.
132    ///
133    /// For the `global` location the domain is `aiplatform.googleapis.com`
134    /// (no region prefix). Regional locations use `{region}-aiplatform.googleapis.com`.
135    fn base_url(&self, publisher: &str) -> String {
136        let domain = if self.region == "global" {
137            "aiplatform.googleapis.com".to_owned()
138        } else {
139            format!("{}-aiplatform.googleapis.com", self.region)
140        };
141        format!(
142            "https://{domain}/v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}",
143            domain = domain,
144            region = self.region,
145            project = self.project_id,
146            publisher = publisher,
147            model = self.model,
148        )
149    }
150
151    /// Set the provider-owned thinking configuration for this model.
152    #[must_use]
153    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
154        self.thinking = Some(thinking);
155        self
156    }
157
158    fn requires_anthropic_adaptive_thinking(&self) -> bool {
159        matches!(
160            self.model.as_str(),
161            MODEL_SONNET_46
162                | MODEL_SONNET_5
163                | MODEL_OPUS_46
164                | MODEL_OPUS_47
165                | MODEL_OPUS_48
166                | MODEL_FABLE_5
167        )
168    }
169
170    fn build_cached_vertex_claude_messages(
171        request: &ChatRequest,
172    ) -> Vec<anthropic_data::ApiMessage> {
173        let mut messages = anthropic_data::build_api_messages(request);
174        anthropic_data::apply_cache_control_to_last_user_message(
175            &mut messages,
176            vertex_cache_control(),
177        );
178        messages
179    }
180
181    fn build_vertex_claude_system_prompt(
182        system: &str,
183    ) -> Option<anthropic_data::ApiSystemPrompt<'_>> {
184        anthropic_data::build_api_system_prompt(system, Some(vertex_cache_control()))
185    }
186
187    /// Effective output-token budget for a request.
188    ///
189    /// Mirrors the Anthropic provider: an implicit budget falls back to the
190    /// provider/model default ([`default_max_tokens`](LlmProvider::default_max_tokens),
191    /// which clamps to Vertex's safe ceiling) instead of silently capping at
192    /// `ChatRequest::DEFAULT_MAX_TOKENS`.
193    fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
194        if request.max_tokens_explicit {
195            request.max_tokens
196        } else {
197            self.default_max_tokens()
198        }
199    }
200
201    fn map_claude_response(api_response: anthropic_data::ApiResponse) -> ChatResponse {
202        let content = anthropic_data::map_content_blocks(api_response.content);
203        let stop_reason = api_response
204            .stop_reason
205            .as_ref()
206            .map(anthropic_data::map_stop_reason);
207
208        ChatResponse {
209            id: api_response.id,
210            content,
211            model: api_response.model,
212            stop_reason,
213            usage: Usage {
214                input_tokens: api_response.usage.total_input_tokens(),
215                output_tokens: api_response.usage.output,
216                cached_input_tokens: api_response.usage.cached_input_tokens(),
217                cache_creation_input_tokens: api_response.usage.cache_creation_input_tokens(),
218            },
219        }
220    }
221}
222
223#[async_trait]
224impl LlmProvider for VertexProvider {
225    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
226        if self.is_claude_model() {
227            return self.chat_claude(request).await;
228        }
229        self.chat_gemini(request).await
230    }
231
232    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
233        if self.is_claude_model() {
234            return self.chat_stream_claude(request);
235        }
236        self.chat_stream_gemini(request)
237    }
238
239    fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
240        let Some(thinking) = thinking else {
241            return Ok(());
242        };
243
244        if self
245            .capabilities()
246            .is_some_and(|caps| !caps.supports_thinking)
247        {
248            return Err(anyhow::anyhow!(
249                "thinking is not supported for provider={} model={}",
250                self.provider(),
251                self.model()
252            ));
253        }
254
255        if matches!(thinking.mode, ThinkingMode::Adaptive)
256            && !self
257                .capabilities()
258                .is_some_and(|caps| caps.supports_adaptive_thinking)
259        {
260            return Err(anyhow::anyhow!(
261                "adaptive thinking is not supported for provider={} model={}",
262                self.provider(),
263                self.model()
264            ));
265        }
266
267        if self.is_claude_model()
268            && self.requires_anthropic_adaptive_thinking()
269            && matches!(thinking.mode, ThinkingMode::Enabled { .. })
270        {
271            return Err(anyhow::anyhow!(
272                "budget_tokens thinking is deprecated for provider={} model={}; use ThinkingConfig::adaptive() instead",
273                self.provider(),
274                self.model()
275            ));
276        }
277
278        Ok(())
279    }
280
281    fn model(&self) -> &str {
282        &self.model
283    }
284
285    fn provider(&self) -> &'static str {
286        "vertex"
287    }
288
289    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
290        self.thinking.as_ref()
291    }
292
293    fn default_max_tokens(&self) -> u32 {
294        let provider = if self.is_claude_model() {
295            "anthropic"
296        } else {
297            "gemini"
298        };
299        let model_max = self
300            .capabilities()
301            .and_then(|caps| caps.max_output_tokens)
302            .or_else(|| {
303                crate::model_capabilities::default_max_output_tokens(provider, self.model())
304            })
305            .unwrap_or(4096);
306        model_max.clamp(4096, DEFAULT_SAFE_MAX_OUTPUT_TOKENS)
307    }
308}
309
310// ============================================================================
311// Gemini path (publishers/google)
312// ============================================================================
313
314impl VertexProvider {
315    #[allow(clippy::too_many_lines)]
316    async fn chat_gemini(&self, request: ChatRequest) -> Result<ChatOutcome> {
317        let thinking = match self.resolve_thinking_config(request.thinking.as_ref()) {
318            Ok(thinking) => thinking,
319            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
320        };
321        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
322            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
323        }
324        let contents = build_api_contents(&request.messages);
325        let tools = request
326            .tools
327            .as_ref()
328            .map(|t| convert_tools_to_config(t.clone()));
329        let tool_config = request
330            .tool_choice
331            .as_ref()
332            .map(ApiFunctionCallingConfig::from_tool_choice);
333        let system_instruction = if request.system.is_empty() {
334            None
335        } else {
336            Some(ApiContent {
337                role: None,
338                parts: vec![ApiPart::Text {
339                    text: request.system.clone(),
340                    thought_signature: None,
341                }],
342            })
343        };
344
345        let thinking_config = thinking.as_ref().map(map_thinking_config);
346        let (response_mime_type, response_schema) =
347            request.response_format.as_ref().map_or((None, None), |rf| {
348                (
349                    Some("application/json"),
350                    Some(gemini_response_schema(&rf.schema)),
351                )
352            });
353
354        let max_tokens = self.effective_max_tokens(&request);
355        let api_request = ApiGenerateContentRequest {
356            contents: &contents,
357            system_instruction: system_instruction.as_ref(),
358            tools: tools.as_ref().map(std::slice::from_ref),
359            tool_config,
360            generation_config: Some(ApiGenerationConfig {
361                max_output_tokens: Some(max_tokens),
362                thinking_config,
363                response_mime_type,
364                response_schema,
365            }),
366            cached_content: request.cached_content.as_deref(),
367        };
368
369        log::debug!(
370            "Vertex AI LLM request model={} max_tokens={}",
371            self.model,
372            max_tokens
373        );
374
375        let url = format!("{}:generateContent", self.base_url("google"));
376
377        let response = self
378            .client
379            .post(&url)
380            .header("Content-Type", "application/json")
381            .timeout(std::time::Duration::from_secs(CHAT_READ_TIMEOUT_SECS))
382            .bearer_auth(&self.access_token)
383            .json(&api_request)
384            .send()
385            .await
386            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
387
388        let status = response.status();
389        // Read `Retry-After` off the 429 response before the body is consumed.
390        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
391            crate::http::retry_after_from_headers(response.headers())
392        } else {
393            None
394        };
395        let bytes = response
396            .bytes()
397            .await
398            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
399
400        log::debug!(
401            "Vertex AI LLM response status={} body_len={}",
402            status,
403            bytes.len()
404        );
405
406        if status == StatusCode::TOO_MANY_REQUESTS {
407            return Ok(ChatOutcome::RateLimited(retry_after));
408        }
409
410        if status.is_server_error() {
411            let body = String::from_utf8_lossy(&bytes);
412            log::error!("Vertex AI server error status={status} body={body}");
413            return Ok(ChatOutcome::ServerError(body.into_owned()));
414        }
415
416        if status.is_client_error() {
417            let body = String::from_utf8_lossy(&bytes);
418            log::warn!("Vertex AI client error status={status} body={body}");
419            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
420        }
421
422        let api_response: ApiGenerateContentResponse = serde_json::from_slice(&bytes)
423            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
424
425        let candidate = api_response
426            .candidates
427            .into_iter()
428            .next()
429            .ok_or_else(|| anyhow::anyhow!("no candidates in response"))?;
430
431        let content = build_content_blocks(&candidate.content);
432
433        if content.is_empty() && !candidate.content.parts.is_empty() {
434            log::warn!(
435                "Vertex AI parts not converted to content blocks raw_parts={:?}",
436                candidate.content.parts
437            );
438        }
439
440        let has_tool_calls = content
441            .iter()
442            .any(|b| matches!(b, agent_sdk_foundation::llm::ContentBlock::ToolUse { .. }));
443
444        let stop_reason = candidate
445            .finish_reason
446            .as_ref()
447            .map(|r| map_finish_reason(r, has_tool_calls));
448
449        let usage = api_response
450            .usage_metadata
451            .unwrap_or(ApiUsageMetadata {
452                prompt: 0,
453                candidates: 0,
454                cached_content: 0,
455            })
456            .into_usage();
457
458        Ok(ChatOutcome::Success(ChatResponse {
459            id: String::new(),
460            content,
461            model: self.model.clone(),
462            stop_reason,
463            usage,
464        }))
465    }
466
467    fn chat_stream_gemini(&self, request: ChatRequest) -> StreamBox<'_> {
468        Box::pin(async_stream::stream! {
469            let thinking = match self.resolve_thinking_config(request.thinking.as_ref()) {
470                Ok(thinking) => thinking,
471                Err(error) => {
472                    yield Ok(StreamDelta::Error {
473                        message: error.to_string(),
474                        kind: StreamErrorKind::InvalidRequest,
475                    });
476                    return;
477                }
478            };
479            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
480                yield Ok(StreamDelta::Error {
481                    message: error.to_string(),
482                    kind: StreamErrorKind::InvalidRequest,
483                });
484                return;
485            }
486
487            let contents = build_api_contents(&request.messages);
488            let tools = request
489                .tools
490                .as_ref()
491                .map(|t| convert_tools_to_config(t.clone()));
492            let tool_config = request
493                .tool_choice
494                .as_ref()
495                .map(ApiFunctionCallingConfig::from_tool_choice);
496            let system_instruction = build_gemini_system_instruction(&request.system);
497            let thinking_config = thinking.as_ref().map(map_thinking_config);
498            let (response_mime_type, response_schema) =
499                gemini_response_format(request.response_format.as_ref());
500
501            let max_tokens = self.effective_max_tokens(&request);
502            let api_request = ApiGenerateContentRequest {
503                contents: &contents,
504                system_instruction: system_instruction.as_ref(),
505                tools: tools.as_ref().map(std::slice::from_ref),
506                tool_config,
507                generation_config: Some(ApiGenerationConfig {
508                    max_output_tokens: Some(max_tokens),
509                    thinking_config,
510                    response_mime_type,
511                    response_schema,
512                }),
513                cached_content: request.cached_content.as_deref(),
514            };
515
516            log::debug!(
517                "Vertex AI streaming LLM request model={} max_tokens={}",
518                self.model,
519                max_tokens
520            );
521
522            let url = format!("{}:streamGenerateContent?alt=sse", self.base_url("google"));
523
524            let response = match self.send_gemini_stream_request(&url, &api_request).await {
525                Ok(response) => response,
526                Err(item) => {
527                    yield item;
528                    return;
529                }
530            };
531
532            let mut inner = stream_gemini_response(response);
533            while let Some(item) = futures::StreamExt::next(&mut inner).await {
534                yield item;
535            }
536        })
537    }
538
539    /// Issue the Vertex Gemini streaming request, returning the raw response on
540    /// success or a ready-to-`yield` stream item describing the failure.
541    ///
542    /// The `Err` payload is the exact `StreamBox` item to `yield`: an
543    /// `anyhow::Error` for a transport failure, or a classified
544    /// [`StreamDelta::Error`] for a non-success HTTP status. This keeps the
545    /// generator's failure handling to a single `yield`.
546    async fn send_gemini_stream_request(
547        &self,
548        url: &str,
549        api_request: &ApiGenerateContentRequest<'_>,
550    ) -> Result<reqwest::Response, anyhow::Result<StreamDelta>> {
551        let response = match self
552            .client
553            .post(url)
554            .header("Content-Type", "application/json")
555            .bearer_auth(&self.access_token)
556            .json(api_request)
557            .send()
558            .await
559        {
560            Ok(response) => response,
561            // Include the cause so 401 detection / diagnostics survive.
562            Err(e) => return Err(Err(anyhow::anyhow!("request failed: {e}"))),
563        };
564
565        let status = response.status();
566        if !status.is_success() {
567            let body = response.text().await.unwrap_or_default();
568            let kind = if status == StatusCode::TOO_MANY_REQUESTS {
569                StreamErrorKind::RateLimited
570            } else if status.is_server_error() {
571                StreamErrorKind::ServerError
572            } else {
573                StreamErrorKind::InvalidRequest
574            };
575            log::warn!("Vertex AI error status={status} body={body}");
576            return Err(Ok(StreamDelta::Error {
577                message: body,
578                kind,
579            }));
580        }
581
582        Ok(response)
583    }
584}
585
586/// Build the Gemini `system_instruction` content from the request system prompt,
587/// or `None` when the prompt is empty.
588fn build_gemini_system_instruction(system: &str) -> Option<ApiContent> {
589    if system.is_empty() {
590        None
591    } else {
592        Some(ApiContent {
593            role: None,
594            parts: vec![ApiPart::Text {
595                text: system.to_owned(),
596                thought_signature: None,
597            }],
598        })
599    }
600}
601
602/// Map an optional response format into Gemini's `(responseMimeType,
603/// responseSchema)` pair, sanitizing the schema to the subset Gemini accepts.
604fn gemini_response_format(
605    response_format: Option<&ResponseFormat>,
606) -> (Option<&'static str>, Option<serde_json::Value>) {
607    response_format.map_or((None, None), |rf| {
608        (
609            Some("application/json"),
610            Some(gemini_response_schema(&rf.schema)),
611        )
612    })
613}
614
615// ============================================================================
616// Claude path (publishers/anthropic)
617// ============================================================================
618
619impl VertexProvider {
620    async fn chat_claude(&self, request: ChatRequest) -> Result<ChatOutcome> {
621        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
622            Ok(thinking) => thinking,
623            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
624        };
625        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
626            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
627        }
628        let messages = Self::build_cached_vertex_claude_messages(&request);
629        let tools = build_vertex_claude_tools(&request);
630        let thinking = thinking_config
631            .as_ref()
632            .map(anthropic_data::ApiThinkingConfig::from_thinking_config);
633        let output_config = thinking_config
634            .as_ref()
635            .and_then(|t| t.effort)
636            .map(|effort| anthropic_data::ApiOutputConfig { effort });
637        let system = Self::build_vertex_claude_system_prompt(&request.system);
638        let tool_choice = request
639            .tool_choice
640            .as_ref()
641            .map(anthropic_data::ApiToolChoice::from_tool_choice);
642
643        let max_tokens = self.effective_max_tokens(&request);
644        let api_request = anthropic_data::ApiMessagesRequest {
645            model: None, // model is in the URL for Vertex
646            max_tokens,
647            system,
648            messages: &messages,
649            tools: tools.as_deref(),
650            tool_choice,
651            stream: false,
652            thinking,
653            output_config,
654            anthropic_version: Some(VERTEX_ANTHROPIC_VERSION),
655        };
656
657        log::debug!(
658            "Vertex AI (Claude) LLM request model={} max_tokens={}",
659            self.model,
660            max_tokens
661        );
662
663        if log::log_enabled!(log::Level::Debug) {
664            match serde_json::to_string_pretty(&api_request) {
665                Ok(json) => log::debug!("Vertex AI (Claude) request payload:\n{json}"),
666                Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
667            }
668        }
669
670        let url = format!("{}:rawPredict", self.base_url("anthropic"));
671
672        let response = self
673            .client
674            .post(&url)
675            .header("Content-Type", "application/json")
676            .timeout(std::time::Duration::from_secs(CHAT_READ_TIMEOUT_SECS))
677            .bearer_auth(&self.access_token)
678            .json(&api_request)
679            .send()
680            .await
681            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
682
683        let status = response.status();
684        // Read `Retry-After` off the 429 response before the body is consumed.
685        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
686            crate::http::retry_after_from_headers(response.headers())
687        } else {
688            None
689        };
690        let bytes = response
691            .bytes()
692            .await
693            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
694
695        log::debug!(
696            "Vertex AI (Claude) response status={} body_len={}",
697            status,
698            bytes.len()
699        );
700
701        if status == StatusCode::TOO_MANY_REQUESTS {
702            return Ok(ChatOutcome::RateLimited(retry_after));
703        }
704
705        if status.is_server_error() {
706            let body = String::from_utf8_lossy(&bytes);
707            log::error!("Vertex AI (Claude) server error status={status} body={body}");
708            return Ok(ChatOutcome::ServerError(body.into_owned()));
709        }
710
711        if status.is_client_error() {
712            let body = String::from_utf8_lossy(&bytes);
713            log::warn!("Vertex AI (Claude) client error status={status} body={body}");
714            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
715        }
716
717        let api_response: anthropic_data::ApiResponse = serde_json::from_slice(&bytes)
718            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
719
720        log::debug!(
721            "Vertex AI (Claude) response: id={} model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
722            api_response.id,
723            api_response.model,
724            api_response.stop_reason,
725            api_response.usage.total_input_tokens(),
726            api_response.usage.output,
727            api_response.content.len()
728        );
729
730        Ok(ChatOutcome::Success(Self::map_claude_response(
731            api_response,
732        )))
733    }
734
735    #[allow(clippy::too_many_lines)]
736    fn chat_stream_claude(&self, request: ChatRequest) -> StreamBox<'_> {
737        Box::pin(async_stream::stream! {
738            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
739                Ok(thinking) => thinking,
740                Err(error) => {
741                    yield Ok(StreamDelta::Error {
742                        message: error.to_string(),
743                        kind: StreamErrorKind::InvalidRequest,
744                    });
745                    return;
746                }
747            };
748            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
749                yield Ok(StreamDelta::Error {
750                    message: error.to_string(),
751                    kind: StreamErrorKind::InvalidRequest,
752                });
753                return;
754            }
755            let messages = Self::build_cached_vertex_claude_messages(&request);
756            let tools = build_vertex_claude_tools(&request);
757            let thinking = thinking_config
758                .as_ref()
759                .map(anthropic_data::ApiThinkingConfig::from_thinking_config);
760            let output_config = thinking_config
761                .as_ref()
762                .and_then(|t| t.effort)
763                .map(|effort| anthropic_data::ApiOutputConfig { effort });
764            let system = Self::build_vertex_claude_system_prompt(&request.system);
765            let tool_choice = request
766                .tool_choice
767                .as_ref()
768                .map(anthropic_data::ApiToolChoice::from_tool_choice);
769
770            let max_tokens = self.effective_max_tokens(&request);
771            let api_request = anthropic_data::ApiMessagesRequest {
772                model: None, // model is in the URL for Vertex
773                max_tokens,
774                system,
775                messages: &messages,
776                tools: tools.as_deref(),
777                tool_choice,
778                stream: true,
779                thinking,
780                output_config,
781                anthropic_version: Some(VERTEX_ANTHROPIC_VERSION),
782            };
783
784            log::debug!(
785                "Vertex AI (Claude) streaming request model={} max_tokens={}",
786                self.model,
787                max_tokens
788            );
789
790            if log::log_enabled!(log::Level::Debug) {
791                match serde_json::to_string_pretty(&api_request) {
792                    Ok(json) => log::debug!("Vertex AI (Claude) streaming request payload:\n{json}"),
793                    Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
794                }
795            }
796
797            let url = format!("{}:streamRawPredict", self.base_url("anthropic"));
798
799            let response = match self
800                .client
801                .post(&url)
802                .header("Content-Type", "application/json")
803                .bearer_auth(&self.access_token)
804                .json(&api_request)
805                .send()
806                .await
807            {
808                Ok(r) => r,
809                Err(e) => {
810                    yield Err(anyhow::anyhow!("request failed: {e}"));
811                    return;
812                }
813            };
814
815            let status = response.status();
816
817            if status == StatusCode::TOO_MANY_REQUESTS {
818                yield Ok(StreamDelta::Error {
819                    message: "Rate limited".to_string(),
820                    kind: StreamErrorKind::RateLimited,
821                });
822                return;
823            }
824
825            if status.is_server_error() {
826                let body = response.text().await.unwrap_or_default();
827                log::error!("Vertex AI (Claude) server error status={status} body={body}");
828                yield Ok(StreamDelta::Error {
829                    message: body,
830                    kind: StreamErrorKind::ServerError,
831                });
832                return;
833            }
834
835            if status.is_client_error() {
836                let body = response.text().await.unwrap_or_default();
837                log::warn!("Vertex AI (Claude) client error status={status} body={body}");
838                yield Ok(StreamDelta::Error {
839                    message: body,
840                    kind: StreamErrorKind::InvalidRequest,
841                });
842                return;
843            }
844
845            // Process SSE stream using the Anthropic SSE parser
846            let mut stream = response.bytes_stream();
847            let mut buffer = String::new();
848            let mut input_tokens: u32 = 0;
849            let mut output_tokens: u32 = 0;
850            let mut cached_input_tokens: u32 = 0;
851            let mut cache_creation_input_tokens: u32 = 0;
852            let mut tool_ids: std::collections::HashMap<usize, String> =
853                std::collections::HashMap::new();
854            let mut received_message_stop = false;
855            let mut pending_stop_reason: Option<agent_sdk_foundation::llm::StopReason> = None;
856
857            while let Some(chunk_result) = stream.next().await {
858                let chunk = match chunk_result {
859                    Ok(c) => c,
860                    Err(e) => {
861                        // Include the cause so 401 detection / diagnostics survive.
862                        yield Err(anyhow::anyhow!("stream error: {e}"));
863                        return;
864                    }
865                };
866
867                buffer.push_str(&String::from_utf8_lossy(&chunk));
868
869                // Process complete SSE events (terminated by a blank line)
870                while let Some(event_block) = anthropic_data::take_next_sse_event(&mut buffer) {
871                    if anthropic_data::is_message_stop_event(&event_block) {
872                        received_message_stop = true;
873                    }
874
875                    if let Some(delta) = anthropic_data::parse_sse_event(
876                        &event_block,
877                        &mut input_tokens,
878                        &mut output_tokens,
879                        &mut cached_input_tokens,
880                        &mut cache_creation_input_tokens,
881                        &mut tool_ids,
882                        &mut pending_stop_reason,
883                    ) {
884                        yield Ok(delta);
885                    }
886                    if anthropic_data::is_message_stop_event(&event_block) {
887                        yield Ok(StreamDelta::Done {
888                            stop_reason: pending_stop_reason.take(),
889                        });
890                    }
891                }
892            }
893
894            // Process remaining buffer
895            let remaining = buffer.trim();
896            if !remaining.is_empty() {
897                if anthropic_data::is_message_stop_event(remaining) {
898                    received_message_stop = true;
899                }
900
901                if let Some(delta) = anthropic_data::parse_sse_event(
902                    remaining,
903                    &mut input_tokens,
904                    &mut output_tokens,
905                    &mut cached_input_tokens,
906                    &mut cache_creation_input_tokens,
907                    &mut tool_ids,
908                    &mut pending_stop_reason,
909                ) {
910                    yield Ok(delta);
911                }
912                if anthropic_data::is_message_stop_event(remaining) {
913                    yield Ok(StreamDelta::Done {
914                        stop_reason: pending_stop_reason.take(),
915                    });
916                }
917            }
918
919            if !received_message_stop {
920                log::warn!(
921                    "Vertex AI (Claude) SSE stream ended without message_stop"
922                );
923                yield Ok(StreamDelta::Error {
924                    message: "Stream ended unexpectedly without completion".to_string(),
925                    kind: StreamErrorKind::ServerError,
926                });
927            }
928        })
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    #[test]
937    fn test_new_creates_provider() {
938        let provider = VertexProvider::new(
939            "token".to_string(),
940            "my-project".to_string(),
941            "us-central1".to_string(),
942            "custom-model".to_string(),
943        );
944
945        assert_eq!(provider.model(), "custom-model");
946        assert_eq!(provider.provider(), "vertex");
947    }
948
949    #[test]
950    fn test_flash_factory() {
951        let provider = VertexProvider::flash(
952            "token".to_string(),
953            "my-project".to_string(),
954            "us-central1".to_string(),
955        );
956
957        assert_eq!(provider.model(), MODEL_GEMINI_3_FLASH);
958        assert_eq!(provider.provider(), "vertex");
959    }
960
961    #[test]
962    fn test_pro_factory() {
963        let provider = VertexProvider::pro(
964            "token".to_string(),
965            "my-project".to_string(),
966            "us-central1".to_string(),
967        );
968
969        assert_eq!(provider.model(), MODEL_GEMINI_31_PRO);
970        assert_eq!(provider.provider(), "vertex");
971    }
972
973    #[test]
974    fn test_provider_is_cloneable() {
975        let provider = VertexProvider::new(
976            "token".to_string(),
977            "my-project".to_string(),
978            "us-central1".to_string(),
979            "test-model".to_string(),
980        );
981        let cloned = provider.clone();
982
983        assert_eq!(provider.model(), cloned.model());
984        assert_eq!(provider.provider(), cloned.provider());
985    }
986
987    #[test]
988    fn test_is_claude_model() {
989        let claude_provider = VertexProvider::new(
990            "token".to_string(),
991            "project".to_string(),
992            "us-central1".to_string(),
993            "claude-sonnet-4-20250514".to_string(),
994        );
995        assert!(claude_provider.is_claude_model());
996
997        let gemini_provider = VertexProvider::new(
998            "token".to_string(),
999            "project".to_string(),
1000            "us-central1".to_string(),
1001            "gemini-3-flash-preview".to_string(),
1002        );
1003        assert!(!gemini_provider.is_claude_model());
1004    }
1005
1006    #[test]
1007    fn test_base_url_gemini() {
1008        let provider = VertexProvider::new(
1009            "token".to_string(),
1010            "my-project".to_string(),
1011            "us-central1".to_string(),
1012            "gemini-3-flash-preview".to_string(),
1013        );
1014
1015        let url = provider.base_url("google");
1016        assert_eq!(
1017            url,
1018            "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_base_url_claude() {
1024        let provider = VertexProvider::new(
1025            "token".to_string(),
1026            "my-project".to_string(),
1027            "us-central1".to_string(),
1028            "claude-sonnet-4-20250514".to_string(),
1029        );
1030
1031        let url = provider.base_url("anthropic");
1032        assert_eq!(
1033            url,
1034            "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-20250514"
1035        );
1036    }
1037
1038    #[test]
1039    fn test_base_url_with_different_region() {
1040        let provider = VertexProvider::new(
1041            "token".to_string(),
1042            "other-project".to_string(),
1043            "europe-west4".to_string(),
1044            "gemini-3.1-pro-preview".to_string(),
1045        );
1046
1047        let url = provider.base_url("google");
1048        assert!(url.starts_with("https://europe-west4-aiplatform.googleapis.com/"));
1049        assert!(url.contains("/projects/other-project/"));
1050        assert!(url.contains("/locations/europe-west4/"));
1051        assert!(url.ends_with("/models/gemini-3.1-pro-preview"));
1052    }
1053
1054    #[test]
1055    fn test_base_url_global_region_has_no_prefix() {
1056        let provider = VertexProvider::new(
1057            "token".to_string(),
1058            "my-project".to_string(),
1059            "global".to_string(),
1060            "gemini-3.1-pro-preview".to_string(),
1061        );
1062
1063        let url = provider.base_url("google");
1064        assert_eq!(
1065            url,
1066            "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-3.1-pro-preview"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_vertex_claude_46_rejects_budgeted_thinking() {
1072        let provider = VertexProvider::new(
1073            "token".to_string(),
1074            "project".to_string(),
1075            "global".to_string(),
1076            MODEL_SONNET_46.to_string(),
1077        );
1078
1079        let error = provider
1080            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1081            .unwrap_err();
1082        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1083    }
1084
1085    #[test]
1086    fn test_vertex_claude_opus_47_rejects_budgeted_thinking() {
1087        let provider = VertexProvider::new(
1088            "token".to_string(),
1089            "project".to_string(),
1090            "global".to_string(),
1091            MODEL_OPUS_47.to_string(),
1092        );
1093
1094        let error = provider
1095            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1096            .unwrap_err();
1097        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1098    }
1099
1100    #[test]
1101    fn test_vertex_claude_opus_48_rejects_budgeted_thinking() {
1102        let provider = VertexProvider::new(
1103            "token".to_string(),
1104            "project".to_string(),
1105            "global".to_string(),
1106            MODEL_OPUS_48.to_string(),
1107        );
1108
1109        let error = provider
1110            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1111            .unwrap_err();
1112        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1113    }
1114
1115    #[test]
1116    fn test_vertex_claude_fable_5_rejects_budgeted_thinking() {
1117        let provider = VertexProvider::new(
1118            "token".to_string(),
1119            "project".to_string(),
1120            "global".to_string(),
1121            MODEL_FABLE_5.to_string(),
1122        );
1123
1124        let error = provider
1125            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1126            .unwrap_err();
1127        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1128    }
1129
1130    #[test]
1131    fn test_model_constants() {
1132        assert_eq!(MODEL_GEMINI_3_FLASH, "gemini-3-flash-preview");
1133        assert_eq!(MODEL_GEMINI_31_PRO, "gemini-3.1-pro-preview");
1134        assert_eq!(MODEL_GEMINI_3_PRO, "gemini-3.0-pro");
1135    }
1136
1137    fn request_with_max_tokens(max_tokens: u32, explicit: bool) -> ChatRequest {
1138        ChatRequest {
1139            system: String::new(),
1140            messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
1141            tools: None,
1142            max_tokens,
1143            max_tokens_explicit: explicit,
1144            session_id: None,
1145            cached_content: None,
1146            thinking: None,
1147            tool_choice: None,
1148            response_format: None,
1149            cache: None,
1150        }
1151    }
1152
1153    #[test]
1154    fn test_effective_max_tokens_honors_explicit_budget() {
1155        let provider = VertexProvider::new(
1156            "token".to_string(),
1157            "project".to_string(),
1158            "global".to_string(),
1159            MODEL_SONNET_46.to_string(),
1160        );
1161        let request = request_with_max_tokens(1234, true);
1162        assert_eq!(provider.effective_max_tokens(&request), 1234);
1163    }
1164
1165    #[test]
1166    fn test_effective_max_tokens_uses_clamped_default_when_implicit() {
1167        // An implicit budget for a Claude-on-Vertex model must fall back to the
1168        // clamped default (<= 32k), not the unclamped capability ceiling and
1169        // not ChatRequest::DEFAULT_MAX_TOKENS.
1170        let provider = VertexProvider::new(
1171            "token".to_string(),
1172            "project".to_string(),
1173            "global".to_string(),
1174            MODEL_SONNET_46.to_string(),
1175        );
1176        let request = request_with_max_tokens(4096, false);
1177        let effective = provider.effective_max_tokens(&request);
1178        assert_eq!(effective, provider.default_max_tokens());
1179        assert!(effective <= DEFAULT_SAFE_MAX_OUTPUT_TOKENS);
1180    }
1181}