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, 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
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_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, 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 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 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, 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 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 yield Err(anyhow::anyhow!("stream error: {e}"));
870 return;
871 }
872 };
873
874 buffer.push_str(&String::from_utf8_lossy(&chunk));
875
876 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 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 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}