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, thinking_for_forced_tool};
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            // Forcing a specific tool is incompatible with extended thinking on
623            // Claude (the API 400s), so drop thinking at the wire boundary even
624            // when it was resurrected from the provider-configured default.
625            Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
626            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
627        };
628        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
629            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
630        }
631        let messages = Self::build_cached_vertex_claude_messages(&request);
632        let tools = build_vertex_claude_tools(&request);
633        let thinking = thinking_config
634            .as_ref()
635            .map(anthropic_data::ApiThinkingConfig::from_thinking_config);
636        let output_config = thinking_config
637            .as_ref()
638            .and_then(|t| t.effort)
639            .map(|effort| anthropic_data::ApiOutputConfig { effort });
640        let system = Self::build_vertex_claude_system_prompt(&request.system);
641        let tool_choice = request
642            .tool_choice
643            .as_ref()
644            .map(anthropic_data::ApiToolChoice::from_tool_choice);
645
646        let max_tokens = self.effective_max_tokens(&request);
647        let api_request = anthropic_data::ApiMessagesRequest {
648            model: None, // model is in the URL for Vertex
649            max_tokens,
650            system,
651            messages: &messages,
652            tools: tools.as_deref(),
653            tool_choice,
654            stream: false,
655            thinking,
656            output_config,
657            anthropic_version: Some(VERTEX_ANTHROPIC_VERSION),
658        };
659
660        log::debug!(
661            "Vertex AI (Claude) LLM request model={} max_tokens={}",
662            self.model,
663            max_tokens
664        );
665
666        if log::log_enabled!(log::Level::Debug) {
667            match serde_json::to_string_pretty(&api_request) {
668                Ok(json) => log::debug!("Vertex AI (Claude) request payload:\n{json}"),
669                Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
670            }
671        }
672
673        let url = format!("{}:rawPredict", self.base_url("anthropic"));
674
675        let response = self
676            .client
677            .post(&url)
678            .header("Content-Type", "application/json")
679            .timeout(std::time::Duration::from_secs(CHAT_READ_TIMEOUT_SECS))
680            .bearer_auth(&self.access_token)
681            .json(&api_request)
682            .send()
683            .await
684            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
685
686        let status = response.status();
687        // Read `Retry-After` off the 429 response before the body is consumed.
688        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
689            crate::http::retry_after_from_headers(response.headers())
690        } else {
691            None
692        };
693        let bytes = response
694            .bytes()
695            .await
696            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
697
698        log::debug!(
699            "Vertex AI (Claude) response status={} body_len={}",
700            status,
701            bytes.len()
702        );
703
704        if status == StatusCode::TOO_MANY_REQUESTS {
705            return Ok(ChatOutcome::RateLimited(retry_after));
706        }
707
708        if status.is_server_error() {
709            let body = String::from_utf8_lossy(&bytes);
710            log::error!("Vertex AI (Claude) server error status={status} body={body}");
711            return Ok(ChatOutcome::ServerError(body.into_owned()));
712        }
713
714        if status.is_client_error() {
715            let body = String::from_utf8_lossy(&bytes);
716            log::warn!("Vertex AI (Claude) client error status={status} body={body}");
717            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
718        }
719
720        let api_response: anthropic_data::ApiResponse = serde_json::from_slice(&bytes)
721            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
722
723        log::debug!(
724            "Vertex AI (Claude) response: id={} model={} stop_reason={:?} usage={{input_tokens={}, output_tokens={}}} content_blocks={}",
725            api_response.id,
726            api_response.model,
727            api_response.stop_reason,
728            api_response.usage.total_input_tokens(),
729            api_response.usage.output,
730            api_response.content.len()
731        );
732
733        Ok(ChatOutcome::Success(Self::map_claude_response(
734            api_response,
735        )))
736    }
737
738    #[allow(clippy::too_many_lines)]
739    fn chat_stream_claude(&self, request: ChatRequest) -> StreamBox<'_> {
740        Box::pin(async_stream::stream! {
741            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
742                // Forcing a specific tool is incompatible with extended thinking
743                // on Claude (the API 400s), so drop thinking at the wire boundary
744                // even when it was resurrected from the provider-configured
745                // default.
746                Ok(thinking) => thinking_for_forced_tool(thinking, request.tool_choice.as_ref()),
747                Err(error) => {
748                    yield Ok(StreamDelta::Error {
749                        message: error.to_string(),
750                        kind: StreamErrorKind::InvalidRequest,
751                    });
752                    return;
753                }
754            };
755            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
756                yield Ok(StreamDelta::Error {
757                    message: error.to_string(),
758                    kind: StreamErrorKind::InvalidRequest,
759                });
760                return;
761            }
762            let messages = Self::build_cached_vertex_claude_messages(&request);
763            let tools = build_vertex_claude_tools(&request);
764            let thinking = thinking_config
765                .as_ref()
766                .map(anthropic_data::ApiThinkingConfig::from_thinking_config);
767            let output_config = thinking_config
768                .as_ref()
769                .and_then(|t| t.effort)
770                .map(|effort| anthropic_data::ApiOutputConfig { effort });
771            let system = Self::build_vertex_claude_system_prompt(&request.system);
772            let tool_choice = request
773                .tool_choice
774                .as_ref()
775                .map(anthropic_data::ApiToolChoice::from_tool_choice);
776
777            let max_tokens = self.effective_max_tokens(&request);
778            let api_request = anthropic_data::ApiMessagesRequest {
779                model: None, // model is in the URL for Vertex
780                max_tokens,
781                system,
782                messages: &messages,
783                tools: tools.as_deref(),
784                tool_choice,
785                stream: true,
786                thinking,
787                output_config,
788                anthropic_version: Some(VERTEX_ANTHROPIC_VERSION),
789            };
790
791            log::debug!(
792                "Vertex AI (Claude) streaming request model={} max_tokens={}",
793                self.model,
794                max_tokens
795            );
796
797            if log::log_enabled!(log::Level::Debug) {
798                match serde_json::to_string_pretty(&api_request) {
799                    Ok(json) => log::debug!("Vertex AI (Claude) streaming request payload:\n{json}"),
800                    Err(e) => log::debug!("Failed to serialize request for logging: {e}"),
801                }
802            }
803
804            let url = format!("{}:streamRawPredict", self.base_url("anthropic"));
805
806            let response = match self
807                .client
808                .post(&url)
809                .header("Content-Type", "application/json")
810                .bearer_auth(&self.access_token)
811                .json(&api_request)
812                .send()
813                .await
814            {
815                Ok(r) => r,
816                Err(e) => {
817                    yield Err(anyhow::anyhow!("request failed: {e}"));
818                    return;
819                }
820            };
821
822            let status = response.status();
823
824            if status == StatusCode::TOO_MANY_REQUESTS {
825                yield Ok(StreamDelta::Error {
826                    message: "Rate limited".to_string(),
827                    kind: StreamErrorKind::RateLimited,
828                });
829                return;
830            }
831
832            if status.is_server_error() {
833                let body = response.text().await.unwrap_or_default();
834                log::error!("Vertex AI (Claude) server error status={status} body={body}");
835                yield Ok(StreamDelta::Error {
836                    message: body,
837                    kind: StreamErrorKind::ServerError,
838                });
839                return;
840            }
841
842            if status.is_client_error() {
843                let body = response.text().await.unwrap_or_default();
844                log::warn!("Vertex AI (Claude) client error status={status} body={body}");
845                yield Ok(StreamDelta::Error {
846                    message: body,
847                    kind: StreamErrorKind::InvalidRequest,
848                });
849                return;
850            }
851
852            // Process SSE stream using the Anthropic SSE parser
853            let mut stream = response.bytes_stream();
854            let mut buffer = String::new();
855            let mut input_tokens: u32 = 0;
856            let mut output_tokens: u32 = 0;
857            let mut cached_input_tokens: u32 = 0;
858            let mut cache_creation_input_tokens: u32 = 0;
859            let mut tool_ids: std::collections::HashMap<usize, String> =
860                std::collections::HashMap::new();
861            let mut received_message_stop = false;
862            let mut pending_stop_reason: Option<agent_sdk_foundation::llm::StopReason> = None;
863
864            while let Some(chunk_result) = stream.next().await {
865                let chunk = match chunk_result {
866                    Ok(c) => c,
867                    Err(e) => {
868                        // Include the cause so 401 detection / diagnostics survive.
869                        yield Err(anyhow::anyhow!("stream error: {e}"));
870                        return;
871                    }
872                };
873
874                buffer.push_str(&String::from_utf8_lossy(&chunk));
875
876                // Process complete SSE events (terminated by a blank line)
877                while let Some(event_block) = anthropic_data::take_next_sse_event(&mut buffer) {
878                    if anthropic_data::is_message_stop_event(&event_block) {
879                        received_message_stop = true;
880                    }
881
882                    if let Some(delta) = anthropic_data::parse_sse_event(
883                        &event_block,
884                        &mut input_tokens,
885                        &mut output_tokens,
886                        &mut cached_input_tokens,
887                        &mut cache_creation_input_tokens,
888                        &mut tool_ids,
889                        &mut pending_stop_reason,
890                    ) {
891                        yield Ok(delta);
892                    }
893                    if anthropic_data::is_message_stop_event(&event_block) {
894                        yield Ok(StreamDelta::Done {
895                            stop_reason: pending_stop_reason.take(),
896                        });
897                    }
898                }
899            }
900
901            // Process remaining buffer
902            let remaining = buffer.trim();
903            if !remaining.is_empty() {
904                if anthropic_data::is_message_stop_event(remaining) {
905                    received_message_stop = true;
906                }
907
908                if let Some(delta) = anthropic_data::parse_sse_event(
909                    remaining,
910                    &mut input_tokens,
911                    &mut output_tokens,
912                    &mut cached_input_tokens,
913                    &mut cache_creation_input_tokens,
914                    &mut tool_ids,
915                    &mut pending_stop_reason,
916                ) {
917                    yield Ok(delta);
918                }
919                if anthropic_data::is_message_stop_event(remaining) {
920                    yield Ok(StreamDelta::Done {
921                        stop_reason: pending_stop_reason.take(),
922                    });
923                }
924            }
925
926            if !received_message_stop {
927                log::warn!(
928                    "Vertex AI (Claude) SSE stream ended without message_stop"
929                );
930                yield Ok(StreamDelta::Error {
931                    message: "Stream ended unexpectedly without completion".to_string(),
932                    kind: StreamErrorKind::ServerError,
933                });
934            }
935        })
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    #[test]
944    fn test_new_creates_provider() {
945        let provider = VertexProvider::new(
946            "token".to_string(),
947            "my-project".to_string(),
948            "us-central1".to_string(),
949            "custom-model".to_string(),
950        );
951
952        assert_eq!(provider.model(), "custom-model");
953        assert_eq!(provider.provider(), "vertex");
954    }
955
956    #[test]
957    fn test_flash_factory() {
958        let provider = VertexProvider::flash(
959            "token".to_string(),
960            "my-project".to_string(),
961            "us-central1".to_string(),
962        );
963
964        assert_eq!(provider.model(), MODEL_GEMINI_3_FLASH);
965        assert_eq!(provider.provider(), "vertex");
966    }
967
968    #[test]
969    fn test_pro_factory() {
970        let provider = VertexProvider::pro(
971            "token".to_string(),
972            "my-project".to_string(),
973            "us-central1".to_string(),
974        );
975
976        assert_eq!(provider.model(), MODEL_GEMINI_31_PRO);
977        assert_eq!(provider.provider(), "vertex");
978    }
979
980    #[test]
981    fn test_provider_is_cloneable() {
982        let provider = VertexProvider::new(
983            "token".to_string(),
984            "my-project".to_string(),
985            "us-central1".to_string(),
986            "test-model".to_string(),
987        );
988        let cloned = provider.clone();
989
990        assert_eq!(provider.model(), cloned.model());
991        assert_eq!(provider.provider(), cloned.provider());
992    }
993
994    #[test]
995    fn test_is_claude_model() {
996        let claude_provider = VertexProvider::new(
997            "token".to_string(),
998            "project".to_string(),
999            "us-central1".to_string(),
1000            "claude-sonnet-4-20250514".to_string(),
1001        );
1002        assert!(claude_provider.is_claude_model());
1003
1004        let gemini_provider = VertexProvider::new(
1005            "token".to_string(),
1006            "project".to_string(),
1007            "us-central1".to_string(),
1008            "gemini-3-flash-preview".to_string(),
1009        );
1010        assert!(!gemini_provider.is_claude_model());
1011    }
1012
1013    #[test]
1014    fn test_base_url_gemini() {
1015        let provider = VertexProvider::new(
1016            "token".to_string(),
1017            "my-project".to_string(),
1018            "us-central1".to_string(),
1019            "gemini-3-flash-preview".to_string(),
1020        );
1021
1022        let url = provider.base_url("google");
1023        assert_eq!(
1024            url,
1025            "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_base_url_claude() {
1031        let provider = VertexProvider::new(
1032            "token".to_string(),
1033            "my-project".to_string(),
1034            "us-central1".to_string(),
1035            "claude-sonnet-4-20250514".to_string(),
1036        );
1037
1038        let url = provider.base_url("anthropic");
1039        assert_eq!(
1040            url,
1041            "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-20250514"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_base_url_with_different_region() {
1047        let provider = VertexProvider::new(
1048            "token".to_string(),
1049            "other-project".to_string(),
1050            "europe-west4".to_string(),
1051            "gemini-3.1-pro-preview".to_string(),
1052        );
1053
1054        let url = provider.base_url("google");
1055        assert!(url.starts_with("https://europe-west4-aiplatform.googleapis.com/"));
1056        assert!(url.contains("/projects/other-project/"));
1057        assert!(url.contains("/locations/europe-west4/"));
1058        assert!(url.ends_with("/models/gemini-3.1-pro-preview"));
1059    }
1060
1061    #[test]
1062    fn test_base_url_global_region_has_no_prefix() {
1063        let provider = VertexProvider::new(
1064            "token".to_string(),
1065            "my-project".to_string(),
1066            "global".to_string(),
1067            "gemini-3.1-pro-preview".to_string(),
1068        );
1069
1070        let url = provider.base_url("google");
1071        assert_eq!(
1072            url,
1073            "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-3.1-pro-preview"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_vertex_claude_46_rejects_budgeted_thinking() {
1079        let provider = VertexProvider::new(
1080            "token".to_string(),
1081            "project".to_string(),
1082            "global".to_string(),
1083            MODEL_SONNET_46.to_string(),
1084        );
1085
1086        let error = provider
1087            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1088            .unwrap_err();
1089        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1090    }
1091
1092    #[test]
1093    fn test_vertex_claude_opus_47_rejects_budgeted_thinking() {
1094        let provider = VertexProvider::new(
1095            "token".to_string(),
1096            "project".to_string(),
1097            "global".to_string(),
1098            MODEL_OPUS_47.to_string(),
1099        );
1100
1101        let error = provider
1102            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1103            .unwrap_err();
1104        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1105    }
1106
1107    #[test]
1108    fn test_vertex_claude_opus_48_rejects_budgeted_thinking() {
1109        let provider = VertexProvider::new(
1110            "token".to_string(),
1111            "project".to_string(),
1112            "global".to_string(),
1113            MODEL_OPUS_48.to_string(),
1114        );
1115
1116        let error = provider
1117            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1118            .unwrap_err();
1119        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1120    }
1121
1122    #[test]
1123    fn test_vertex_claude_fable_5_rejects_budgeted_thinking() {
1124        let provider = VertexProvider::new(
1125            "token".to_string(),
1126            "project".to_string(),
1127            "global".to_string(),
1128            MODEL_FABLE_5.to_string(),
1129        );
1130
1131        let error = provider
1132            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
1133            .unwrap_err();
1134        assert!(error.to_string().contains("ThinkingConfig::adaptive()"));
1135    }
1136
1137    #[test]
1138    fn test_model_constants() {
1139        assert_eq!(MODEL_GEMINI_3_FLASH, "gemini-3-flash-preview");
1140        assert_eq!(MODEL_GEMINI_31_PRO, "gemini-3.1-pro-preview");
1141        assert_eq!(MODEL_GEMINI_3_PRO, "gemini-3.0-pro");
1142    }
1143
1144    fn request_with_max_tokens(max_tokens: u32, explicit: bool) -> ChatRequest {
1145        ChatRequest {
1146            system: String::new(),
1147            messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
1148            tools: None,
1149            max_tokens,
1150            max_tokens_explicit: explicit,
1151            session_id: None,
1152            cached_content: None,
1153            thinking: None,
1154            tool_choice: None,
1155            response_format: None,
1156            cache: None,
1157        }
1158    }
1159
1160    #[test]
1161    fn test_effective_max_tokens_honors_explicit_budget() {
1162        let provider = VertexProvider::new(
1163            "token".to_string(),
1164            "project".to_string(),
1165            "global".to_string(),
1166            MODEL_SONNET_46.to_string(),
1167        );
1168        let request = request_with_max_tokens(1234, true);
1169        assert_eq!(provider.effective_max_tokens(&request), 1234);
1170    }
1171
1172    #[test]
1173    fn test_effective_max_tokens_uses_clamped_default_when_implicit() {
1174        // An implicit budget for a Claude-on-Vertex model must fall back to the
1175        // clamped default (<= 32k), not the unclamped capability ceiling and
1176        // not ChatRequest::DEFAULT_MAX_TOKENS.
1177        let provider = VertexProvider::new(
1178            "token".to_string(),
1179            "project".to_string(),
1180            "global".to_string(),
1181            MODEL_SONNET_46.to_string(),
1182        );
1183        let request = request_with_max_tokens(4096, false);
1184        let effective = provider.effective_max_tokens(&request);
1185        assert_eq!(effective, provider.default_max_tokens());
1186        assert!(effective <= DEFAULT_SAFE_MAX_OUTPUT_TOKENS);
1187    }
1188}