1use 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
35pub const MODEL_GEMINI_3_PRO: &str = "gemini-3.0-pro";
37
38const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16";
40const DEFAULT_SAFE_MAX_OUTPUT_TOKENS: u32 = 32_000;
41
42const CONNECT_TIMEOUT_SECS: u64 = 30;
44const TCP_KEEPALIVE_SECS: u64 = 30;
46const 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
55fn 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#[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 #[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 #[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 #[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 fn is_claude_model(&self) -> bool {
128 self.model.starts_with("claude-")
129 }
130
131 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 #[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 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
310impl 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 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 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 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
586fn 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
602fn 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
615impl 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, 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 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, 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 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 yield Err(anyhow::anyhow!("stream error: {e}"));
863 return;
864 }
865 };
866
867 buffer.push_str(&String::from_utf8_lossy(&chunk));
868
869 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 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 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}