diff --git a/backend/app/models/llm/constants.py b/backend/app/models/llm/constants.py
index a731b00a7..91c3099eb 100644
--- a/backend/app/models/llm/constants.py
+++ b/backend/app/models/llm/constants.py
@@ -176,7 +176,7 @@ def normalize_bcp47_language(value: str) -> str:
DEFAULT_STT_MODEL = "gemini-2.5-pro"
-DEFAULT_TTS_MODEL = "gemini-3.1-flash-tts-preview"
+DEFAULT_TTS_MODEL = "gemini-3.8-flash-tts"
DEFAULT_TTS_VOICE = "Kore"
DEFAULT_RAG_MODEL = "gpt-4o"
@@ -197,23 +197,26 @@ def normalize_bcp47_language(value: str) -> str:
DEFAULT_SARVAM_STT_MODEL = "saaras:v3"
DEFAULT_SARVAM_TTS_MODEL = "bulbul:v3"
DEFAULT_ELEVENLABS_STT_MODEL = "scribe_v2"
-DEFAULT_ELEVENLABS_TTS_MODEL = "eleven_v3"
+DEFAULT_ELEVENLABS_TTS_MODEL = "eleven_v4"
-# BCP-47 to language tag -> Gemini ISO 639-1 code (Indic + English)
+# Kaapi BCP-47 tag -> Gemini TTS language. Full locales, not bare ISO codes:
+# en-IN keeps the Indian accent where "en" does not. Unlisted tags fall back
+# to auto-detect.
BCP47_LOCALE_TO_GEMINI_LANG: dict[str, str] = {
- "en-IN": "en",
- "hi-IN": "hi",
- "bn-IN": "bn",
- "ta-IN": "ta",
- "te-IN": "te",
- "mr-IN": "mr",
- "gu-IN": "gu",
- "kn-IN": "kn",
- "ml-IN": "ml",
- "pa-IN": "pa",
- "od-IN": "or",
- # "as-IN": "as", //not supported by Gemini
- "sd-IN": "sd",
+ "en-IN": "en-IN",
+ "hi-IN": "hi-IN",
+ "bn-IN": "bn-IN",
+ "ta-IN": "ta-IN",
+ "te-IN": "te-IN",
+ "mr-IN": "mr-IN",
+ "gu-IN": "gu-IN",
+ "kn-IN": "kn-IN",
+ "ml-IN": "ml-IN",
+ "pa-IN": "pa-IN",
+ # Kaapi uses the non-standard "od-IN" for Odia; the real tag is "or-IN".
+ "od-IN": "or-IN",
+ # "as-IN": not supported by Gemini
+ "sd-IN": "sd-IN",
}
# BCP-47 language tag → ElevenLabs ISO 639-1 code (Indic + English)
diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py
index fdc078d17..a68589561 100644
--- a/backend/app/models/llm/request.py
+++ b/backend/app/models/llm/request.py
@@ -92,7 +92,7 @@ class TextLLMParams(ParamSerialization, SQLModel):
"(e.g. {'type': 'enabled', 'budget_tokens': 4096})"
),
)
- thinking_level: Literal["low", "medium", "high"] | None = Field(
+ thinking_level: Literal["minimal", "low", "medium", "high"] | None = Field(
default=None,
description="Google thinking level for thinking-capable Gemini models",
)
diff --git a/backend/app/services/llm/mappers.py b/backend/app/services/llm/mappers.py
index 3be295dd7..ee34977b9 100644
--- a/backend/app/services/llm/mappers.py
+++ b/backend/app/services/llm/mappers.py
@@ -267,8 +267,9 @@ def map_kaapi_to_google_params(
Supported Mapping:
- model → model
- instructions → instructions (for STT prompts, if available)
- - temperature -> temperature parameter (0-2)
- - top_p / max_output_tokens → same name (text only)
+ - temperature / top_p → not sent (deprecated by Gemini)
+ - max_output_tokens → same name (text only)
+ - language → full Gemini locale via BCP47_LOCALE_TO_GEMINI_LANG; unsupported → auto-detect (TTS only)
- thinking_level → thinking_config.thinking_level (text only)
- output_schema → output_schema, converted to Gemini's shape (text only)
- knowledge_base_ids → FileSearch tool store names (text only)
@@ -296,19 +297,11 @@ def map_kaapi_to_google_params(
google_params["model"] = model
if completion_type == CompletionType.TEXT:
- # Text completion - instructions, temperature, reasoning, knowledge_base_ids
+ # Text completion - instructions, reasoning, knowledge_base_ids
instructions = params.get("instructions")
if instructions:
google_params["instructions"] = instructions
- temperature = params.get("temperature")
- if temperature is not None:
- google_params["temperature"] = temperature
-
- top_p = params.get("top_p")
- if top_p is not None:
- google_params["top_p"] = top_p
-
max_output_tokens = params.get("max_output_tokens")
if max_output_tokens is not None:
google_params["max_output_tokens"] = max_output_tokens
@@ -353,7 +346,7 @@ def map_kaapi_to_google_params(
# Language: Only set if explicitly provided (None/missing = auto-detect)
language = params.get("language")
if language:
- google_lang = BCP47_LOCALE_TO_GEMINI_LANG.get(language) or None
+ google_lang = BCP47_LOCALE_TO_GEMINI_LANG.get(language)
if not google_lang:
warnings.append(
f"Unsupported language '{language}' for Gemini TTS, using auto-detect"
@@ -362,7 +355,7 @@ def map_kaapi_to_google_params(
google_params["language"] = google_lang
elif completion_type == CompletionType.STT:
- # STT mode - instructions, temperature, input_language, output_language, response_format
+ # STT mode - instructions, input_language, output_language, response_format
# Apply smart default for input_language
input_language = params.get("input_language") or "auto"
google_params["input_language"] = input_language
@@ -372,10 +365,6 @@ def map_kaapi_to_google_params(
if instructions:
google_params["instructions"] = instructions
- temperature = params.get("temperature")
- if temperature is not None:
- google_params["temperature"] = temperature
-
output_language = params.get("output_language")
if output_language:
google_params["output_language"] = output_language
diff --git a/backend/app/services/llm/providers/google_aistudio.py b/backend/app/services/llm/providers/google_aistudio.py
index 16724c87c..d78d7b7a3 100644
--- a/backend/app/services/llm/providers/google_aistudio.py
+++ b/backend/app/services/llm/providers/google_aistudio.py
@@ -1,29 +1,34 @@
-import logging
import base64
+import io
+import logging
+import wave
from typing import Any
+
from google import genai
from google.genai import errors as genai_errors
-from google.genai.types import (
- FileSearch,
- GenerateContentResponse,
- GenerateContentConfig,
- ThinkingConfig,
- SpeechConfig,
- Tool,
- VoiceConfig,
- PrebuiltVoiceConfig,
-)
+from google.genai import types as genai_types
+
+# Interactions raises its own error hierarchy, not google.genai.errors; in
+# google-genai 2.29 this private module is the only path that exports it.
+from google.genai._gaos.lib import compat_errors as interactions_errors
+from google.genai.interactions import Interaction
+from app.core.audio_utils import (
+ AudioRef,
+ convert_pcm_to_mp3,
+ convert_pcm_to_ogg,
+ pcm_to_wav,
+)
from app.models.llm import (
- NativeCompletionConfig,
+ ImageContent,
LLMCallResponse,
- QueryParams,
LLMResponse,
- Usage,
- TextOutput,
- TextContent,
- ImageContent,
+ NativeCompletionConfig,
PDFContent,
+ QueryParams,
+ TextContent,
+ TextOutput,
+ Usage,
)
from app.models.llm.constants import (
DEFAULT_STT_MODEL,
@@ -32,15 +37,8 @@
DEFAULT_TTS_VOICE,
CompletionType,
)
-from app.models.llm.response import AudioOutput, AudioContent
+from app.models.llm.response import AudioContent, AudioOutput
from app.services.llm.providers.base import BaseProvider, ContentPart, MultiModalInput
-from app.services.llm.mappers import BCP47_LOCALE_TO_GEMINI_LANG
-from app.core.audio_utils import (
- AudioRef,
- convert_pcm_to_mp3,
- convert_pcm_to_ogg,
- pcm_to_wav,
-)
logger = logging.getLogger(__name__)
@@ -59,59 +57,119 @@ def __init__(self, client: genai.Client):
def create_client(credentials: dict[str, Any]) -> Any:
if "api_key" not in credentials:
raise ValueError("API Key for Google Gemini Not Set")
- return genai.Client(api_key=credentials["api_key"])
+ # Interactions reads `attempts` as the retry count; files.upload as total attempts.
+ return genai.Client(
+ api_key=credentials["api_key"],
+ http_options=genai_types.HttpOptions(
+ retry_options=genai_types.HttpRetryOptions(attempts=3)
+ ),
+ )
@staticmethod
def format_parts(
parts: list[ContentPart],
- ) -> list[dict]:
- items = []
+ ) -> list[dict[str, Any]]:
+ """Render Kaapi content parts as Interactions content items."""
+ items: list[dict[str, Any]] = []
for part in parts:
if isinstance(part, TextContent):
- items.append({"text": part.value})
-
- elif isinstance(part, ImageContent):
- if part.format == "base64":
- items.append(
- {
- "inline_data": {
- "data": part.value,
- "mime_type": part.mime_type,
- }
- }
- )
- else:
- items.append(
- {
- "file_data": {
- "file_uri": part.value,
- "mime_type": part.mime_type,
- "display_name": None,
- }
- }
- )
+ items.append({"type": "text", "text": part.value})
+ continue
+
+ if isinstance(part, ImageContent):
+ item: dict[str, Any] = {"type": "image"}
elif isinstance(part, PDFContent):
- if part.format == "base64":
- items.append(
- {
- "inline_data": {
- "data": part.value,
- "mime_type": part.mime_type,
- }
- }
- )
- else:
- items.append(
- {
- "file_data": {
- "file_uri": part.value,
- "mime_type": part.mime_type,
- "display_name": None,
- }
- }
- )
+ item = {"type": "document"}
+ else:
+ continue
+
+ if part.format == "base64":
+ item["data"] = part.value
+ else:
+ item["uri"] = part.value
+ if part.mime_type:
+ item["mime_type"] = part.mime_type
+ items.append(item)
return items
+ @staticmethod
+ def _extract_usage(interaction: Interaction, provider: str) -> Usage:
+ usage = interaction.usage
+ if usage is None:
+ logger.warning(
+ f"[GoogleAIProvider._extract_usage] Interaction missing usage, using zeros | "
+ f"provider={provider}, interaction_id={interaction.id}"
+ )
+ return Usage(
+ input_tokens=0, output_tokens=0, total_tokens=0, reasoning_tokens=0
+ )
+
+ return Usage(
+ input_tokens=usage.total_input_tokens or 0,
+ output_tokens=usage.total_output_tokens or 0,
+ total_tokens=usage.total_tokens or 0,
+ reasoning_tokens=usage.total_thought_tokens or 0,
+ )
+
+ @staticmethod
+ def create_interaction(
+ client: genai.Client, request: dict[str, Any]
+ ) -> Interaction:
+ interaction = client.interactions.create(**request)
+ # create() is typed Interaction | Stream; we never pass stream=True.
+ if not isinstance(interaction, Interaction):
+ raise RuntimeError(
+ f"[GoogleAIProvider.create_interaction] Expected an Interaction, "
+ f"got {type(interaction).__name__}"
+ )
+ return interaction
+
+ @classmethod
+ def build_text_request(
+ cls,
+ params: dict[str, Any],
+ model: str,
+ resolved_input: str | list[ContentPart] | MultiModalInput,
+ ) -> dict[str, Any]:
+ """Build the Interactions request shared by the AI Studio and GCP text paths."""
+ if isinstance(resolved_input, MultiModalInput):
+ content = cls.format_parts(resolved_input.parts)
+ elif isinstance(resolved_input, list):
+ content = cls.format_parts(resolved_input)
+ else:
+ content = [{"type": "text", "text": resolved_input}]
+
+ request: dict[str, Any] = {
+ "model": model,
+ "input": [{"type": "user_input", "content": content}],
+ }
+
+ instructions = params.get("instructions")
+ if instructions:
+ request["system_instruction"] = instructions
+
+ # Kaapi configs carry thinking_config.thinking_level; native configs use reasoning.
+ thinking_level = (params.get("thinking_config") or {}).get(
+ "thinking_level"
+ ) or params.get("reasoning")
+ max_output_tokens = params.get("max_output_tokens")
+ generation_config: dict[str, Any] = {}
+ if thinking_level:
+ generation_config["thinking_level"] = thinking_level
+ if max_output_tokens is not None:
+ generation_config["max_output_tokens"] = max_output_tokens
+ if generation_config:
+ request["generation_config"] = generation_config
+
+ # The bare-schema form is enforced on Gemini 2.5 and 3; the typed
+ # {"type": "text", "schema": ...} wrapper is silently ignored on 2.5.
+ output_schema = params.get("output_schema")
+ if output_schema:
+ request["response_mime_type"] = "application/json"
+ request["response_format"] = output_schema
+
+ return request
+
def _execute_stt(
self,
completion_config: NativeCompletionConfig,
@@ -147,9 +205,8 @@ def _execute_stt(
instructions = generation_params.get("instructions", "")
input_language = generation_params.get("input_language") or "auto"
output_language = generation_params.get("output_language", "")
- temperature = generation_params.get("temperature") or 0.0
+ thinking_level = generation_params.get("thinking_level") or "low"
- # Build transcription/translation instruction
if input_language == "auto":
lang_instruction = (
"Detect the spoken language automatically and transcribe the audio"
@@ -161,7 +218,6 @@ def _execute_stt(
lang_instruction += f" and translate to {output_language} in the native script of {output_language} and only return transcribed script in {output_language}."
forced_transcription_text = "Only return transcribed text and no other text."
- # Merge user instructions with language instructions
if instructions:
merged_instruction = (
f"{instructions}. {lang_instruction}. {forced_transcription_text}"
@@ -173,36 +229,41 @@ def _execute_stt(
with resolved_input.to_path() as audio_path:
gemini_file = self.client.files.upload(file=audio_path)
- contents = []
- if merged_instruction:
- contents.append(merged_instruction)
- contents.append(gemini_file)
-
- response: GenerateContentResponse = self.client.models.generate_content(
- model=model,
- contents=contents,
- # TODO switch back default thinking configs for reasoning supported models in future
- config=GenerateContentConfig(
- thinking_config=ThinkingConfig(
- include_thoughts=True, thinking_budget=1000
- ),
- temperature=temperature,
- ),
+ interaction = self.create_interaction(
+ self.client,
+ {
+ "model": model,
+ "input": [
+ {
+ "type": "user_input",
+ "content": [
+ {"type": "text", "text": merged_instruction},
+ {
+ "type": "audio",
+ "uri": gemini_file.uri,
+ "mime_type": gemini_file.mime_type,
+ },
+ ],
+ }
+ ],
+ "generation_config": {"thinking_level": thinking_level},
+ },
)
- # Validate response has required fields
- if not response.response_id:
+ response_id = interaction.id
+ if not response_id or interaction.status != "completed":
error_message = (
- "[GEMINI] STT response is missing a response_id. This indicates "
- "an unexpected upstream payload from Gemini. Retry the request; "
- "if the issue persists, contact Kaapi."
+ f"[GEMINI] STT interaction did not complete (status="
+ f"{interaction.status}, errors={interaction.errors}). Retry the "
+ f"request; if the issue persists, contact Kaapi."
)
logger.warning(
- f"[GoogleAIProvider._execute_stt] {error_message} | provider={provider}, model={model}"
+ f"[GoogleAIProvider._execute_stt] {error_message} | "
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- if not response.text:
+ if not interaction.output_text:
error_message = (
"[GEMINI] STT response is missing transcribed text. Gemini "
"returned an empty result — verify the audio is audible and in "
@@ -211,51 +272,30 @@ def _execute_stt(
)
logger.warning(
f"[GoogleAIProvider._execute_stt] {error_message} | "
- f"provider={provider}, model={model}, response_id={response.response_id}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- # Extract usage metadata with null checks
- if response.usage_metadata:
- input_tokens = response.usage_metadata.prompt_token_count or 0
- output_tokens = response.usage_metadata.candidates_token_count or 0
- total_tokens = response.usage_metadata.total_token_count or 0
- reasoning_tokens = response.usage_metadata.thoughts_token_count or 0
- else:
- logger.warning(
- f"[GoogleAIProvider._execute_stt] Response missing usage_metadata, using zeros | provider={provider}"
- )
- input_tokens = 0
- output_tokens = 0
- total_tokens = 0
- reasoning_tokens = 0
-
- # Build response
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=response.response_id,
- model=response.model_version or model,
+ provider_response_id=response_id,
+ model=interaction.model or model,
provider=provider,
output=TextOutput(
content=TextContent(
- value=response.text, language_code=output_language
+ value=interaction.output_text, language_code=output_language
)
),
),
- usage=Usage(
- input_tokens=input_tokens,
- output_tokens=output_tokens,
- total_tokens=total_tokens,
- reasoning_tokens=reasoning_tokens,
- ),
+ usage=self._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = response.model_dump()
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleAIProvider._execute_stt] Successfully generated STT response | "
- f"request_id={response.response_id}, provider={provider}, model={model}"
+ f"request_id={response_id}, provider={provider}, model={model}"
)
return llm_response, None
@@ -279,7 +319,6 @@ def _execute_tts(
provider = completion_config.provider
generation_params = completion_config.params
- # Validate input is a text string
if not isinstance(resolved_input, str):
error_message = (
f"[KAAPI] TTS validation failed: {provider} TTS requires a text "
@@ -301,54 +340,53 @@ def _execute_tts(
)
return None, error_message
- # Extract params with defaults (language is optional — Gemini auto-detects from script)
model = generation_params.get("model") or DEFAULT_TTS_MODEL
voice = generation_params.get("voice") or DEFAULT_TTS_VOICE
-
+ # Optional: Gemini auto-detects the language from the script when unset.
language = generation_params.get("language")
-
- # Extract optional params
response_format = generation_params.get("response_format", "wav")
- # Extract Gemini-specific params from provider_specific.gemini
provider_specific = generation_params.get("provider_specific", {})
gemini_params = provider_specific.get("gemini", {})
-
director_notes = gemini_params.get("director_notes", "")
- # Build Gemini TTS config
- config_kwargs = {
- "response_modalities": ["AUDIO"],
- "speech_config": SpeechConfig(
- voice_config=VoiceConfig(
- prebuilt_voice_config=PrebuiltVoiceConfig(voice_name=voice)
- ),
- language_code=language,
- ),
- }
+ transcript: dict[str, Any] = {
+ "type": "text",
+ "text": f"{resolved_input}",
+ }
if director_notes:
- config_kwargs["system_instruction"] = director_notes
+ transcript["annotations"] = [
+ {"type": "speech_metadata", "style": director_notes}
+ ]
- config = GenerateContentConfig(**config_kwargs)
- decorated_resolved_input = f"{resolved_input}"
- # Execute TTS
- response: GenerateContentResponse = self.client.models.generate_content(
- model=model, contents=decorated_resolved_input, config=config
+ speech_config: dict[str, Any] = {"voice": voice}
+ if language:
+ speech_config["language"] = language
+
+ interaction = self.create_interaction(
+ self.client,
+ {
+ "model": model,
+ "input": [{"type": "user_input", "content": [transcript]}],
+ "generation_config": {"speech_config": [speech_config]},
+ "response_format": {"type": "audio"},
+ },
)
- if not response.response_id:
+
+ response_id = interaction.id
+ if not response_id or interaction.status != "completed":
error_message = (
- "[GEMINI] TTS response is missing a response_id. This indicates "
- "an unexpected upstream payload from Gemini. Retry the request; "
- "if the issue persists, contact Kaapi."
+ f"[GEMINI] TTS interaction did not complete (status="
+ f"{interaction.status}, errors={interaction.errors}). Retry the "
+ f"request; if the issue persists, contact Kaapi."
)
logger.warning(
- f"[GoogleAIProvider._execute_tts] {error_message} | provider={provider}, model={model}"
+ f"[GoogleAIProvider._execute_tts] {error_message} | "
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- try:
- raw_audio_bytes = response.candidates[0].content.parts[0].inline_data.data
- except (IndexError, AttributeError) as e:
+ if interaction.output_audio is None:
error_message = (
"[GEMINI] Failed to extract audio bytes from TTS response: "
"Gemini was unable to generate audio from the provided input. "
@@ -358,12 +396,11 @@ def _execute_tts(
)
logger.warning(
f"[GoogleAIProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}, response_id={response.response_id}, cause={type(e).__name__}",
- exc_info=True,
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- if not raw_audio_bytes:
+ if not interaction.output_audio.data:
error_message = (
"[GEMINI] TTS response is missing generated audio data. This is "
"typically a Gemini server-side error. Wait a minute and retry; "
@@ -371,111 +408,82 @@ def _execute_tts(
)
logger.warning(
f"[GoogleAIProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}, response_id={response.response_id}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- # Post-process audio format conversion if needed
- # Gemini TTS natively outputs 24kHz 16-bit raw PCM — wrap in WAV container
+ raw_audio_bytes = base64.b64decode(interaction.output_audio.data)
+ # Preview TTS models return headerless 24 kHz PCM (and reject audio/l16),
+ # while 3.8+ return a full WAV; normalise to PCM for pcm_to_wav / convert_pcm_to_*.
+ if raw_audio_bytes.startswith(b"RIFF"):
+ with wave.open(io.BytesIO(raw_audio_bytes), "rb") as wav_file:
+ raw_audio_bytes = wav_file.readframes(wav_file.getnframes())
+
actual_format = "wav"
- wav_bytes = pcm_to_wav(raw_audio_bytes)
- encoded_content = base64.b64encode(wav_bytes).decode("ascii")
-
- if response_format and response_format != "wav":
- # Need to convert from WAV to requested format
- logger.info(
- f"[GoogleAIProvider._execute_tts] Converting audio from WAV to {response_format} | provider={provider}"
- )
-
- if response_format == "mp3":
- converted_bytes, convert_error = convert_pcm_to_mp3(raw_audio_bytes)
- if convert_error:
- error_message = (
- f"[KAAPI] Post-processing failure: unable to convert "
- f"Gemini PCM audio to MP3 ({convert_error}). Falling "
- f"back to WAV is possible by setting response_format='wav'."
- )
- logger.error(
- f"[GoogleAIProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}, pcm_bytes={len(raw_audio_bytes)}"
- )
- return None, error_message
- encoded_content = base64.b64encode(converted_bytes or b"").decode(
- "ascii"
+ if response_format == "mp3":
+ converted_bytes, convert_error = convert_pcm_to_mp3(raw_audio_bytes)
+ if convert_error:
+ error_message = (
+ f"[KAAPI] Post-processing failure: unable to convert "
+ f"Gemini PCM audio to MP3 ({convert_error}). Falling "
+ f"back to WAV is possible by setting response_format='wav'."
)
- actual_format = "mp3"
-
- elif response_format == "ogg":
- converted_bytes, convert_error = convert_pcm_to_ogg(raw_audio_bytes)
- if convert_error:
- error_message = (
- f"[KAAPI] Post-processing failure: unable to convert "
- f"Gemini PCM audio to OGG ({convert_error}). Falling "
- f"back to WAV is possible by setting response_format='wav'."
- )
- logger.error(
- f"[GoogleAIProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}, pcm_bytes={len(raw_audio_bytes)}"
- )
- return None, error_message
- encoded_content = base64.b64encode(converted_bytes or b"").decode(
- "ascii"
+ logger.error(
+ f"[GoogleAIProvider._execute_tts] {error_message} | "
+ f"provider={provider}, model={model}, pcm_bytes={len(raw_audio_bytes)}"
)
- actual_format = "ogg"
- else:
- logger.warning(
- f"[GoogleAIProvider._execute_tts] Unsupported response_format '{response_format}', returning native WAV | provider={provider}"
+ return None, error_message
+ encoded_content = base64.b64encode(converted_bytes or b"").decode("ascii")
+ actual_format = "mp3"
+
+ elif response_format == "ogg":
+ converted_bytes, convert_error = convert_pcm_to_ogg(raw_audio_bytes)
+ if convert_error:
+ error_message = (
+ f"[KAAPI] Post-processing failure: unable to convert "
+ f"Gemini PCM audio to OGG ({convert_error}). Falling "
+ f"back to WAV is possible by setting response_format='wav'."
+ )
+ logger.error(
+ f"[GoogleAIProvider._execute_tts] {error_message} | "
+ f"provider={provider}, model={model}, pcm_bytes={len(raw_audio_bytes)}"
)
- response_format = "wav"
- logger.info(
- f"[GoogleAIProvider._execute_tts] Audio conversion successful: {actual_format.upper()} ({len(raw_audio_bytes)} bytes) | provider={provider}"
- )
- response_mime_type = f"audio/{response_format}"
-
- # Extract usage metadata
- if response.usage_metadata:
- input_tokens = response.usage_metadata.prompt_token_count or 0
- output_tokens = response.usage_metadata.candidates_token_count or 0
- total_tokens = response.usage_metadata.total_token_count or 0
- reasoning_tokens = response.usage_metadata.thoughts_token_count or 0
+ return None, error_message
+ encoded_content = base64.b64encode(converted_bytes or b"").decode("ascii")
+ actual_format = "ogg"
+
else:
- logger.warning(
- f"[GoogleAIProvider._execute_tts] Response missing usage_metadata, using zeros | provider={provider}"
+ if response_format and response_format != "wav":
+ logger.warning(
+ f"[GoogleAIProvider._execute_tts] Unsupported response_format "
+ f"'{response_format}', returning native WAV | provider={provider}"
+ )
+ encoded_content = base64.b64encode(pcm_to_wav(raw_audio_bytes)).decode(
+ "ascii"
)
- input_tokens = 0
- output_tokens = 0
- total_tokens = 0
- reasoning_tokens = 0
- # Build response
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=response.response_id,
- model=response.model_version or model,
+ provider_response_id=response_id,
+ model=interaction.model or model,
provider=provider,
- # output=LLMOutput(audio_bytes=audio_bytes, audio_format=actual_format),
output=AudioOutput(
content=AudioContent(
format="base64",
value=encoded_content,
- mime_type=response_mime_type,
+ mime_type=f"audio/{actual_format}",
)
),
),
- usage=Usage(
- input_tokens=input_tokens,
- output_tokens=output_tokens,
- total_tokens=total_tokens,
- reasoning_tokens=reasoning_tokens,
- ),
+ usage=self._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = response.model_dump()
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleAIProvider._execute_tts] Successfully generated TTS response | "
- f"request_id={response.response_id}, provider={provider}, model={model}, audio_size={len(raw_audio_bytes)} bytes"
+ f"request_id={response_id}, provider={provider}, model={model}, audio_size={len(raw_audio_bytes)} bytes"
)
return llm_response, None
@@ -486,122 +494,62 @@ def _execute_text(
resolved_input: str | list[ContentPart] | MultiModalInput,
include_provider_raw_response: bool = False,
) -> tuple[LLMCallResponse | None, str | None]:
- model = completion_config.params.get("model") or DEFAULT_TEXT_MODELS["google"]
-
- if isinstance(resolved_input, MultiModalInput):
- gemini_parts = self.format_parts(resolved_input.parts)
- contents = [{"role": "user", "parts": gemini_parts}]
- elif isinstance(resolved_input, list):
- gemini_parts = self.format_parts(resolved_input)
- contents = [{"role": "user", "parts": gemini_parts}]
- else:
- contents = [{"role": "user", "parts": [{"text": resolved_input}]}]
-
- instructions = completion_config.params.get("instructions", "")
- temperature = completion_config.params.get("temperature", None)
- thinking_level = completion_config.params.get("reasoning", None)
- knowledge_base_ids = completion_config.params.get("knowledge_base_ids", None)
-
- generation_kwargs = {}
- if instructions:
- generation_kwargs["system_instruction"] = instructions
-
- if temperature is not None:
- generation_kwargs["temperature"] = temperature
-
- if thinking_level is not None:
- generation_kwargs["thinking_config"] = ThinkingConfig(
- include_thoughts=False, thinking_level=thinking_level
- )
+ provider = completion_config.provider
+ params = completion_config.params
+ model = params.get("model") or DEFAULT_TEXT_MODELS["google"]
+ request = self.build_text_request(params, model, resolved_input)
+ knowledge_base_ids = params.get("knowledge_base_ids")
if knowledge_base_ids:
- generation_kwargs["tools"] = [
- Tool(file_search=FileSearch(file_search_store_names=knowledge_base_ids))
+ request["tools"] = [
+ {"type": "file_search", "file_search_store_names": knowledge_base_ids}
]
- response = self.client.models.generate_content(
- model=model,
- contents=contents,
- config=GenerateContentConfig(**generation_kwargs),
- )
+ interaction = self.create_interaction(self.client, request)
- provider = completion_config.provider
-
- if not response.response_id:
+ response_id = interaction.id
+ if not response_id or interaction.status != "completed":
error_message = (
- "[GEMINI] Text response is missing a response_id. This "
- "indicates an unexpected upstream payload from Gemini. Retry "
- "the request; if the issue persists, contact Kaapi."
+ f"[GEMINI] Text interaction did not complete (status="
+ f"{interaction.status}, errors={interaction.errors}). This "
+ f"typically means the response was blocked by Gemini's safety "
+ f"filters or failed upstream. Review the prompt, then retry."
)
logger.warning(
- f"[GoogleAIProvider._execute_text] {error_message} | provider={provider}, model={model}"
+ f"[GoogleAIProvider._execute_text] {error_message} | "
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- if not response.text:
- # Gemini commonly returns no text when the response is blocked by
- # safety filters or when the candidate finishes with a non-STOP
- # reason. Surface the finish_reason / block_reason if available so
- # the caller can act on it.
- finish_reason = None
- block_reason = None
- try:
- finish_reason = response.candidates[0].finish_reason
- except (IndexError, AttributeError):
- pass
- try:
- block_reason = response.prompt_feedback.block_reason
- except AttributeError:
- pass
-
+ if not interaction.output_text:
error_message = (
f"[GEMINI] Text response is missing generated content "
- f"(finish_reason={finish_reason}, block_reason={block_reason}). "
- f"This typically means the response was blocked by Gemini's "
- f"safety filters, truncated by token limits, or the model "
- f"returned no candidates. Review the prompt and safety "
- f"settings, then retry."
+ f"(status={interaction.status}). This typically means the "
+ f"model returned no text output — e.g. the response was blocked "
+ f"by Gemini's safety filters or consumed by thinking tokens. "
+ f"Review the prompt and token limits, then retry."
)
logger.warning(
f"[GoogleAIProvider._execute_text] {error_message} | "
- f"provider={provider}, model={model}, response_id={response.response_id}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- if response.usage_metadata:
- input_tokens = response.usage_metadata.prompt_token_count or 0
- output_tokens = response.usage_metadata.candidates_token_count or 0
- total_tokens = response.usage_metadata.total_token_count or 0
- reasoning_tokens = response.usage_metadata.thoughts_token_count or 0
- else:
- logger.warning(
- f"[GoogleAIProvider._execute_text] Response missing usage_metadata, using zeros | provider={completion_config.provider}"
- )
- input_tokens = 0
- output_tokens = 0
- total_tokens = 0
- reasoning_tokens = 0
-
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=response.response_id,
- model=response.model_version or model,
- provider=completion_config.provider,
- output=TextOutput(content=TextContent(value=response.text)),
- ),
- usage=Usage(
- input_tokens=input_tokens,
- output_tokens=output_tokens,
- total_tokens=total_tokens,
- reasoning_tokens=reasoning_tokens,
+ provider_response_id=response_id,
+ model=interaction.model or model,
+ provider=provider,
+ output=TextOutput(content=TextContent(value=interaction.output_text)),
),
+ usage=self._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = response.model_dump(mode="json")
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleAIProvider._execute_text] Successfully generated text response | "
- f"request_id={response.response_id}, provider={completion_config.provider}, model={model}"
+ f"request_id={response_id}, provider={provider}, model={model}"
)
return llm_response, None
@@ -612,8 +560,9 @@ def execute(
resolved_input: str | list[ContentPart] | MultiModalInput,
include_provider_raw_response: bool = False,
) -> tuple[LLMCallResponse | None, str | None]:
+ provider = completion_config.provider
+ completion_type = completion_config.type
try:
- completion_type = completion_config.type
if completion_type == CompletionType.STT:
return self._execute_stt(
completion_config=completion_config,
@@ -643,7 +592,69 @@ def execute(
)
logger.warning(
f"[GoogleAIProvider.execute] {error_message} | "
- f"provider={completion_config.provider}, type={completion_type}",
+ f"provider={provider}, type={completion_type}",
+ exc_info=True,
+ )
+ return None, error_message
+
+ except interactions_errors.APIStatusError as e:
+ code = e.status_code
+ if code == 429:
+ error_message = (
+ f"[GEMINI] Rate limit / quota exceeded (code: 429): "
+ f"{e.message}. You have hit Gemini's per-minute or per-day "
+ f"quota for this model. Wait at least 1 minute and retry; "
+ f"if the issue persists, request a quota increase from "
+ f"Google or contact Kaapi."
+ )
+ elif code in (401, 403):
+ error_message = (
+ f"[GEMINI] Authentication / permission denied (code: "
+ f"{code}): {e.message}. Verify the Gemini API key is valid, "
+ f"not expired, and has access to the requested model and "
+ f"project."
+ )
+ elif code == 404:
+ error_message = (
+ f"[GEMINI] Resource not found (code: 404): {e.message}. "
+ f"Check that the model name and any referenced IDs in "
+ f"your config are correct and available in your region."
+ )
+ elif code == 400:
+ error_message = (
+ f"[GEMINI] Bad request (code: 400): {e.message}. Review "
+ f"your config parameters and input payload — the request "
+ f"shape, model, or content may be invalid for this Gemini "
+ f"endpoint."
+ )
+ elif code is not None and code >= 500:
+ error_message = (
+ f"[GEMINI] Server error (code: {code}): {e.message}. This "
+ f"is typically transient (Gemini overloaded, internal "
+ f"error, or deadline exceeded) — retry in a few seconds. "
+ f"If the issue persists, contact Kaapi."
+ )
+ else:
+ error_message = (
+ f"[GEMINI] Client error (code: {code}): {e.message}. "
+ f"Review the request configuration; if the issue persists, "
+ f"contact Kaapi."
+ )
+ logger.warning(
+ f"[GoogleAIProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}",
+ exc_info=True,
+ )
+ return None, error_message
+
+ except interactions_errors.APIConnectionError as e:
+ error_message = (
+ f"[KAAPI] Could not reach Gemini ({type(e).__name__}): {e}. "
+ f"Retry the request; if the issue persists, contact Kaapi."
+ )
+ logger.error(
+ f"[GoogleAIProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}",
exc_info=True,
)
return None, error_message
@@ -743,7 +754,16 @@ def execute(
f"failure. Contact Kaapi if the issue persists."
)
logger.error(
- f"[GoogleAIProvider.execute] {error_message} | provider={completion_config.provider}",
+ f"[GoogleAIProvider.execute] {error_message} | provider={provider}",
exc_info=True,
)
return None, error_message
+
+ error_message = (
+ f"[KAAPI] Unsupported completion type '{completion_type}' for "
+ f"Gemini. Use one of: text, stt, tts."
+ )
+ logger.warning(
+ f"[GoogleAIProvider.execute] {error_message} | provider={provider}"
+ )
+ return None, error_message
diff --git a/backend/app/services/llm/providers/google_gcp.py b/backend/app/services/llm/providers/google_gcp.py
index 172d24953..470cc2d15 100644
--- a/backend/app/services/llm/providers/google_gcp.py
+++ b/backend/app/services/llm/providers/google_gcp.py
@@ -1,10 +1,18 @@
import base64
+import io
import json
import logging
import uuid
+import wave
from typing import Any
-import requests
+from google import genai
+from google.auth import exceptions as google_auth_errors
+from google.genai import types as genai_types
+
+# Interactions raises its own error hierarchy, not google.genai.errors; in
+# google-genai 2.29 this private module is the only path that exports it.
+from google.genai._gaos.lib import compat_errors as interactions_errors
from app.core.audio_utils import (
AudioRef,
@@ -12,18 +20,15 @@
convert_pcm_to_ogg,
pcm_to_wav,
)
-from app.core.cloud.storage import upload_audio_to_gcs
+from app.core.cloud.storage import build_gcp_sa_credentials, upload_audio_to_gcs
from app.core.config import settings
from app.models.llm import (
- ImageContent,
LLMCallResponse,
LLMResponse,
NativeCompletionConfig,
- PDFContent,
QueryParams,
TextContent,
TextOutput,
- Usage,
)
from app.models.llm.constants import (
DEFAULT_STT_MODEL,
@@ -32,13 +37,14 @@
DEFAULT_TTS_VOICE,
CompletionType,
)
-from app.models.llm.request import ImageContent, PDFContent
from app.models.llm.response import AudioContent, AudioOutput
from app.services.llm.providers.base import BaseProvider, ContentPart, MultiModalInput
+from app.services.llm.providers.google_aistudio import GoogleAIProvider
logger = logging.getLogger(__name__)
-REQUEST_TIMEOUT = 60
+REQUEST_TIMEOUT_MS = 60_000
+DEFAULT_VERTEX_LOCATION = "global"
SUPPORTED_AUDIO_MIMES = {
"audio/wav",
"audio/mp3",
@@ -50,65 +56,39 @@
}
-def _load_platform_sa_info() -> dict | None:
- """Load the platform-default GCP SA JSON.
-
- Supports two configuration shapes for settings.GCP_SA_KEY:
- 1. Raw JSON string (e.g. injected via env var / secret manager)
- 2. Filesystem path to a JSON key file
+def _parse_sa_info(value: Any) -> dict | None:
+ """Accept the SA key as a dict (credential row) or a JSON string (env /
+ secret manager). Unparseable values count as absent instead of raising, so a
+ bad key degrades to the next auth option rather than failing client creation.
"""
- sa_value = settings.GCP_SA_KEY
- if not sa_value:
- return None
-
- stripped = sa_value.strip()
- if stripped.startswith("{"):
+ if isinstance(value, dict):
+ return value or None
+ if isinstance(value, str) and value.strip().startswith("{"):
try:
- return json.loads(stripped)
+ return json.loads(value)
except json.JSONDecodeError as e:
- logger.warning(
- f"[_load_platform_sa_info] GCP_SA_KEY looks like JSON but "
- f"failed to parse | error={e}"
- )
- return None
+ logger.warning(f"[_parse_sa_info] SA key is not valid JSON | error={e}")
+ return None
class GoogleGCPClient:
- """Holds Google GCP connection details. Pure config — no SDK session.
-
- BYOK: per-project SA JSON + GCS bucket are passed via credentials and
- stored directly on the client; falls back to platform-shared values
- in settings when not provided by the project credential row.
- """
+ """Vertex ``genai.Client`` plus the SA + bucket STT needs to stage audio in GCS."""
def __init__(
self,
- api_key: str,
- project_id: str,
- location: str,
+ genai_client: genai.Client,
+ project_id: str | None,
sa_info: dict | None = None,
gcs_bucket: str | None = None,
):
- self.api_key = api_key
+ self.genai = genai_client
self.project_id = project_id
- self.location = location
self.sa_info = sa_info
- self.gcs_bucket = gcs_bucket or settings.GCS_AUDIO_BUCKET
-
- def endpoint(self, model: str) -> str:
- if self.location == "global":
- host = "aiplatform.googleapis.com"
- else:
- host = f"{self.location}-aiplatform.googleapis.com"
- return (
- f"https://{host}/v1"
- f"/projects/{self.project_id}/locations/{self.location}"
- f"/publishers/google/models/{model}:generateContent"
- )
+ self.gcs_bucket = gcs_bucket
class GoogleGCPProvider(BaseProvider):
- """Google GCP provider using REST + API key auth.
+ """Google GCP (Vertex AI) provider on the google-genai Interactions API.
Supports text, STT (audio → text), and TTS (text → audio) via Gemini
multimodal models on GCP.
@@ -120,298 +100,73 @@ def __init__(self, client: GoogleGCPClient):
@staticmethod
def create_client(credentials: dict[str, Any]) -> Any:
- # settings.GCP_SA_KEY; BYOK rows pass `sa_key` inline.
+ # Each field falls back to platform settings on its own, so a BYOK row
+ # only needs the fields it wants to override.
credentials = credentials or {}
api_key = credentials.get("api_key") or settings.GCP_VERTEX_API_KEY
- project_id = credentials.get("project_id") or settings.GCP_PROJECT_ID
- location = credentials.get("location") or settings.GCP_VERTEX_LOCATION
- gcs_bucket = credentials.get("gcs_bucket") or settings.GCS_AUDIO_BUCKET
- sa_info = credentials.get("sa_key") or _load_platform_sa_info()
-
- source = "byok" if credentials.get("api_key") else "platform"
- logger.info(
- f"[create_client] google-gcp creds | source={source}, "
- f"project_id={project_id}, location={location}"
+ sa_info = _parse_sa_info(credentials.get("sa_key")) or _parse_sa_info(
+ settings.GCP_SA_KEY
)
-
- missing = [
- name
- for name, value in (
- ("api_key", api_key),
- ("project_id", project_id),
- ("location", location),
- ("sa_key", sa_info),
- ("gcs_bucket", gcs_bucket),
- )
- if not value
- ]
- if missing:
- raise ValueError(
- f"Google GCP credentials missing required fields: {', '.join(missing)}"
- )
- return GoogleGCPClient(
- api_key=api_key,
- project_id=project_id,
- location=location,
- sa_info=sa_info,
- gcs_bucket=gcs_bucket,
+ project_id = (
+ credentials.get("project_id")
+ or settings.GCP_PROJECT_ID
+ or (sa_info or {}).get("project_id")
)
+ location = (
+ credentials.get("location")
+ or settings.GCP_VERTEX_LOCATION
+ or DEFAULT_VERTEX_LOCATION
+ )
+ gcs_bucket = credentials.get("gcs_bucket") or settings.GCS_AUDIO_BUCKET
- def _post(
- self, model: str, payload: dict, log_context: str = ""
- ) -> tuple[dict | None, str | None]:
- """POST to GCP generateContent and return parsed JSON or a
- descriptive, pre-logged error message.
-
- Maps:
- - ``requests.Timeout`` / ``ConnectionError`` / ``RequestException``
- → ``[KAAPI]`` network-side errors
- - HTTP 4xx/5xx → ``[GOOGLE_GCP]`` errors, branched by status code, with
- Google's ``error.message`` / ``error.status`` surfaced when the
- response body is the standard error envelope.
- - Non-JSON 200 body → ``[GOOGLE_GCP]`` malformed-response error
- """
- url = self.client.endpoint(model)
- logger.debug(f"[_post] google-gcp url={url}")
-
- try:
- resp = requests.post(
- url,
- params={"key": self.client.api_key},
- headers={"Content-Type": "application/json"},
- json=payload,
- timeout=REQUEST_TIMEOUT,
- )
- except requests.Timeout as e:
- error_message = (
- f"[KAAPI] Google GCP request timed out after {REQUEST_TIMEOUT}s "
- f"(code: {type(e).__name__}): {str(e)}. The request took too "
- f"long to complete — retry with a smaller payload or contact "
- f"Kaapi if the issue persists."
- )
- logger.error(
- f"[GoogleGCPProvider._post] {error_message} | model={model}, {log_context}",
- exc_info=True,
- )
- return None, error_message
- except requests.ConnectionError as e:
- error_message = (
- f"[KAAPI] Google GCP connection failed (code: "
- f"{type(e).__name__}): {str(e)}. Network or DNS issue "
- f"reaching Google GCP — check network connectivity from the "
- f"Kaapi backend. If the issue persists, contact Kaapi."
- )
- logger.error(
- f"[GoogleGCPProvider._post] {error_message} | model={model}, {log_context}",
- exc_info=True,
- )
- return None, error_message
- except requests.RequestException as e:
- error_message = (
- f"[KAAPI] Google GCP request failed (code: "
- f"{type(e).__name__}): {str(e)}. Unexpected requests-library "
- f"error — contact Kaapi if the issue persists."
- )
- logger.error(
- f"[GoogleGCPProvider._post] {error_message} | model={model}, {log_context}",
- exc_info=True,
- )
- return None, error_message
-
- if not resp.ok:
- status_code = resp.status_code
- google_msg = resp.text[:500]
- google_status = None
+ sa_credentials = None
+ if sa_info:
try:
- err_envelope = resp.json().get("error", {})
- google_msg = err_envelope.get("message") or google_msg
- google_status = err_envelope.get("status")
- except (ValueError, AttributeError):
- # Body wasn't JSON or wasn't the expected envelope shape;
- # fall back to the raw text already captured above.
- pass
-
- status_label = f" {google_status}" if google_status else ""
-
- if status_code == 400:
- error_message = (
- f"[GOOGLE_GCP] Bad request (code: 400{status_label}): "
- f"{google_msg}. Review your config parameters and input "
- f"payload — the request shape, model, or content may be "
- f"invalid for this Google GCP endpoint."
- )
- elif status_code in (401, 403):
- error_message = (
- f"[GOOGLE_GCP] Authentication / permission denied (code: "
- f"{status_code}{status_label}): {google_msg}. Verify the "
- f"Google GCP API key is valid and not expired, the project_id "
- f"and location are correct, and the service account has "
- f"access to the requested model."
+ sa_credentials = build_gcp_sa_credentials(sa_info)
+ except (ValueError, KeyError) as e:
+ logger.warning(
+ f"[create_client] google-gcp SA key is malformed, falling "
+ f"back to API key | error={e}"
)
- elif status_code == 404:
- error_message = (
- f"[GOOGLE_GCP] Resource not found (code: 404{status_label}): "
- f"{google_msg}. Check that the model '{model}' exists and "
- f"is available in your project and location."
- )
- elif status_code == 429:
- error_message = (
- f"[GOOGLE_GCP] Rate limit / quota exceeded (code: 429"
- f"{status_label}): {google_msg}. You have hit Google GCP's "
- f"per-minute or per-day quota for this model. Wait at "
- f"least 1 minute and retry; if the issue persists, "
- f"request a quota increase from Google or contact Kaapi."
- )
- elif 500 <= status_code < 600:
- error_message = (
- f"[GOOGLE_GCP] Server error (code: {status_code}"
- f"{status_label}): {google_msg}. This is typically "
- f"transient (Google GCP overloaded or internal error) — "
- f"retry in a few seconds. If the issue persists, contact "
- f"Kaapi."
- )
- else:
- error_message = (
- f"[GOOGLE_GCP] HTTP error (code: {status_code}{status_label}): "
- f"{google_msg}. If the issue persists, contact Kaapi."
- )
-
- # 5xx server errors are escalation-worthy; 4xx (including 429)
- # are caller's fault and only need a warning.
- log = logger.error if 500 <= status_code < 600 else logger.warning
- log(
- f"[GoogleGCPProvider._post] {error_message} | "
- f"model={model}, {log_context}"
- )
- return None, error_message
+ sa_info = None
- try:
- return resp.json(), None
- except ValueError as e:
- error_message = (
- f"[GOOGLE_GCP] Returned a non-JSON success response: {str(e)}. "
- f"This indicates an unexpected payload shape from Google GCP — "
- f"retry the request. If the issue persists, contact Kaapi."
- )
- logger.warning(
- f"[GoogleGCPProvider._post] {error_message} | "
- f"model={model}, {log_context}"
- )
- return None, error_message
-
- @staticmethod
- def _extract_usage(data: dict) -> Usage:
- meta = data.get("usageMetadata") or {}
- input_tokens = meta.get("promptTokenCount") or 0
- output_tokens = meta.get("candidatesTokenCount") or 0
- total_tokens = meta.get("totalTokenCount") or (input_tokens + output_tokens)
- reasoning_tokens = meta.get("thoughtsTokenCount") or 0
- return Usage(
- input_tokens=input_tokens,
- output_tokens=output_tokens,
- total_tokens=total_tokens,
- reasoning_tokens=reasoning_tokens,
+ http_options = genai_types.HttpOptions(
+ timeout=REQUEST_TIMEOUT_MS,
+ retry_options=genai_types.HttpRetryOptions(attempts=3),
)
-
- @staticmethod
- def _format_content_parts(parts: list[ContentPart]) -> list[dict]:
- """Render Kaapi content parts as GCP REST `parts` entries (camelCase
- keys, matching the generateContent wire format used elsewhere in
- this file — not the genai SDK's snake_case Python bindings)."""
- items = []
- for part in parts:
- if isinstance(part, TextContent):
- items.append({"text": part.value})
- elif isinstance(part, (ImageContent, PDFContent)):
- if part.format == "base64":
- items.append(
- {"inlineData": {"mimeType": part.mime_type, "data": part.value}}
- )
- else:
- items.append(
- {
- "fileData": {
- "mimeType": part.mime_type,
- "fileUri": part.value,
- }
- }
- )
- return items
-
- def _execute_text(
- self,
- completion_config: NativeCompletionConfig,
- resolved_input: str | list[ContentPart] | MultiModalInput,
- include_provider_raw_response: bool = False,
- ) -> tuple[LLMCallResponse | None, str | None]:
- """Execute a text completion via Google GCP generateContent."""
- provider = completion_config.provider
- params = completion_config.params
-
- if isinstance(resolved_input, MultiModalInput):
- gemini_parts = self._format_content_parts(resolved_input.parts)
- elif isinstance(resolved_input, list):
- gemini_parts = self._format_content_parts(resolved_input)
- else:
- gemini_parts = [{"text": resolved_input}]
-
- model = params.get("model") or DEFAULT_TEXT_MODELS["google"]
- instructions = params.get("instructions")
- temperature = params.get("temperature")
-
- payload: dict[str, Any] = {
- "contents": [{"role": "user", "parts": gemini_parts}],
- }
- if instructions:
- payload["systemInstruction"] = {"parts": [{"text": instructions}]}
-
- generation_config: dict[str, Any] = {}
- if temperature is not None:
- generation_config["temperature"] = temperature
- if generation_config:
- payload["generationConfig"] = generation_config
-
- data, err = self._post(
- model, payload, log_context=f"provider={provider}, type=text"
- )
- if err:
- return None, err
-
- try:
- text = data["candidates"][0]["content"]["parts"][0]["text"]
- except (KeyError, IndexError, TypeError):
- error_message = (
- "[GOOGLE_GCP] Text response is missing generated content. "
- "Google GCP returned a 200 response but the expected "
- "candidates[0].content.parts[0].text path is absent — this "
- "typically means the response was blocked by safety filters "
- "or truncated. Review the prompt and safety settings, then "
- "retry."
+ # The SDK rejects credentials + api_key together. Prefer the SA since STT
+ # needs it for GCS anyway; otherwise use the API key (Vertex express mode).
+ if sa_credentials and project_id:
+ auth_mode = "service_account"
+ genai_client = genai.Client(
+ vertexai=True,
+ project=project_id,
+ location=location,
+ credentials=sa_credentials,
+ http_options=http_options,
+ )
+ elif api_key:
+ auth_mode = "api_key"
+ genai_client = genai.Client(
+ vertexai=True, api_key=api_key, http_options=http_options
)
- logger.warning(
- f"[GoogleGCPProvider._execute_text] {error_message} | "
- f"provider={provider}, model={model}, response_id={data.get('responseId')}"
+ else:
+ raise ValueError(
+ "Google GCP credentials missing: provide sa_key (with project_id) "
+ "or api_key, on the project credentials or in platform settings."
)
- return None, error_message
-
- llm_response = LLMCallResponse(
- response=LLMResponse(
- provider_response_id=data.get("responseId")
- or f"google-gcp-{uuid.uuid4().hex}",
- model=data.get("modelVersion") or model,
- provider=provider,
- output=TextOutput(content=TextContent(value=text)),
- ),
- usage=self._extract_usage(data),
- )
-
- if include_provider_raw_response:
- llm_response.provider_raw_response = data
logger.info(
- f"[GoogleGCPProvider._execute_text] Generated text response | provider={provider}, model={model}"
+ f"[create_client] google-gcp creds | "
+ f"source={'byok' if credentials else 'platform'}, auth={auth_mode}, "
+ f"project_id={project_id}, location={location}"
+ )
+ return GoogleGCPClient(
+ genai_client=genai_client,
+ project_id=project_id,
+ sa_info=sa_info,
+ gcs_bucket=gcs_bucket,
)
- return llm_response, None
def _execute_stt(
self,
@@ -419,13 +174,6 @@ def _execute_stt(
resolved_input: "AudioRef",
include_provider_raw_response: bool = False,
) -> tuple[LLMCallResponse | None, str | None]:
- """Execute STT via Google GCP generateContent.
-
- Note:
- HTTP / network errors come back from ``_post()`` already-logged
- and tagged. This method only handles Kaapi-side input validation,
- staging failures, and Google GCP response-shape checks.
- """
provider = completion_config.provider
params = completion_config.params
@@ -452,14 +200,13 @@ def _execute_stt(
)
return None, error_message
- # Push bytes straight to GCS — no disk I/O. fileData.fileUri bypasses
- # the 20 MB inline cap.
- if not self.client.sa_info:
+ # Vertex reads audio from GCS by gs:// URI, which bypasses the inline size cap.
+ if not self.client.sa_info or not self.client.gcs_bucket:
error_message = (
- "[KAAPI] Google GCP STT staging failed: ``google-gcp`` sa_key is "
- "not configured on this project's credentials, so audio "
- "cannot be uploaded to GCS for transcription. Add the "
- "service-account key to the project's ``google-gcp`` credentials."
+ "[KAAPI] Google GCP STT staging failed: the ``google-gcp`` "
+ "sa_key or gcs_bucket is not configured on this project's "
+ "credentials or in platform settings, so audio cannot be "
+ "uploaded to GCS for transcription."
)
logger.warning(
f"[GoogleGCPProvider._execute_stt] {error_message} | provider={provider}"
@@ -492,10 +239,9 @@ def _execute_stt(
instructions = params.get("instructions")
input_language = params.get("input_language") or "auto"
output_language = params.get("output_language")
- temperature = params.get("temperature")
max_output_tokens = params.get("max_output_tokens") or 2048
+ thinking_level = params.get("thinking_level") or "low"
- # Build transcription/translation instruction
if input_language == "auto":
lang_instruction = (
"Detect the spoken language automatically and transcribe the audio"
@@ -515,63 +261,59 @@ def _execute_stt(
else:
prompt = f"{lang_instruction}. {forced}"
- generation_config: dict[str, Any] = {"maxOutputTokens": max_output_tokens}
- if temperature is not None:
- generation_config["temperature"] = temperature
-
- payload = {
- "contents": [
- {
- "role": "user",
- "parts": [
- {"fileData": {"mimeType": mime_type, "fileUri": gs_uri}},
- {"text": prompt},
- ],
- }
- ],
- "generationConfig": generation_config,
- }
-
- data, err = self._post(
- model, payload, log_context=f"provider={provider}, type=stt"
+ interaction = GoogleAIProvider.create_interaction(
+ self.client.genai,
+ {
+ "model": model,
+ "input": [
+ {
+ "type": "user_input",
+ "content": [
+ {"type": "text", "text": prompt},
+ {"type": "audio", "uri": gs_uri, "mime_type": mime_type},
+ ],
+ }
+ ],
+ "generation_config": {
+ "thinking_level": thinking_level,
+ "max_output_tokens": max_output_tokens,
+ },
+ },
)
- if err:
- return None, err
- try:
- transcript = data["candidates"][0]["content"]["parts"][0]["text"]
- except (KeyError, IndexError, TypeError):
+ # Vertex may not persist interactions, so the id can come back empty.
+ response_id = interaction.id or f"google-gcp-{uuid.uuid4().hex}"
+ if interaction.status != "completed" or not interaction.output_text:
error_message = (
- "[GOOGLE_GCP] STT response is missing transcribed text. Google GCP "
- "returned a 200 response but the expected "
- "candidates[0].content.parts[0].text path is absent — this "
- "typically means the response was blocked by safety filters "
- "or truncated. Review the prompt and safety settings, then "
- "retry."
+ f"[GOOGLE_GCP] STT response is missing transcribed text "
+ f"(status={interaction.status}, errors={interaction.errors}). "
+ f"This typically means the response was blocked by safety "
+ f"filters or truncated. Review the prompt and safety settings, "
+ f"then retry."
)
logger.warning(
f"[GoogleGCPProvider._execute_stt] {error_message} | "
- f"provider={provider}, model={model}, response_id={data.get('responseId')}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=data.get("responseId")
- or f"google-gcp-{uuid.uuid4().hex}",
- model=data.get("modelVersion") or model,
+ provider_response_id=response_id,
+ model=interaction.model or model,
provider=provider,
output=TextOutput(
content=TextContent(
- value=transcript.strip(), language_code=output_language
+ value=interaction.output_text.strip(),
+ language_code=output_language,
)
),
),
- usage=self._extract_usage(data),
+ usage=GoogleAIProvider._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = data
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleGCPProvider._execute_stt] Transcribed audio | provider={provider}, model={model}"
@@ -584,13 +326,6 @@ def _execute_tts(
resolved_input: str,
include_provider_raw_response: bool = False,
) -> tuple[LLMCallResponse | None, str | None]:
- """Execute TTS via Google GCP generateContent.
-
- Note:
- HTTP / network errors come back from ``_post()`` already-logged
- and tagged. This method only handles Kaapi-side input validation,
- Google GCP response-shape checks, and audio post-processing failures.
- """
provider = completion_config.provider
params = completion_config.params
@@ -620,74 +355,62 @@ def _execute_tts(
language = params.get("language")
response_format = params.get("response_format") or "wav"
- speech_config: dict[str, Any] = {
- "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}
- }
- if language:
- speech_config["languageCode"] = language
- decorate_resolved_input = f"{resolved_input}"
- payload: dict[str, Any] = {
- "contents": [
- {"role": "user", "parts": [{"text": decorate_resolved_input}]}
- ],
- "generationConfig": {
- "responseModalities": ["AUDIO"],
- "speechConfig": speech_config,
- },
- }
-
provider_specific = params.get("provider_specific", {}) or {}
gemini_params = provider_specific.get("gemini", {}) or {}
director_notes = gemini_params.get("director_notes")
+
+ transcript: dict[str, Any] = {
+ "type": "text",
+ "text": f"{resolved_input}",
+ }
if director_notes:
- payload["systemInstruction"] = {"parts": [{"text": director_notes}]}
+ transcript["annotations"] = [
+ {"type": "speech_metadata", "style": director_notes}
+ ]
- data, err = self._post(
- model, payload, log_context=f"provider={provider}, type=tts"
+ speech_config: dict[str, Any] = {"voice": voice}
+ if language:
+ speech_config["language"] = language
+
+ interaction = GoogleAIProvider.create_interaction(
+ self.client.genai,
+ {
+ "model": model,
+ "input": [{"type": "user_input", "content": [transcript]}],
+ "generation_config": {"speech_config": [speech_config]},
+ "response_format": {"type": "audio"},
+ },
)
- if err:
- return None, err
- try:
- inline = data["candidates"][0]["content"]["parts"][0]["inlineData"]
- audio_b64 = inline["data"]
- except (KeyError, IndexError, TypeError):
+ response_id = interaction.id or f"google-gcp-{uuid.uuid4().hex}"
+ audio_b64 = interaction.output_audio.data if interaction.output_audio else None
+ if interaction.status != "completed" or not audio_b64:
error_message = (
- "[GOOGLE_GCP] TTS response is missing audio data. Google GCP returned "
- "a 200 response but the expected "
- "candidates[0].content.parts[0].inlineData path is absent — "
- "this typically means Google GCP was unable to generate audio "
- "from the input. Ensure the input text is properly formatted "
- "and does not contain unsupported control sequences."
+ f"[GOOGLE_GCP] TTS response is missing audio data (status="
+ f"{interaction.status}, errors={interaction.errors}). Google GCP "
+ f"was unable to generate audio from the input. Ensure the input "
+ f"text is properly formatted and does not contain unsupported "
+ f"control sequences."
)
logger.warning(
f"[GoogleGCPProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}, response_id={data.get('responseId')}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
- try:
- raw_pcm = base64.b64decode(audio_b64)
- except (ValueError, TypeError) as e:
- error_message = (
- f"[GOOGLE_GCP] TTS returned invalid base64 audio: {str(e)}. The "
- f"audio payload could not be decoded — this indicates a "
- f"corrupted response from Google GCP. Retry the request; if the "
- f"issue persists, contact Kaapi."
- )
- logger.warning(
- f"[GoogleGCPProvider._execute_tts] {error_message} | "
- f"provider={provider}, model={model}",
- exc_info=True,
- )
- return None, error_message
+ raw_pcm = base64.b64decode(audio_b64)
+ # Preview TTS models return headerless 24 kHz PCM while 3.8+ return a
+ # full WAV; normalise to PCM for pcm_to_wav / convert_pcm_to_*.
+ if raw_pcm.startswith(b"RIFF"):
+ with wave.open(io.BytesIO(raw_pcm), "rb") as wav_file:
+ raw_pcm = wav_file.readframes(wav_file.getnframes())
if not raw_pcm:
error_message = (
"[GOOGLE_GCP] TTS returned empty audio data. Google GCP accepted the "
- "request and returned a base64 payload that decoded to zero "
- "bytes — this is typically a Google GCP server-side issue. Wait "
- "a minute and retry; if the issue persists, contact Kaapi."
+ "request and returned a payload that decoded to zero bytes — "
+ "this is typically a Google GCP server-side issue. Wait a "
+ "minute and retry; if the issue persists, contact Kaapi."
)
logger.warning(
f"[GoogleGCPProvider._execute_tts] {error_message} | "
@@ -696,8 +419,7 @@ def _execute_tts(
return None, error_message
actual_format = "wav"
- wav_bytes = pcm_to_wav(raw_pcm)
- encoded_content = base64.b64encode(wav_bytes).decode("ascii")
+ encoded_content = base64.b64encode(pcm_to_wav(raw_pcm)).decode("ascii")
if response_format == "mp3":
converted, convert_err = convert_pcm_to_mp3(raw_pcm)
@@ -737,9 +459,8 @@ def _execute_tts(
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=data.get("responseId")
- or f"google-gcp-{uuid.uuid4().hex}",
- model=data.get("modelVersion") or model,
+ provider_response_id=response_id,
+ model=interaction.model or model,
provider=provider,
output=AudioOutput(
content=AudioContent(
@@ -749,11 +470,11 @@ def _execute_tts(
)
),
),
- usage=self._extract_usage(data),
+ usage=GoogleAIProvider._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = data
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleGCPProvider._execute_tts] Synthesised audio | "
@@ -762,103 +483,46 @@ def _execute_tts(
)
return llm_response, None
- @staticmethod
- def _format_parts_rest(parts: list[ContentPart]) -> list[dict[str, Any]]:
- """Map resolved content parts to REST generateContent ``parts`` (camelCase)."""
- items: list[dict[str, Any]] = []
- for part in parts:
- if isinstance(part, TextContent):
- items.append({"text": part.value})
- elif isinstance(part, (ImageContent, PDFContent)):
- if part.format == "base64":
- items.append(
- {"inlineData": {"data": part.value, "mimeType": part.mime_type}}
- )
- else:
- items.append(
- {
- "fileData": {
- "fileUri": part.value,
- "mimeType": part.mime_type,
- }
- }
- )
- return items
-
def _execute_text(
self,
completion_config: NativeCompletionConfig,
resolved_input: str | list[ContentPart] | MultiModalInput,
include_provider_raw_response: bool = False,
) -> tuple[LLMCallResponse | None, str | None]:
- """Execute a text completion via Google GCP generateContent.
-
- HTTP / network errors return pre-logged from ``_post()``; this method
- only handles payload building and response-shape validation.
- """
provider = completion_config.provider
params = completion_config.params
model = params.get("model") or DEFAULT_TEXT_MODELS["google-gcp"]
- if isinstance(resolved_input, MultiModalInput):
- parts = self._format_parts_rest(resolved_input.parts)
- elif isinstance(resolved_input, list):
- parts = self._format_parts_rest(resolved_input)
- else:
- parts = [{"text": resolved_input}]
-
- instructions = params.get("instructions")
- temperature = params.get("temperature")
- max_output_tokens = params.get("max_output_tokens")
-
- generation_config: dict[str, Any] = {}
- if temperature is not None:
- generation_config["temperature"] = temperature
- if max_output_tokens is not None:
- generation_config["maxOutputTokens"] = max_output_tokens
-
- payload: dict[str, Any] = {"contents": [{"role": "user", "parts": parts}]}
- if generation_config:
- payload["generationConfig"] = generation_config
- if instructions:
- payload["systemInstruction"] = {"parts": [{"text": instructions}]}
-
- data, err = self._post(
- model, payload, log_context=f"provider={provider}, type=text"
- )
- if err:
- return None, err
+ request = GoogleAIProvider.build_text_request(params, model, resolved_input)
+ interaction = GoogleAIProvider.create_interaction(self.client.genai, request)
- try:
- text = data["candidates"][0]["content"]["parts"][0]["text"]
- except (KeyError, IndexError, TypeError):
+ response_id = interaction.id or f"google-gcp-{uuid.uuid4().hex}"
+ if interaction.status != "completed" or not interaction.output_text:
error_message = (
- "[GOOGLE_GCP] Text response is missing generated content. Google "
- "GCP returned a 200 response but the expected "
- "candidates[0].content.parts[0].text path is absent — this "
- "typically means the response was blocked by safety filters or "
- "truncated by token limits. Review the prompt and safety "
- "settings, then retry."
+ f"[GOOGLE_GCP] Text response is missing generated content "
+ f"(status={interaction.status}, errors={interaction.errors}). "
+ f"This typically means the response was blocked by safety "
+ f"filters or truncated by token limits. Review the prompt and "
+ f"safety settings, then retry."
)
logger.warning(
f"[GoogleGCPProvider._execute_text] {error_message} | "
- f"provider={provider}, model={model}, response_id={data.get('responseId')}"
+ f"provider={provider}, model={model}, response_id={response_id}"
)
return None, error_message
llm_response = LLMCallResponse(
response=LLMResponse(
- provider_response_id=data.get("responseId")
- or f"google-gcp-{uuid.uuid4().hex}",
- model=data.get("modelVersion") or model,
+ provider_response_id=response_id,
+ model=interaction.model or model,
provider=provider,
- output=TextOutput(content=TextContent(value=text)),
+ output=TextOutput(content=TextContent(value=interaction.output_text)),
),
- usage=self._extract_usage(data),
+ usage=GoogleAIProvider._extract_usage(interaction, provider),
)
if include_provider_raw_response:
- llm_response.provider_raw_response = data
+ llm_response.provider_raw_response = interaction.model_dump(mode="json")
logger.info(
f"[GoogleGCPProvider._execute_text] Generated text | "
@@ -916,12 +580,102 @@ def execute(
)
return None, error_message
+ except interactions_errors.APIStatusError as e:
+ code = e.status_code
+ if code == 400:
+ error_message = (
+ f"[GOOGLE_GCP] Bad request (code: 400): {e.message}. Review "
+ f"your config parameters and input payload — the request "
+ f"shape, model, or content may be invalid for this Google "
+ f"GCP endpoint."
+ )
+ elif code in (401, 403):
+ error_message = (
+ f"[GOOGLE_GCP] Authentication / permission denied (code: "
+ f"{code}): {e.message}. Verify the Google GCP API key or "
+ f"service account is valid, the project_id and location are "
+ f"correct, and the account has access to the requested model."
+ )
+ elif code == 404:
+ error_message = (
+ f"[GOOGLE_GCP] Resource not found (code: 404): {e.message}. "
+ f"Check that the model exists and is available in your "
+ f"project and location."
+ )
+ elif code == 429:
+ error_message = (
+ f"[GOOGLE_GCP] Rate limit / quota exceeded (code: 429): "
+ f"{e.message}. You have hit Google GCP's per-minute or "
+ f"per-day quota for this model. Wait at least 1 minute and "
+ f"retry; if the issue persists, request a quota increase "
+ f"from Google or contact Kaapi."
+ )
+ elif code is not None and code >= 500:
+ error_message = (
+ f"[GOOGLE_GCP] Server error (code: {code}): {e.message}. "
+ f"This is typically transient (Google GCP overloaded or "
+ f"internal error) — retry in a few seconds. If the issue "
+ f"persists, contact Kaapi."
+ )
+ else:
+ error_message = (
+ f"[GOOGLE_GCP] HTTP error (code: {code}): {e.message}. If "
+ f"the issue persists, contact Kaapi."
+ )
+ # 5xx is escalation-worthy; 4xx (including 429) is the caller's.
+ log = logger.error if code is not None and code >= 500 else logger.warning
+ log(
+ f"[GoogleGCPProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}"
+ )
+ return None, error_message
+
+ except interactions_errors.APITimeoutError as e:
+ error_message = (
+ f"[KAAPI] Google GCP request timed out after "
+ f"{REQUEST_TIMEOUT_MS // 1000}s ({type(e).__name__}): {e}. "
+ f"Retry with a smaller payload or contact Kaapi if the issue "
+ f"persists."
+ )
+ logger.error(
+ f"[GoogleGCPProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}",
+ exc_info=True,
+ )
+ return None, error_message
+
+ except interactions_errors.APIConnectionError as e:
+ error_message = (
+ f"[KAAPI] Google GCP connection failed ({type(e).__name__}): "
+ f"{e}. Network or DNS issue reaching Google GCP — check network "
+ f"connectivity from the Kaapi backend. If the issue persists, "
+ f"contact Kaapi."
+ )
+ logger.error(
+ f"[GoogleGCPProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}",
+ exc_info=True,
+ )
+ return None, error_message
+
+ except google_auth_errors.GoogleAuthError as e:
+ # Raised while minting the SA access token, before any HTTP status.
+ error_message = (
+ f"[GOOGLE_GCP] Authentication failed ({type(e).__name__}): {e}. "
+ f"Verify the service-account key is valid and not revoked."
+ )
+ logger.warning(
+ f"[GoogleGCPProvider.execute] {error_message} | "
+ f"provider={provider}, type={completion_type}"
+ )
+ return None, error_message
+
except Exception as e:
error_message = (
f"[KAAPI] Unexpected error while executing Google GCP "
f"{completion_type or 'request'}: {str(e)}. This was not "
- f"raised inside the Google GCP HTTP call — likely a Kaapi-side "
- f"failure. Contact Kaapi if the issue persists."
+ f"raised by the Google GCP API — likely a Kaapi-side failure. "
+ f"Contact Kaapi if the issue persists."
)
logger.error(
f"[GoogleGCPProvider.execute] {error_message} | "
diff --git a/backend/app/tests/models/llm/test_request.py b/backend/app/tests/models/llm/test_request.py
index 18b5a974c..05a169a88 100644
--- a/backend/app/tests/models/llm/test_request.py
+++ b/backend/app/tests/models/llm/test_request.py
@@ -7,6 +7,7 @@
ConfigBlob,
InlineValidator,
NativeCompletionConfig,
+ TextLLMParams,
Validator,
build_kaapi_completion_config,
)
@@ -80,6 +81,16 @@ def test_unset_temperature_survives_json_round_trip(self) -> None:
)
+class TestTextLLMParamsThinkingLevel:
+ @pytest.mark.parametrize("level", ["minimal", "low", "medium", "high"])
+ def test_accepts_gemini_thinking_levels(self, level: str) -> None:
+ assert TextLLMParams(thinking_level=level).thinking_level == level
+
+ def test_rejects_unknown_thinking_level(self) -> None:
+ with pytest.raises(ValidationError):
+ TextLLMParams(thinking_level="extreme")
+
+
def _blob(input_guardrails: list) -> ConfigBlob:
return ConfigBlob(
completion=NativeCompletionConfig(
diff --git a/backend/app/tests/services/llm/providers/test_eleven_ai.py b/backend/app/tests/services/llm/providers/test_eleven_ai.py
index 8ad4eaedd..7e6eadf67 100644
--- a/backend/app/tests/services/llm/providers/test_eleven_ai.py
+++ b/backend/app/tests/services/llm/providers/test_eleven_ai.py
@@ -11,7 +11,7 @@
NativeCompletionConfig,
QueryParams,
)
-from app.models.llm.constants import CompletionType
+from app.models.llm.constants import CompletionType, DEFAULT_ELEVENLABS_TTS_MODEL
from app.services.llm.providers.eleven_ai import ElevenlabsAIProvider
@@ -376,7 +376,7 @@ def test_tts_uses_default_model_when_missing(
assert result is not None
# Verify the default model was used
call_kwargs = mock_client.text_to_speech.convert.call_args.kwargs
- assert call_kwargs["model_id"] == "eleven_v3"
+ assert call_kwargs["model_id"] == DEFAULT_ELEVENLABS_TTS_MODEL
def test_tts_uses_default_voice_when_missing(
self, provider, mock_client, query_params
diff --git a/backend/app/tests/services/llm/providers/test_google_aistudio.py b/backend/app/tests/services/llm/providers/test_google_aistudio.py
index 0b44fb428..f98ed69f3 100644
--- a/backend/app/tests/services/llm/providers/test_google_aistudio.py
+++ b/backend/app/tests/services/llm/providers/test_google_aistudio.py
@@ -1,355 +1,714 @@
-"""
-Tests for the Google AI provider (STT and TTS).
-"""
-
import base64
+from typing import Any
+from unittest.mock import MagicMock, patch
+import httpx
import pytest
-from unittest.mock import MagicMock, patch
-from types import SimpleNamespace
+from google.genai._gaos.lib import compat_errors
+from google.genai.interactions import Interaction
-from app.models.llm import (
- NativeCompletionConfig,
- QueryParams,
-)
+from app.core.audio_utils import AudioRef, pcm_to_wav
+from app.models.llm import NativeCompletionConfig, QueryParams, TextContent
from app.models.llm.constants import CompletionType
from app.services.llm.providers.google_aistudio import GoogleAIProvider
+SAMPLE_PCM_BYTES = b"\x00\x01" * 1000
+GEMINI_REQUEST = httpx.Request("POST", "https://generativelanguage.googleapis.com")
+
+
+def make_interaction(
+ *,
+ text: str | None = "Hello world",
+ audio: bytes | None = None,
+ interaction_id: str | None = "int_123",
+ model: str | None = "gemini-2.5-pro",
+ status: str = "completed",
+ errors: list[dict[str, str]] | None = None,
+ usage: dict[str, int] | None = None,
+) -> Interaction:
+ """Build a real SDK Interaction from a wire-shaped payload.
+
+ The SDK derives output_text / output_audio from the model_output step, so
+ going through model_validate keeps the tests honest about those accessors.
+ """
+ content: list[dict[str, Any]] = []
+ if text is not None:
+ content.append({"type": "text", "text": text})
+ if audio is not None:
+ content.append(
+ {
+ "type": "audio",
+ "data": base64.b64encode(audio).decode("ascii"),
+ "mime_type": "audio/l16",
+ }
+ )
+ payload: dict[str, Any] = {
+ "status": status,
+ "model": model,
+ "steps": [{"type": "model_output", "content": content}],
+ "usage": usage
+ if usage is not None
+ else {
+ "total_input_tokens": 50,
+ "total_output_tokens": 100,
+ "total_thought_tokens": 7,
+ "total_tokens": 157,
+ },
+ }
+ if interaction_id is not None:
+ payload["id"] = interaction_id
+ if errors is not None:
+ payload["errors"] = errors
+ return Interaction.model_validate(payload)
-def mock_google_response(
- text: str = "Transcribed text",
- model: str = "gemini-2.5-pro",
- response_id: str = "resp_123",
-) -> SimpleNamespace:
- """Create a mock Google AI response object."""
- usage = SimpleNamespace(
- prompt_token_count=50,
- candidates_token_count=100,
- total_token_count=150,
- thoughts_token_count=0,
- )
- response = SimpleNamespace(
- response_id=response_id,
- model_version=model,
- text=text,
- usage_metadata=usage,
- model_dump=lambda: {
- "response_id": response_id,
- "model_version": model,
- "text": text,
- "usage_metadata": {
- "prompt_token_count": 50,
- "candidates_token_count": 100,
- "total_token_count": 150,
- "thoughts_token_count": 0,
- },
- },
- )
- return response
+def create_kwargs(mock_client: MagicMock) -> dict[str, Any]:
+ mock_client.interactions.create.assert_called_once()
+ return mock_client.interactions.create.call_args.kwargs
-class TestGoogleAIProviderSTT:
- """Test cases for GoogleAIProvider STT functionality."""
+@pytest.fixture
+def mock_client() -> MagicMock:
+ return MagicMock()
+
+
+@pytest.fixture
+def provider(mock_client: MagicMock) -> GoogleAIProvider:
+ return GoogleAIProvider(client=mock_client)
+
+@pytest.fixture
+def query_params() -> QueryParams:
+ return QueryParams(input="Hello there")
+
+
+class TestGoogleAIProviderText:
@pytest.fixture
- def mock_client(self):
- """Create a mock Google AI client."""
- client = MagicMock()
- # Mock file upload
- mock_file = MagicMock()
- mock_file.name = "test_audio.wav"
- client.files.upload.return_value = mock_file
- return client
+ def text_config(self) -> NativeCompletionConfig:
+ return NativeCompletionConfig(
+ provider="google-native",
+ type=CompletionType.TEXT,
+ params={"model": "gemini-2.5-pro"},
+ )
+
+ def test_text_happy_path(self, provider, mock_client, text_config, query_params):
+ mock_client.interactions.create.return_value = make_interaction(text="Hi!")
+
+ result, error = provider.execute(text_config, query_params, "Hello there")
+
+ assert error is None
+ assert result.response.output.content.value == "Hi!"
+ assert result.response.provider_response_id == "int_123"
+ assert result.response.model == "gemini-2.5-pro"
+ assert result.usage.input_tokens == 50
+ assert result.usage.output_tokens == 100
+ assert result.usage.reasoning_tokens == 7
+ assert result.usage.total_tokens == 157
+ kwargs = create_kwargs(mock_client)
+ assert kwargs["model"] == "gemini-2.5-pro"
+ assert kwargs["input"] == [
+ {"type": "user_input", "content": [{"type": "text", "text": "Hello there"}]}
+ ]
+
+ def test_text_instructions_become_system_instruction(
+ self, provider, mock_client, text_config, query_params
+ ):
+ text_config.params["instructions"] = "Be terse."
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(text_config, query_params, "Ping")
+
+ assert create_kwargs(mock_client)["system_instruction"] == "Be terse."
+
+ def test_text_minimal_request_has_no_optional_fields(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(text_config, query_params, "Ping")
+
+ assert set(create_kwargs(mock_client)) == {"model", "input"}
+
+ def test_text_output_schema_forwarded_as_json_response_format(
+ self, provider, mock_client, text_config, query_params
+ ):
+ schema = {"type": "OBJECT", "properties": {"band": {"type": "STRING"}}}
+ text_config.params["output_schema"] = schema
+ mock_client.interactions.create.return_value = make_interaction(
+ text='{"band": "high"}'
+ )
+
+ result, error = provider.execute(text_config, query_params, "Ping")
+
+ assert error is None
+ kwargs = create_kwargs(mock_client)
+ assert kwargs["response_format"] == schema
+ assert kwargs["response_mime_type"] == "application/json"
+
+ def test_text_non_interaction_return_is_reported_as_error(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = iter(())
+
+ result, error = provider.execute(text_config, query_params, "Ping")
+
+ assert result is None
+ assert "[KAAPI] Unexpected error" in error
+
+ def test_text_thinking_config_takes_precedence_over_reasoning(
+ self, provider, mock_client, text_config, query_params
+ ):
+ text_config.params["thinking_config"] = {"thinking_level": "high"}
+ text_config.params["reasoning"] = "low"
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(text_config, query_params, "Ping")
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "thinking_level": "high"
+ }
+
+ def test_text_reasoning_used_as_thinking_level_fallback(
+ self, provider, mock_client, text_config, query_params
+ ):
+ text_config.params["reasoning"] = "low"
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(text_config, query_params, "Ping")
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "thinking_level": "low"
+ }
+
+ def test_text_max_output_tokens_in_generation_config(
+ self, provider, mock_client, text_config, query_params
+ ):
+ text_config.params["max_output_tokens"] = 256
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(text_config, query_params, "Ping")
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "max_output_tokens": 256
+ }
+
+ def test_text_sampling_params_not_sent(
+ self, provider, mock_client, text_config, query_params
+ ):
+ text_config.params["temperature"] = 0.2
+ text_config.params["top_p"] = 0.9
+ mock_client.interactions.create.return_value = make_interaction()
+
+ result, error = provider.execute(text_config, query_params, "Ping")
+
+ assert error is None
+ assert set(create_kwargs(mock_client)) == {"model", "input"}
+
+ def test_text_list_of_content_parts(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction()
+
+ result, error = provider.execute(
+ text_config, query_params, [TextContent(value="part one")]
+ )
+
+ assert error is None
+ assert create_kwargs(mock_client)["input"] == [
+ {"type": "user_input", "content": [{"type": "text", "text": "part one"}]}
+ ]
+
+ def test_text_knowledge_base_ids_set_file_search_tool(
+ self, provider, mock_client, text_config, query_params
+ ):
+ store_names = ["fileSearchStores/store-1", "fileSearchStores/store-2"]
+ text_config.params["knowledge_base_ids"] = store_names
+ mock_client.interactions.create.return_value = make_interaction()
+
+ result, error = provider.execute(text_config, query_params, "Ask the KB")
+ assert error is None
+ assert create_kwargs(mock_client)["tools"] == [
+ {"type": "file_search", "file_search_store_names": store_names}
+ ]
+
+ def test_text_missing_model_uses_default(self, provider, mock_client, query_params):
+ config = NativeCompletionConfig(
+ provider="google-native", type=CompletionType.TEXT, params={}
+ )
+ mock_client.interactions.create.return_value = make_interaction(model=None)
+
+ result, error = provider.execute(config, query_params, "Hi")
+
+ assert error is None
+ assert create_kwargs(mock_client)["model"] == "gemini-2.5-pro"
+ assert result.response.model == "gemini-2.5-pro"
+
+ def test_text_include_provider_raw_response(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(text="raw")
+
+ result, error = provider.execute(
+ text_config, query_params, "Hi", include_provider_raw_response=True
+ )
+
+ assert error is None
+ assert result.provider_raw_response["id"] == "int_123"
+ assert result.provider_raw_response["output_text"] == "raw"
+
+ def test_text_missing_usage_defaults_to_zeros(
+ self, provider, mock_client, text_config, query_params
+ ):
+ interaction = make_interaction()
+ interaction.usage = None
+ mock_client.interactions.create.return_value = interaction
+
+ result, error = provider.execute(text_config, query_params, "Hi")
+
+ assert error is None
+ assert result.usage.input_tokens == 0
+ assert result.usage.output_tokens == 0
+ assert result.usage.total_tokens == 0
+ assert result.usage.reasoning_tokens == 0
+
+ def test_text_missing_interaction_id_returns_error(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(
+ interaction_id=None
+ )
+
+ result, error = provider.execute(text_config, query_params, "Hi")
+
+ assert result is None
+ assert error.startswith("[GEMINI] Text interaction did not complete")
+
+ @pytest.mark.parametrize("status", ["failed", "incomplete"])
+ def test_text_non_completed_status_returns_error(
+ self, provider, mock_client, text_config, query_params, status
+ ):
+ mock_client.interactions.create.return_value = make_interaction(
+ status=status,
+ errors=[{"code": "SAFETY", "message": "blocked by policy"}],
+ )
+
+ result, error = provider.execute(text_config, query_params, "Hi")
+
+ assert result is None
+ assert error.startswith("[GEMINI]")
+ assert f"status={status}" in error
+ assert "SAFETY" in error
+ assert "blocked by policy" in error
+
+ def test_text_empty_output_text_returns_error(
+ self, provider, mock_client, text_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(text=None)
+
+ result, error = provider.execute(text_config, query_params, "Hi")
+
+ assert result is None
+ assert error.startswith("[GEMINI]")
+ assert "missing generated content" in error
+
+
+class TestGoogleAIProviderSTT:
@pytest.fixture
- def provider(self, mock_client):
- """Create a GoogleAIProvider instance with mock client."""
- return GoogleAIProvider(client=mock_client)
+ def mock_client(self) -> MagicMock:
+ client = MagicMock()
+ uploaded = MagicMock()
+ uploaded.name = "files/abc"
+ uploaded.uri = "https://generativelanguage.googleapis.com/v1beta/files/abc"
+ uploaded.mime_type = "audio/wav"
+ client.files.upload.return_value = uploaded
+ return client
@pytest.fixture
- def stt_config(self):
- """Create a basic STT completion config."""
+ def stt_config(self) -> NativeCompletionConfig:
return NativeCompletionConfig(
provider="google-aistudio-native",
type=CompletionType.STT,
- params={
- "model": "gemini-2.5-pro",
- },
+ params={"model": "gemini-2.5-pro"},
)
@pytest.fixture
- def audio_ref(self):
- from app.core.audio_utils import AudioRef
-
+ def audio_ref(self) -> AudioRef:
return AudioRef(bytes_=b"fake audio data", mime_type="audio/wav")
- @pytest.fixture
- def query_params(self):
- """Create basic query parameters."""
- return QueryParams(input="Test audio input")
+ @staticmethod
+ def _prompt(mock_client: MagicMock) -> str:
+ content = create_kwargs(mock_client)["input"][0]["content"]
+ return content[0]["text"]
def test_stt_success_with_auto_language(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test successful STT execution with auto language detection."""
- mock_response = mock_google_response(text="Hello world")
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = make_interaction(
+ text="Hello world"
+ )
result, error = provider.execute(stt_config, query_params, audio_ref)
assert error is None
- assert result is not None
assert result.response.output.content.value == "Hello world"
+ assert result.response.provider_response_id == "int_123"
assert result.response.model == "gemini-2.5-pro"
assert result.response.provider == "google-aistudio-native"
assert result.usage.input_tokens == 50
assert result.usage.output_tokens == 100
- assert result.usage.total_tokens == 150
-
- # Verify file upload was called with a materialized temp path matching the AudioRef mime.
- mock_client.files.upload.assert_called_once()
+ assert result.usage.total_tokens == 157
uploaded_path = mock_client.files.upload.call_args.kwargs["file"]
assert uploaded_path.endswith(".wav")
- mock_client.models.generate_content.assert_called_once()
+ assert "Detect the spoken language automatically" in self._prompt(mock_client)
+
+ def test_stt_references_uploaded_file_uri(
+ self, provider, mock_client, stt_config, query_params, audio_ref
+ ):
+ mock_client.interactions.create.return_value = make_interaction()
- # Verify instruction contains auto-detect
- call_args = mock_client.models.generate_content.call_args
- assert "Detect the spoken language automatically" in call_args[1]["contents"][0]
+ provider.execute(stt_config, query_params, audio_ref)
- def test_stt_with_specific_input_language(
+ kwargs = create_kwargs(mock_client)
+ assert kwargs["model"] == "gemini-2.5-pro"
+ assert kwargs["input"][0]["type"] == "user_input"
+ assert kwargs["input"][0]["content"][1] == {
+ "type": "audio",
+ "uri": "https://generativelanguage.googleapis.com/v1beta/files/abc",
+ "mime_type": "audio/wav",
+ }
+
+ def test_stt_defaults_thinking_level_to_low(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test STT with specific input language."""
- stt_config.params["input_language"] = "English"
+ mock_client.interactions.create.return_value = make_interaction()
- mock_response = mock_google_response(text="Transcribed English text")
- mock_client.models.generate_content.return_value = mock_response
+ provider.execute(stt_config, query_params, audio_ref)
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "thinking_level": "low"
+ }
+
+ def test_stt_explicit_thinking_level_overrides_default(
+ self, provider, mock_client, stt_config, query_params, audio_ref
+ ):
+ stt_config.params["thinking_level"] = "high"
+ mock_client.interactions.create.return_value = make_interaction()
+
+ provider.execute(stt_config, query_params, audio_ref)
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "thinking_level": "high"
+ }
+
+ def test_stt_sampling_params_not_sent(
+ self, provider, mock_client, stt_config, query_params, audio_ref
+ ):
+ stt_config.params["temperature"] = 0.4
+ stt_config.params["top_p"] = 0.9
+ mock_client.interactions.create.return_value = make_interaction()
result, error = provider.execute(stt_config, query_params, audio_ref)
assert error is None
- assert result is not None
+ kwargs = create_kwargs(mock_client)
+ assert set(kwargs) == {"model", "input", "generation_config"}
+ assert kwargs["generation_config"] == {"thinking_level": "low"}
- # Verify instruction contains specific language
- call_args = mock_client.models.generate_content.call_args
- assert "Transcribe the audio from English" in call_args[1]["contents"][0]
+ def test_stt_with_specific_input_language(
+ self, provider, mock_client, stt_config, query_params, audio_ref
+ ):
+ stt_config.params["input_language"] = "English"
+ mock_client.interactions.create.return_value = make_interaction()
+
+ result, error = provider.execute(stt_config, query_params, audio_ref)
+
+ assert error is None
+ assert "Transcribe the audio from English" in self._prompt(mock_client)
def test_stt_with_translation(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test STT with translation to different output language."""
stt_config.params["input_language"] = "Spanish"
stt_config.params["output_language"] = "English"
-
- mock_response = mock_google_response(text="Translated text")
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = make_interaction()
result, error = provider.execute(stt_config, query_params, audio_ref)
assert error is None
- assert result is not None
-
- # Verify instruction contains translation
- call_args = mock_client.models.generate_content.call_args
- instruction = call_args[1]["contents"][0]
- assert "Transcribe the audio from Spanish" in instruction
- assert "translate to English" in instruction
+ prompt = self._prompt(mock_client)
+ assert "Transcribe the audio from Spanish" in prompt
+ assert "translate to English" in prompt
+ assert result.response.output.content.language_code == "English"
def test_stt_with_custom_instructions(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test STT with custom instructions."""
stt_config.params["instructions"] = "Include timestamps"
-
- mock_response = mock_google_response(text="Transcribed with timestamps")
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = make_interaction()
result, error = provider.execute(stt_config, query_params, audio_ref)
assert error is None
- assert result is not None
-
- # Verify custom instructions are included
- call_args = mock_client.models.generate_content.call_args
- instruction = call_args[1]["contents"][0]
- assert "Include timestamps" in instruction
+ assert self._prompt(mock_client).startswith("Include timestamps. ")
- def test_stt_with_type_error(
+ def test_stt_empty_output_text_returns_error(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test handling of TypeError (invalid parameters)."""
- mock_client.models.generate_content.side_effect = TypeError(
- "unexpected keyword argument 'invalid_param'"
- )
+ mock_client.interactions.create.return_value = make_interaction(text=None)
result, error = provider.execute(stt_config, query_params, audio_ref)
assert result is None
- assert error is not None
- assert "Invalid or unexpected parameter in Config" in error
+ assert error.startswith("[GEMINI]")
+ assert "missing transcribed text" in error
- def test_stt_with_generic_exception(
+ def test_stt_failed_status_returns_error(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test handling of unexpected exceptions."""
- mock_client.files.upload.side_effect = Exception("File upload failed")
+ mock_client.interactions.create.return_value = make_interaction(status="failed")
result, error = provider.execute(stt_config, query_params, audio_ref)
assert result is None
- assert error is not None
- assert "[KAAPI] Unexpected error" in error
+ assert "STT interaction did not complete (status=failed" in error
- def test_stt_with_invalid_input_type(
- self, provider, mock_client, stt_config, query_params
+ def test_stt_missing_interaction_id_returns_error(
+ self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test STT execution with invalid input type (non-string)."""
- # Pass a dict instead of a string path
- invalid_input = {"invalid": "data"}
+ mock_client.interactions.create.return_value = make_interaction(
+ interaction_id=None
+ )
- result, error = provider.execute(stt_config, query_params, invalid_input)
+ result, error = provider.execute(stt_config, query_params, audio_ref)
assert result is None
- assert error is not None
- assert "STT requires an AudioRef" in error
+ assert error.startswith("[GEMINI] STT interaction did not complete")
- def test_stt_with_valid_audio_ref(
+ def test_stt_type_error(
self, provider, mock_client, stt_config, query_params, audio_ref
):
- """Test STT execution with valid file path string."""
- mock_response = mock_google_response(text="Valid transcription")
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.side_effect = TypeError(
+ "unexpected keyword argument 'invalid_param'"
+ )
result, error = provider.execute(stt_config, query_params, audio_ref)
- assert error is None
- assert result is not None
- assert result.response.output.content.value == "Valid transcription"
-
-
-def mock_tts_google_response(
- audio_bytes: bytes = b"\x00\x01\x02\x03",
- model: str = "gemini-2.5-pro-preview-tts",
- response_id: str = "resp_tts_123",
-) -> SimpleNamespace:
- """Create a mock Google AI TTS response object with audio data."""
- usage = SimpleNamespace(
- prompt_token_count=10,
- candidates_token_count=0,
- total_token_count=10,
- thoughts_token_count=0,
- )
-
- inline_data = SimpleNamespace(data=audio_bytes)
- part = SimpleNamespace(inline_data=inline_data)
- content = SimpleNamespace(parts=[part])
- candidate = SimpleNamespace(content=content)
-
- response = SimpleNamespace(
- response_id=response_id,
- model_version=model,
- candidates=[candidate],
- usage_metadata=usage,
- model_dump=lambda: {
- "response_id": response_id,
- "model_version": model,
- "usage_metadata": {
- "prompt_token_count": 10,
- "candidates_token_count": 0,
- "total_token_count": 10,
- "thoughts_token_count": 0,
- },
- },
- )
- return response
+ assert result is None
+ assert "Invalid or unexpected parameter in Config" in error
+ def test_stt_generic_exception(
+ self, provider, mock_client, stt_config, query_params, audio_ref
+ ):
+ mock_client.files.upload.side_effect = Exception("File upload failed")
-SAMPLE_PCM_BYTES = b"\x00\x01" * 1000
+ result, error = provider.execute(stt_config, query_params, audio_ref)
+ assert result is None
+ assert "[KAAPI] Unexpected error" in error
-class TestGoogleAIProviderTTS:
- """Test cases for GoogleAIProvider TTS functionality."""
+ def test_stt_invalid_input_type(
+ self, provider, mock_client, stt_config, query_params
+ ):
+ result, error = provider.execute(stt_config, query_params, {"invalid": "data"})
- @pytest.fixture
- def mock_client(self):
- """Create a mock Google AI client."""
- return MagicMock()
+ assert result is None
+ assert "STT requires an AudioRef" in error
+ mock_client.interactions.create.assert_not_called()
- @pytest.fixture
- def provider(self, mock_client):
- """Create a GoogleAIProvider instance with mock client."""
- return GoogleAIProvider(client=mock_client)
+class TestGoogleAIProviderTTS:
@pytest.fixture
- def tts_config(self):
- """Create a basic TTS completion config."""
+ def tts_config(self) -> NativeCompletionConfig:
return NativeCompletionConfig(
provider="google-aistudio-native",
type=CompletionType.TTS,
params={
"model": "gemini-2.5-pro-preview-tts",
"voice": "Kore",
- "language": "en-US",
+ "language": "en-IN",
},
)
@pytest.fixture
- def query_params(self):
- """Create basic query parameters."""
- return QueryParams(input="Hello world")
+ def pcm_interaction(self) -> Interaction:
+ return make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, model="gemini-2.5-pro-preview-tts"
+ )
+
+ def test_tts_request_shape(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ result, error = provider.execute(tts_config, query_params, "Say this text")
+
+ assert error is None
+ kwargs = create_kwargs(mock_client)
+ assert kwargs["model"] == "gemini-2.5-pro-preview-tts"
+ assert kwargs["input"] == [
+ {
+ "type": "user_input",
+ "content": [
+ {"type": "text", "text": "Say this text"}
+ ],
+ }
+ ]
+ assert kwargs["response_format"] == {"type": "audio"}
+
+ def test_tts_passes_full_bcp47_language(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ provider.execute(tts_config, query_params, "Hello")
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "speech_config": [{"voice": "Kore", "language": "en-IN"}]
+ }
+
+ def test_tts_omits_language_when_unset(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ del tts_config.params["language"]
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ provider.execute(tts_config, query_params, "Hello")
+
+ assert create_kwargs(mock_client)["generation_config"] == {
+ "speech_config": [{"voice": "Kore"}]
+ }
+
+ def test_tts_director_notes_become_style_annotation(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ tts_config.params["provider_specific"] = {
+ "gemini": {"director_notes": "Speak in a cheerful tone"}
+ }
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert error is None
+ transcript = create_kwargs(mock_client)["input"][0]["content"][0]
+ assert transcript["annotations"] == [
+ {"type": "speech_metadata", "style": "Speak in a cheerful tone"}
+ ]
+ assert "system_instruction" not in create_kwargs(mock_client)
+
+ def test_tts_without_director_notes_has_no_annotations(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ provider.execute(tts_config, query_params, "Hello")
+
+ transcript = create_kwargs(mock_client)["input"][0]["content"][0]
+ assert "annotations" not in transcript
- def test_tts_success_mp3_format(
+ def test_tts_default_wav_wraps_pcm_once(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert error is None
+ assert result.response.output.content.mime_type == "audio/wav"
+ decoded = base64.b64decode(result.response.output.content.value)
+ assert decoded == pcm_to_wav(SAMPLE_PCM_BYTES, sample_rate=24000)
+
+ def test_tts_wav_returned_by_server_is_not_double_wrapped(
+ self, provider, mock_client, tts_config, query_params
+ ):
+ # 3.8 TTS models reply with a full WAV rather than raw PCM.
+ server_wav = pcm_to_wav(SAMPLE_PCM_BYTES, sample_rate=24000)
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=server_wav
+ )
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert error is None
+ decoded = base64.b64decode(result.response.output.content.value)
+ assert decoded == pcm_to_wav(SAMPLE_PCM_BYTES, sample_rate=24000)
+ assert decoded.count(b"RIFF") == 1
+
+ def test_tts_wav_returned_by_server_is_unwrapped_before_mp3(
self, provider, mock_client, tts_config, query_params
):
- """Test TTS with MP3 response format conversion."""
tts_config.params["response_format"] = "mp3"
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ server_wav = pcm_to_wav(SAMPLE_PCM_BYTES, sample_rate=24000)
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=server_wav
+ )
+
+ with patch(
+ "app.services.llm.providers.google_aistudio.convert_pcm_to_mp3",
+ return_value=(b"fake-mp3", None),
+ ) as mock_convert:
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert error is None
+ # ffmpeg conversion is a local external process; mocked to check its input.
+ assert mock_convert.call_args.args[0] == SAMPLE_PCM_BYTES
+
+ def test_tts_mp3_format(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ tts_config.params["response_format"] = "mp3"
+ mock_client.interactions.create.return_value = pcm_interaction
- fake_mp3_bytes = b"fake-mp3-content"
with patch(
"app.services.llm.providers.google_aistudio.convert_pcm_to_mp3",
- return_value=(fake_mp3_bytes, None),
+ return_value=(b"fake-mp3-content", None),
) as mock_convert:
result, error = provider.execute(tts_config, query_params, "Hello world")
assert error is None
- assert result is not None
assert result.response.output.content.mime_type == "audio/mp3"
- decoded = base64.b64decode(result.response.output.content.value)
- assert decoded == fake_mp3_bytes
+ assert base64.b64decode(result.response.output.content.value) == (
+ b"fake-mp3-content"
+ )
mock_convert.assert_called_once_with(SAMPLE_PCM_BYTES)
- def test_tts_success_ogg_format(
- self, provider, mock_client, tts_config, query_params
+ def test_tts_ogg_format(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test TTS with OGG response format conversion."""
tts_config.params["response_format"] = "ogg"
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = pcm_interaction
- fake_ogg_bytes = b"fake-ogg-content"
with patch(
"app.services.llm.providers.google_aistudio.convert_pcm_to_ogg",
- return_value=(fake_ogg_bytes, None),
+ return_value=(b"fake-ogg-content", None),
) as mock_convert:
result, error = provider.execute(tts_config, query_params, "Hello world")
assert error is None
- assert result is not None
assert result.response.output.content.mime_type == "audio/ogg"
- decoded = base64.b64decode(result.response.output.content.value)
- assert decoded == fake_ogg_bytes
+ assert base64.b64decode(result.response.output.content.value) == (
+ b"fake-ogg-content"
+ )
mock_convert.assert_called_once_with(SAMPLE_PCM_BYTES)
+ def test_tts_unsupported_format_falls_back_to_wav(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
+ ):
+ tts_config.params["response_format"] = "flac"
+ mock_client.interactions.create.return_value = pcm_interaction
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert error is None
+ assert result.response.output.content.mime_type == "audio/wav"
+ decoded = base64.b64decode(result.response.output.content.value)
+ assert decoded.startswith(b"RIFF")
+
def test_tts_mp3_conversion_failure(
- self, provider, mock_client, tts_config, query_params
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test error when MP3 conversion fails."""
tts_config.params["response_format"] = "mp3"
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = pcm_interaction
with patch(
"app.services.llm.providers.google_aistudio.convert_pcm_to_mp3",
@@ -362,12 +721,10 @@ def test_tts_mp3_conversion_failure(
assert "ffmpeg not found" in error
def test_tts_ogg_conversion_failure(
- self, provider, mock_client, tts_config, query_params
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test error when OGG conversion fails."""
tts_config.params["response_format"] = "ogg"
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = pcm_interaction
with patch(
"app.services.llm.providers.google_aistudio.convert_pcm_to_ogg",
@@ -378,280 +735,236 @@ def test_tts_ogg_conversion_failure(
assert result is None
assert "unable to convert Gemini PCM audio to OGG" in error
+ def test_tts_missing_output_audio_returns_error(
+ self, provider, mock_client, tts_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(text="oops")
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert result is None
+ assert error.startswith("[GEMINI] Failed to extract audio bytes")
+
+ def test_tts_incomplete_status_returns_error(
+ self, provider, mock_client, tts_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, status="incomplete"
+ )
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert result is None
+ assert "TTS interaction did not complete (status=incomplete" in error
+
+ def test_tts_missing_interaction_id_returns_error(
+ self, provider, mock_client, tts_config, query_params
+ ):
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, interaction_id=None
+ )
+
+ result, error = provider.execute(tts_config, query_params, "Hello")
+
+ assert result is None
+ assert error.startswith("[GEMINI] TTS interaction did not complete")
+
def test_tts_empty_input(self, provider, mock_client, tts_config, query_params):
- """Test error when text input is empty."""
result, error = provider.execute(tts_config, query_params, " ")
assert result is None
assert "text input is empty" in error
- mock_client.models.generate_content.assert_not_called()
+ mock_client.interactions.create.assert_not_called()
def test_tts_non_string_input(
self, provider, mock_client, tts_config, query_params
):
- """Test error when input is not a string."""
result, error = provider.execute(tts_config, query_params, {"invalid": "data"})
assert result is None
assert "TTS requires a text string" in error
- def test_tts_no_usage_metadata(
- self, provider, mock_client, tts_config, query_params
+ def test_tts_missing_usage_defaults_to_zeros(
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test TTS response when usage_metadata is None (defaults to zeros)."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_response.usage_metadata = None
- mock_client.models.generate_content.return_value = mock_response
+ pcm_interaction.usage = None
+ mock_client.interactions.create.return_value = pcm_interaction
result, error = provider.execute(tts_config, query_params, "Hello")
assert error is None
- assert result is not None
assert result.usage.input_tokens == 0
assert result.usage.output_tokens == 0
assert result.usage.total_tokens == 0
assert result.usage.reasoning_tokens == 0
def test_tts_include_provider_raw_response(
- self, provider, mock_client, tts_config, query_params
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test TTS with include_provider_raw_response=True."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = pcm_interaction
result, error = provider.execute(
tts_config, query_params, "Hello", include_provider_raw_response=True
)
assert error is None
- assert result is not None
- assert result.provider_raw_response is not None
- assert isinstance(result.provider_raw_response, dict)
+ assert result.provider_raw_response["id"] == "int_123"
+ assert result.provider_raw_response["output_audio"]["mime_type"] == "audio/l16"
def test_tts_without_provider_raw_response(
- self, provider, mock_client, tts_config, query_params
+ self, provider, mock_client, tts_config, query_params, pcm_interaction
):
- """Test TTS without raw response (default)."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = pcm_interaction
result, error = provider.execute(tts_config, query_params, "Hello")
assert error is None
assert result.provider_raw_response is None
- def test_tts_with_director_notes(
+ def test_tts_model_falls_back_to_config_model(
self, provider, mock_client, tts_config, query_params
):
- """Test TTS with Gemini-specific director_notes parameter."""
- tts_config.params["provider_specific"] = {
- "gemini": {"director_notes": "Speak in a cheerful tone"}
- }
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, model=None
+ )
result, error = provider.execute(tts_config, query_params, "Hello")
assert error is None
- assert result is not None
- # Verify config was passed with system_instruction
- call_args = mock_client.models.generate_content.call_args
- config_arg = call_args[1]["config"]
- assert config_arg.system_instruction == "Speak in a cheerful tone"
-
- def test_tts_without_director_notes(
- self, provider, mock_client, tts_config, query_params
- ):
- """Test TTS without director_notes (no system_instruction in config)."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
-
- result, error = provider.execute(tts_config, query_params, "Hello")
+ assert result.response.model == "gemini-2.5-pro-preview-tts"
- assert error is None
- call_args = mock_client.models.generate_content.call_args
- config_arg = call_args[1]["config"]
- assert (
- not hasattr(config_arg, "system_instruction")
- or config_arg.system_instruction is None
+ def test_tts_default_model(self, provider, mock_client, query_params):
+ config = NativeCompletionConfig(
+ provider="google-aistudio-native", type=CompletionType.TTS, params={}
+ )
+ mock_client.interactions.create.return_value = make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, model=None
)
- def test_tts_model_version_fallback(
- self, provider, mock_client, tts_config, query_params
- ):
- """Test that model falls back to config model when model_version is None."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_response.model_version = None
- mock_client.models.generate_content.return_value = mock_response
-
- result, error = provider.execute(tts_config, query_params, "Hello")
+ result, error = provider.execute(config, query_params, "Hello")
assert error is None
- assert result.response.model == "gemini-2.5-pro-preview-tts"
+ assert create_kwargs(mock_client)["model"] == "gemini-3.8-flash-tts"
def test_tts_generic_exception(
self, provider, mock_client, tts_config, query_params
):
- """Test handling of unexpected exceptions."""
- mock_client.models.generate_content.side_effect = Exception("API unavailable")
+ mock_client.interactions.create.side_effect = Exception("API unavailable")
result, error = provider.execute(tts_config, query_params, "Hello")
assert result is None
assert "[KAAPI] Unexpected error" in error
- def test_tts_type_error(self, provider, mock_client, tts_config, query_params):
- """Test handling of TypeError (invalid parameters)."""
- mock_client.models.generate_content.side_effect = TypeError(
- "unexpected keyword argument"
- )
-
- result, error = provider.execute(tts_config, query_params, "Hello")
-
- assert result is None
- assert "Invalid or unexpected parameter in Config" in error
-
- def test_tts_passes_correct_model_and_content(
- self, provider, mock_client, tts_config, query_params
- ):
- """Test that the correct model and text content are passed to the API."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
-
- result, error = provider.execute(tts_config, query_params, "Say this text")
-
- assert error is None
- call_args = mock_client.models.generate_content.call_args
- assert call_args[1]["model"] == "gemini-2.5-pro-preview-tts"
- assert call_args[1]["contents"] == "Say this text"
-
- def test_tts_passes_correct_voice_config(
- self, provider, mock_client, tts_config, query_params
- ):
- """Test that voice and language are correctly configured."""
- mock_response = mock_tts_google_response(audio_bytes=SAMPLE_PCM_BYTES)
- mock_client.models.generate_content.return_value = mock_response
-
- result, error = provider.execute(tts_config, query_params, "Hello")
-
- assert error is None
- call_args = mock_client.models.generate_content.call_args
- config_arg = call_args[1]["config"]
- assert config_arg.response_modalities == ["AUDIO"]
- voice_name = (
- config_arg.speech_config.voice_config.prebuilt_voice_config.voice_name
- )
- assert voice_name == "Kore"
- assert config_arg.speech_config.language_code == "en-US"
+def status_error(status_code: int, status: str, message: str) -> Exception:
+ body = {"error": {"code": status_code, "status": status, "message": message}}
+ response = httpx.Response(status_code, json=body, request=GEMINI_REQUEST)
+ return compat_errors.APIError.generate(status_code, body, message, response)
-class TestGoogleAIProviderText:
- """Coverage for the text completion path (`_execute_text`)."""
-
- @pytest.fixture
- def mock_client(self):
- return MagicMock()
-
- @pytest.fixture
- def provider(self, mock_client):
- return GoogleAIProvider(client=mock_client)
+class TestGoogleAIProviderErrors:
@pytest.fixture
- def text_config(self):
+ def text_config(self) -> NativeCompletionConfig:
return NativeCompletionConfig(
provider="google-native",
type=CompletionType.TEXT,
- params={"model": "gemini-2.5-pro", "temperature": 0.2},
+ params={"model": "gemini-2.5-pro"},
)
- @pytest.fixture
- def query_params(self):
- return QueryParams(input="Hello there")
-
- def test_text_happy_path(self, provider, mock_client, text_config, query_params):
- mock_client.models.generate_content.return_value = mock_google_response(
- text="Hi!"
- )
- result, error = provider.execute(text_config, query_params, "Hello there")
- assert error is None
- assert result.response.output.content.value == "Hi!"
- assert result.usage.input_tokens == 50
- assert result.usage.output_tokens == 100
-
- def test_text_with_instructions_and_reasoning(
- self, provider, mock_client, text_config, query_params
+ @pytest.mark.parametrize(
+ ("status_code", "status", "expected_prefix"),
+ [
+ (400, "INVALID_ARGUMENT", "[GEMINI] Bad request (code: 400)"),
+ (
+ 401,
+ "UNAUTHENTICATED",
+ "[GEMINI] Authentication / permission denied (code: 401)",
+ ),
+ (
+ 403,
+ "PERMISSION_DENIED",
+ "[GEMINI] Authentication / permission denied (code: 403)",
+ ),
+ (404, "NOT_FOUND", "[GEMINI] Resource not found (code: 404)"),
+ (
+ 429,
+ "RESOURCE_EXHAUSTED",
+ "[GEMINI] Rate limit / quota exceeded (code: 429)",
+ ),
+ (500, "INTERNAL", "[GEMINI] Server error (code: 500)"),
+ (503, "UNAVAILABLE", "[GEMINI] Server error (code: 503)"),
+ (409, "ABORTED", "[GEMINI] Client error (code: 409)"),
+ ],
+ )
+ def test_api_status_error_maps_to_gemini_message(
+ self,
+ provider,
+ mock_client,
+ text_config,
+ query_params,
+ status_code,
+ status,
+ expected_prefix,
):
- text_config.params["instructions"] = "Be terse."
- text_config.params["reasoning"] = "low"
- mock_client.models.generate_content.return_value = mock_google_response(
- text="ok"
+ mock_client.interactions.create.side_effect = status_error(
+ status_code, status, "upstream detail"
)
- result, error = provider.execute(text_config, query_params, "Ping")
- assert error is None
- config_arg = mock_client.models.generate_content.call_args[1]["config"]
- assert config_arg.system_instruction == "Be terse."
- assert config_arg.thinking_config is not None
- def test_text_missing_response_id_returns_error(
- self, provider, mock_client, text_config, query_params
- ):
- resp = mock_google_response(text="x")
- resp.response_id = None
- mock_client.models.generate_content.return_value = resp
result, error = provider.execute(text_config, query_params, "Hi")
+
assert result is None
- assert "response_id" in error
+ assert error.startswith(f"{expected_prefix}: upstream detail")
- def test_text_missing_text_returns_error(
+ def test_api_status_error_without_envelope_uses_exception_message(
self, provider, mock_client, text_config, query_params
):
- resp = mock_google_response(text="")
- resp.candidates = [SimpleNamespace(finish_reason="SAFETY")]
- resp.prompt_feedback = SimpleNamespace(block_reason="HATE")
- mock_client.models.generate_content.return_value = resp
+ response = httpx.Response(502, text="Bad Gateway", request=GEMINI_REQUEST)
+ mock_client.interactions.create.side_effect = compat_errors.APIStatusError(
+ "gateway exploded", response=response, body="Bad Gateway"
+ )
+
result, error = provider.execute(text_config, query_params, "Hi")
+
assert result is None
- assert "missing generated content" in error
+ assert error.startswith("[GEMINI] Server error (code: 502): gateway exploded")
- def test_text_accepts_list_of_content_parts(
+ def test_timeout_maps_to_could_not_reach_gemini(
self, provider, mock_client, text_config, query_params
):
- from app.models.llm import TextContent
-
- mock_client.models.generate_content.return_value = mock_google_response(
- text="seen"
+ mock_client.interactions.create.side_effect = compat_errors.APITimeoutError(
+ request=GEMINI_REQUEST
)
- result, error = provider.execute(
- text_config, query_params, [TextContent(value="part one")]
- )
- assert error is None
- contents = mock_client.models.generate_content.call_args[1]["contents"]
- assert contents[0]["parts"][0]["text"] == "part one"
- def test_text_with_knowledge_base_ids_sets_file_search_tool(
+ result, error = provider.execute(text_config, query_params, "Hi")
+
+ assert result is None
+ assert error.startswith("[KAAPI] Could not reach Gemini (APITimeoutError)")
+
+ def test_connection_error_maps_to_could_not_reach_gemini(
self, provider, mock_client, text_config, query_params
):
- store_names = ["fileSearchStores/store-1", "fileSearchStores/store-2"]
- text_config.params["knowledge_base_ids"] = store_names
- mock_client.models.generate_content.return_value = mock_google_response(
- text="grounded"
+ mock_client.interactions.create.side_effect = compat_errors.APIConnectionError(
+ request=GEMINI_REQUEST
)
- result, error = provider.execute(text_config, query_params, "Ask the KB")
+ result, error = provider.execute(text_config, query_params, "Hi")
- assert error is None
- config_arg = mock_client.models.generate_content.call_args[1]["config"]
- assert config_arg.tools[0].file_search.file_search_store_names == store_names
+ assert result is None
+ assert error.startswith("[KAAPI] Could not reach Gemini (APIConnectionError)")
- def test_text_without_knowledge_base_ids_sets_no_tools(
+ def test_unsupported_completion_type_returns_kaapi_error(
self, provider, mock_client, text_config, query_params
):
- mock_client.models.generate_content.return_value = mock_google_response(
- text="plain"
- )
+ text_config.type = "video"
- result, error = provider.execute(text_config, query_params, "No KB")
+ result, error = provider.execute(text_config, query_params, "Hi")
- assert error is None
- config_arg = mock_client.models.generate_content.call_args[1]["config"]
- assert not config_arg.tools
+ assert result is None
+ assert error.startswith("[KAAPI] Unsupported completion type 'video'")
+ mock_client.interactions.create.assert_not_called()
+
+
+def test_create_client_retries_thrice() -> None:
+ client = GoogleAIProvider.create_client({"api_key": "test-key"})
+ assert client._api_client._http_options.retry_options.attempts == 3
diff --git a/backend/app/tests/services/llm/providers/test_google_gcp.py b/backend/app/tests/services/llm/providers/test_google_gcp.py
index 5263a588d..00910662e 100644
--- a/backend/app/tests/services/llm/providers/test_google_gcp.py
+++ b/backend/app/tests/services/llm/providers/test_google_gcp.py
@@ -1,13 +1,19 @@
-"""Tests for the Google GCP provider."""
+"""Tests for the Google GCP (Vertex) provider."""
import base64
import json
+from types import SimpleNamespace
+from typing import Any
from unittest.mock import MagicMock, patch
+import httpx
import pytest
-import requests
+from google.auth import exceptions as google_auth_errors
+from google.genai._gaos.lib import compat_errors
+from google.genai.interactions import Interaction
-from app.core.audio_utils import AudioRef
+from app.core.audio_utils import AudioRef, pcm_to_wav
+from app.core.config import settings
from app.models.llm import (
ImageContent,
NativeCompletionConfig,
@@ -15,988 +21,876 @@
QueryParams,
TextContent,
)
+from app.models.llm.constants import CompletionType
from app.services.llm.providers.base import MultiModalInput
from app.services.llm.providers.google_gcp import (
- GoogleGCPProvider,
GoogleGCPClient,
- _load_platform_sa_info,
+ GoogleGCPProvider,
+ _parse_sa_info,
)
-from app.models.llm.constants import CompletionType
-
-def _stt_response(text: str = "hello world") -> dict:
- return {
- "candidates": [{"content": {"parts": [{"text": text}]}}],
- "modelVersion": "gemini-2.5-flash",
- "usageMetadata": {
- "promptTokenCount": 10,
- "candidatesTokenCount": 5,
- "totalTokenCount": 15,
+GCP_MODULE = "app.services.llm.providers.google_gcp"
+SAMPLE_PCM_BYTES = b"\x00\x01" * 100
+VERTEX_REQUEST = httpx.Request("POST", "https://aiplatform.googleapis.com")
+SA_INFO = {"type": "service_account", "project_id": "sa-proj"}
+
+
+def make_interaction(
+ *,
+ text: str | None = "hello world",
+ audio: bytes | None = None,
+ audio_b64: str | None = None,
+ interaction_id: str | None = "int_123",
+ model: str | None = "gemini-2.5-flash",
+ status: str = "completed",
+) -> Interaction:
+ content: list[dict[str, Any]] = []
+ if text is not None:
+ content.append({"type": "text", "text": text})
+ if audio is not None:
+ audio_b64 = base64.b64encode(audio).decode("ascii")
+ if audio_b64 is not None:
+ content.append({"type": "audio", "data": audio_b64, "mime_type": "audio/l16"})
+ payload: dict[str, Any] = {
+ "status": status,
+ "model": model,
+ "steps": [{"type": "model_output", "content": content}],
+ "usage": {
+ "total_input_tokens": 10,
+ "total_output_tokens": 5,
+ "total_thought_tokens": 2,
+ "total_tokens": 17,
},
}
+ if interaction_id is not None:
+ payload["id"] = interaction_id
+ return Interaction.model_validate(payload)
-def _tts_response(pcm_bytes: bytes = b"\x00\x01" * 100) -> dict:
- return {
- "candidates": [
- {
- "content": {
- "parts": [
- {
- "inlineData": {
- "mimeType": "audio/pcm",
- "data": base64.b64encode(pcm_bytes).decode("ascii"),
- }
- }
- ]
- }
- }
- ],
- "modelVersion": "gemini-2.5-flash-preview-tts",
- "usageMetadata": {
- "promptTokenCount": 4,
- "candidatesTokenCount": 0,
- "totalTokenCount": 4,
- },
+def tts_interaction(audio: bytes = SAMPLE_PCM_BYTES) -> Interaction:
+ return make_interaction(text=None, audio=audio, model="gemini-2.5-flash-tts")
+
+
+def create_kwargs(client: GoogleGCPClient) -> dict[str, Any]:
+ client.genai.interactions.create.assert_called_once()
+ return client.genai.interactions.create.call_args.kwargs
+
+
+def status_error(status_code: int, message: str) -> Exception:
+ body = {"error": {"code": status_code, "message": message}}
+ response = httpx.Response(status_code, json=body, request=VERTEX_REQUEST)
+ return compat_errors.APIError.generate(status_code, body, message, response)
+
+
+def platform_settings(**overrides: Any):
+ values: dict[str, Any] = {
+ "GCP_VERTEX_API_KEY": "",
+ "GCP_SA_KEY": "",
+ "GCP_PROJECT_ID": "",
+ "GCP_VERTEX_LOCATION": "",
+ "GCS_AUDIO_BUCKET": "",
}
+ values.update(overrides)
+ return patch.multiple(settings, **values)
+
+
+@pytest.fixture
+def client() -> GoogleGCPClient:
+ return GoogleGCPClient(
+ genai_client=MagicMock(),
+ project_id="p",
+ sa_info=SA_INFO,
+ gcs_bucket="test-bucket",
+ )
-def _mock_http_ok(json_body: dict) -> MagicMock:
- resp = MagicMock()
- resp.ok = True
- resp.status_code = 200
- resp.json.return_value = json_body
- return resp
+@pytest.fixture
+def provider(client: GoogleGCPClient) -> GoogleGCPProvider:
+ return GoogleGCPProvider(client=client)
+
+
+@pytest.fixture
+def query() -> QueryParams:
+ return QueryParams(input="ignored")
-def _mock_http_err(status: int = 400, body: str = "bad request") -> MagicMock:
- resp = MagicMock()
- resp.ok = False
- resp.status_code = status
- resp.text = body
- return resp
+@pytest.fixture
+def upload_mock(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ # GCS is an external boundary; STT stages audio there before calling Vertex.
+ mock = MagicMock(return_value="gs://test-bucket/audio/test.wav")
+ monkeypatch.setattr(f"{GCP_MODULE}.upload_audio_to_gcs", mock)
+ return mock
-@pytest.fixture(autouse=True)
-def _mock_gcs(monkeypatch):
- """Stub out GCS upload so STT tests don't touch external services."""
- monkeypatch.setattr(
- "app.services.llm.providers.google_gcp.upload_audio_to_gcs",
- lambda *, audio_bytes, bucket_name, sa_info, **kw: f"gs://{bucket_name}/audio/test.wav",
+def _text_config(**params: Any) -> NativeCompletionConfig:
+ return NativeCompletionConfig(
+ provider="google-gcp-native",
+ type=CompletionType.TEXT,
+ params={"model": "gemini-2.5-flash", **params},
)
-class TestGoogleGCPProvider:
- @pytest.fixture
- def client(self) -> GoogleGCPClient:
- return GoogleGCPClient(
- api_key="k",
- project_id="p",
- location="us-central1",
- sa_info={"type": "service_account", "project_id": "p"},
- gcs_bucket="test-bucket",
- )
+def _stt_config(**params: Any) -> NativeCompletionConfig:
+ return NativeCompletionConfig(
+ provider="google-gcp-native",
+ type=CompletionType.STT,
+ params={"model": "gemini-2.5-flash", "input_language": "auto", **params},
+ )
- @pytest.fixture
- def provider(self, client) -> GoogleGCPProvider:
- return GoogleGCPProvider(client=client)
- @pytest.fixture
- def query(self) -> QueryParams:
- return QueryParams(input="ignored")
+def _tts_config(**params: Any) -> NativeCompletionConfig:
+ return NativeCompletionConfig(
+ provider="google-gcp-native",
+ type=CompletionType.TTS,
+ params={"model": "gemini-2.5-flash-tts", "voice": "Kore", **params},
+ )
- @pytest.fixture
- def audio_ref(self) -> AudioRef:
- return AudioRef(bytes_=b"RIFFfake", mime_type="audio/wav")
- @pytest.fixture
- def stt_config(self) -> NativeCompletionConfig:
- return NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.STT,
- params={"model": "gemini-2.5-flash", "input_language": "auto"},
- )
+class TestParseSaInfo:
+ def test_dict_passes_through(self):
+ assert _parse_sa_info(SA_INFO) == SA_INFO
- @pytest.fixture
- def tts_config(self) -> NativeCompletionConfig:
- return NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TTS,
- params={"model": "gemini-2.5-flash-preview-tts", "voice": "Kore"},
- )
+ def test_empty_dict_is_none(self):
+ assert _parse_sa_info({}) is None
- # ── create_client ────────────────────────────────────────────────────────
- def test_create_client_requires_all_fields(self):
- with pytest.raises(ValueError, match="project_id, location"):
- GoogleGCPProvider.create_client({"api_key": "k"})
+ def test_parses_json_string(self):
+ assert _parse_sa_info(json.dumps(SA_INFO)) == SA_INFO
- def test_create_client_builds_endpoint(self):
- c = GoogleGCPProvider.create_client(
- {
- "api_key": "k",
- "project_id": "p",
- "location": "us-central1",
- "sa_key": {"type": "service_account"},
- "gcs_bucket": "test-bucket",
- }
+ def test_parses_json_string_with_surrounding_whitespace(self):
+ # env-var injection often leaves trailing newlines
+ assert _parse_sa_info("\n " + json.dumps(SA_INFO) + " \n") == SA_INFO
+
+ def test_malformed_json_returns_none(self):
+ assert _parse_sa_info("{not valid json") is None
+
+ def test_non_json_string_returns_none(self):
+ assert _parse_sa_info("/etc/secrets/sa.json") is None
+
+ @pytest.mark.parametrize("value", [None, "", 42])
+ def test_unset_or_other_types_return_none(self, value):
+ assert _parse_sa_info(value) is None
+
+
+class TestCreateClient:
+ def test_service_account_path_builds_vertex_client_with_credentials(self):
+ sa_creds = object()
+ with (
+ platform_settings(GCP_VERTEX_API_KEY="platform-key"),
+ patch(f"{GCP_MODULE}.build_gcp_sa_credentials", return_value=sa_creds),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client(
+ {
+ "sa_key": SA_INFO,
+ "project_id": "byok-proj",
+ "location": "europe-west4",
+ "gcs_bucket": "byok-bucket",
+ }
+ )
+
+ kwargs = mock_client.call_args.kwargs
+ assert kwargs["vertexai"] is True
+ assert kwargs["project"] == "byok-proj"
+ assert kwargs["location"] == "europe-west4"
+ assert kwargs["credentials"] is sa_creds
+ assert kwargs["http_options"].retry_options.attempts == 3
+ # The SDK rejects credentials + api_key together.
+ assert "api_key" not in kwargs
+ assert c.genai is mock_client.return_value
+ assert c.project_id == "byok-proj"
+ assert c.sa_info == SA_INFO
+ assert c.gcs_bucket == "byok-bucket"
+
+ def test_project_id_falls_back_to_sa_info(self):
+ with (
+ platform_settings(),
+ patch(f"{GCP_MODULE}.build_gcp_sa_credentials", return_value=object()),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client({"sa_key": SA_INFO})
+
+ assert mock_client.call_args.kwargs["project"] == "sa-proj"
+ assert c.project_id == "sa-proj"
+
+ def test_location_defaults_to_global(self):
+ with (
+ platform_settings(),
+ patch(f"{GCP_MODULE}.build_gcp_sa_credentials", return_value=object()),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ GoogleGCPProvider.create_client({"sa_key": SA_INFO})
+
+ assert mock_client.call_args.kwargs["location"] == "global"
+
+ def test_platform_sa_json_string_is_used(self):
+ with (
+ platform_settings(GCP_SA_KEY=json.dumps(SA_INFO)),
+ patch(f"{GCP_MODULE}.build_gcp_sa_credentials", return_value=object()),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client({})
+
+ assert "credentials" in mock_client.call_args.kwargs
+ assert c.sa_info == SA_INFO
+
+ def test_malformed_sa_falls_back_to_api_key(self):
+ with (
+ platform_settings(),
+ patch(
+ f"{GCP_MODULE}.build_gcp_sa_credentials",
+ side_effect=ValueError("no private_key"),
+ ),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client({"sa_key": SA_INFO, "api_key": "k"})
+
+ kwargs = mock_client.call_args.kwargs
+ assert kwargs["api_key"] == "k"
+ assert "credentials" not in kwargs
+ assert c.sa_info is None
+
+ def test_malformed_sa_without_api_key_raises(self):
+ with (
+ platform_settings(),
+ patch(
+ f"{GCP_MODULE}.build_gcp_sa_credentials",
+ side_effect=KeyError("client_email"),
+ ),
+ patch(f"{GCP_MODULE}.genai.Client"),
+ pytest.raises(ValueError, match="Google GCP credentials missing"),
+ ):
+ GoogleGCPProvider.create_client({"sa_key": SA_INFO})
+
+ def test_sa_without_project_id_uses_api_key(self):
+ sa_no_project = {"type": "service_account"}
+ with (
+ platform_settings(),
+ patch(f"{GCP_MODULE}.build_gcp_sa_credentials", return_value=object()),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client(
+ {"sa_key": sa_no_project, "api_key": "k"}
+ )
+
+ assert mock_client.call_args.kwargs["api_key"] == "k"
+ assert "credentials" not in mock_client.call_args.kwargs
+ assert c.sa_info == sa_no_project
+
+ def test_byok_overrides_platform_settings(self):
+ with (
+ platform_settings(
+ GCP_VERTEX_API_KEY="platform-key",
+ GCP_PROJECT_ID="platform-proj",
+ GCS_AUDIO_BUCKET="platform-bucket",
+ ),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client(
+ {
+ "api_key": "byok-key",
+ "project_id": "byok-proj",
+ "gcs_bucket": "byok-bucket",
+ }
+ )
+
+ assert mock_client.call_args.kwargs["api_key"] == "byok-key"
+ assert c.project_id == "byok-proj"
+ assert c.gcs_bucket == "byok-bucket"
+
+ def test_partial_byok_fills_from_platform(self):
+ with (
+ platform_settings(
+ GCP_VERTEX_API_KEY="platform-key",
+ GCP_PROJECT_ID="platform-proj",
+ GCS_AUDIO_BUCKET="platform-bucket",
+ ),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ ):
+ c = GoogleGCPProvider.create_client({"api_key": "byok-key"})
+
+ assert mock_client.call_args.kwargs["api_key"] == "byok-key"
+ assert c.project_id == "platform-proj"
+ assert c.gcs_bucket == "platform-bucket"
+
+ def test_missing_everything_raises_value_error(self):
+ with (
+ platform_settings(),
+ patch(f"{GCP_MODULE}.genai.Client") as mock_client,
+ pytest.raises(ValueError) as exc_info,
+ ):
+ GoogleGCPProvider.create_client({})
+
+ msg = str(exc_info.value)
+ assert "sa_key" in msg
+ assert "api_key" in msg
+ mock_client.assert_not_called()
+
+
+class TestSTT:
+ @pytest.fixture
+ def audio_ref(self) -> AudioRef:
+ return AudioRef(bytes_=b"RIFFfake", mime_type="audio/wav")
+
+ def test_happy_path(self, provider, client, query, audio_ref, upload_mock):
+ client.genai.interactions.create.return_value = make_interaction(
+ text=" hi there \n"
)
- assert "us-central1-aiplatform.googleapis.com" in c.endpoint("m")
- assert "projects/p/locations/us-central1" in c.endpoint("m")
- assert "models/m:generateContent" in c.endpoint("m")
- # ── STT ──────────────────────────────────────────────────────────────────
- def test_stt_happy_path(self, provider, stt_config, query, audio_ref):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("hi there")),
- ) as mock_post:
- resp, err = provider.execute(stt_config, query, audio_ref)
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert err is None
assert resp.response.output.content.value == "hi there"
assert resp.response.model == "gemini-2.5-flash"
+ assert resp.response.provider_response_id == "int_123"
assert resp.usage.input_tokens == 10
assert resp.usage.output_tokens == 5
+ assert resp.usage.total_tokens == 17
+
+ upload_kwargs = upload_mock.call_args.kwargs
+ assert upload_kwargs["audio_bytes"] == b"RIFFfake"
+ assert upload_kwargs["bucket_name"] == "test-bucket"
+ assert upload_kwargs["content_type"] == "audio/wav"
+
+ kwargs = create_kwargs(client)
+ content = kwargs["input"][0]["content"]
+ assert kwargs["input"][0]["type"] == "user_input"
+ assert content[0]["type"] == "text"
+ assert "Detect the spoken language automatically" in content[0]["text"]
+ assert content[1] == {
+ "type": "audio",
+ "uri": "gs://test-bucket/audio/test.wav",
+ "mime_type": "audio/wav",
+ }
- kwargs = mock_post.call_args.kwargs
- assert kwargs["params"] == {"key": "k"}
- parts = kwargs["json"]["contents"][0]["parts"]
- assert parts[0]["fileData"]["mimeType"] == "audio/wav"
- assert parts[0]["fileData"]["fileUri"].startswith("gs://test-bucket/")
- assert "Detect the spoken language automatically" in parts[1]["text"]
-
- def test_stt_rejects_non_audioref_input(self, provider, stt_config, query):
- resp, err = provider.execute(stt_config, query, "/some/path.wav")
+ def test_rejects_non_audioref_input(self, provider, client, query):
+ resp, err = provider.execute(_stt_config(), query, "/some/path.wav")
assert resp is None
assert "AudioRef input" in err
+ client.genai.interactions.create.assert_not_called()
- def test_stt_rejects_unsupported_mime(self, provider, stt_config, query):
+ def test_rejects_unsupported_mime(self, provider, query):
bad = AudioRef(bytes_=b"x", mime_type="audio/xyz")
- resp, err = provider.execute(stt_config, query, bad)
+ resp, err = provider.execute(_stt_config(), query, bad)
assert resp is None
assert "unsupported audio mime" in err
- def test_stt_gcs_upload_failure_returns_clean_error(
- self, provider, stt_config, query, audio_ref, monkeypatch
+ @pytest.mark.parametrize(
+ ("sa_info", "gcs_bucket"), [(None, "test-bucket"), (SA_INFO, None)]
+ )
+ def test_missing_sa_or_bucket_returns_staging_error(
+ self, query, audio_ref, upload_mock, sa_info, gcs_bucket
+ ):
+ provider = GoogleGCPProvider(
+ client=GoogleGCPClient(
+ genai_client=MagicMock(),
+ project_id="p",
+ sa_info=sa_info,
+ gcs_bucket=gcs_bucket,
+ )
+ )
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
+ assert resp is None
+ assert "Google GCP STT staging failed" in err
+ upload_mock.assert_not_called()
+
+ def test_gcs_upload_failure_returns_clean_error(
+ self, provider, client, query, audio_ref, monkeypatch
):
monkeypatch.setattr(
- "app.services.llm.providers.google_gcp.upload_audio_to_gcs",
+ f"{GCP_MODULE}.upload_audio_to_gcs",
MagicMock(side_effect=RuntimeError("bucket denied")),
)
- resp, err = provider.execute(stt_config, query, audio_ref)
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert resp is None
assert "Failed to stage audio for Google GCP STT" in err
assert "bucket denied" in err
+ client.genai.interactions.create.assert_not_called()
- def test_stt_http_error_returns_clean_message(
- self, provider, stt_config, query, audio_ref
+ def test_http_error_returns_clean_message(
+ self, provider, client, query, audio_ref, upload_mock
):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_err(403, "permission denied"),
- ):
- resp, err = provider.execute(stt_config, query, audio_ref)
+ client.genai.interactions.create.side_effect = status_error(
+ 403, "permission denied"
+ )
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert resp is None
- assert "[GOOGLE_GCP]" in err
+ assert err.startswith("[GOOGLE_GCP]")
assert "403" in err
assert "permission denied" in err
- def test_stt_network_error_returns_clean_message(
- self, provider, stt_config, query, audio_ref
+ def test_network_error_returns_clean_message(
+ self, provider, client, query, audio_ref, upload_mock
):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- side_effect=requests.ConnectionError("dns boom"),
- ):
- resp, err = provider.execute(stt_config, query, audio_ref)
+ client.genai.interactions.create.side_effect = compat_errors.APIConnectionError(
+ request=VERTEX_REQUEST
+ )
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert resp is None
assert "Google GCP connection failed" in err
- def test_stt_missing_transcript_returns_error(
- self, provider, stt_config, query, audio_ref
+ def test_missing_transcript_returns_error(
+ self, provider, client, query, audio_ref, upload_mock
):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok({"candidates": []}),
- ):
- resp, err = provider.execute(stt_config, query, audio_ref)
+ client.genai.interactions.create.return_value = make_interaction(text=None)
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
+ assert resp is None
+ assert err.startswith("[GOOGLE_GCP] STT response is missing transcribed text")
+
+ def test_non_completed_status_returns_error(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction(
+ text="partial", status="failed"
+ )
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert resp is None
assert "missing transcribed text" in err
+ assert "status=failed" in err
- def test_stt_input_language_overrides_prompt(self, provider, query, audio_ref):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.STT,
- params={
- "model": "gemini-2.5-flash",
- "input_language": "hi-IN",
- "output_language": "en-IN",
- "instructions": "be precise",
- },
+ def test_input_language_and_instructions_shape_prompt(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction()
+ config = _stt_config(
+ input_language="hi-IN",
+ output_language="en-IN",
+ instructions="be precise",
)
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response()),
- ) as mock_post:
- provider.execute(config, query, audio_ref)
+ resp, err = provider.execute(config, query, audio_ref)
- prompt = mock_post.call_args.kwargs["json"]["contents"][0]["parts"][1]["text"]
+ assert err is None
+ assert resp.response.output.content.language_code == "en-IN"
+ prompt = create_kwargs(client)["input"][0]["content"][0]["text"]
assert prompt.startswith("be precise")
assert "hi-IN" in prompt
assert "translate to en-IN" in prompt
- # ── TTS ──────────────────────────────────────────────────────────────────
- def test_tts_happy_path_wav(self, provider, tts_config, query):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ) as mock_post:
- resp, err = provider.execute(tts_config, query, "hello")
+ def test_defaults_thinking_level_and_max_tokens_and_drops_temperature(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction()
+ resp, err = provider.execute(_stt_config(temperature=0.3), query, audio_ref)
+ assert err is None
+ assert create_kwargs(client)["generation_config"] == {
+ "thinking_level": "low",
+ "max_output_tokens": 2048,
+ }
+ def test_explicit_thinking_level_and_max_tokens_override_defaults(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction()
+ provider.execute(
+ _stt_config(thinking_level="high", max_output_tokens=512),
+ query,
+ audio_ref,
+ )
+ assert create_kwargs(client)["generation_config"] == {
+ "thinking_level": "high",
+ "max_output_tokens": 512,
+ }
+
+ def test_empty_interaction_id_gets_generated_response_id(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction(
+ interaction_id=None
+ )
+ resp, err = provider.execute(_stt_config(), query, audio_ref)
assert err is None
- assert resp.response.output.content.format == "base64"
- assert resp.response.output.content.mime_type == "audio/wav"
- decoded = base64.b64decode(resp.response.output.content.value)
- assert decoded[:4] == b"RIFF"
-
- sent = mock_post.call_args.kwargs["json"]
- assert sent["generationConfig"]["responseModalities"] == ["AUDIO"]
- assert (
- sent["generationConfig"]["speechConfig"]["voiceConfig"][
- "prebuiltVoiceConfig"
- ]["voiceName"]
- == "Kore"
+ assert resp.response.provider_response_id.startswith("google-gcp-")
+
+ def test_raw_response_included_when_requested(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction()
+ resp, _ = provider.execute(
+ _stt_config(), query, audio_ref, include_provider_raw_response=True
)
+ assert resp.provider_raw_response["id"] == "int_123"
+ assert resp.provider_raw_response["status"] == "completed"
- def test_tts_rejects_non_string_input(self, provider, tts_config, query):
- resp, err = provider.execute(tts_config, query, ["not a string"])
+ def test_raw_response_omitted_by_default(
+ self, provider, client, query, audio_ref, upload_mock
+ ):
+ client.genai.interactions.create.return_value = make_interaction()
+ resp, _ = provider.execute(_stt_config(), query, audio_ref)
+ assert resp.provider_raw_response is None
+
+
+class TestTTS:
+ def test_happy_path_wav(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
+
+ resp, err = provider.execute(_tts_config(), query, "hello")
+
+ assert err is None
+ content = resp.response.output.content
+ assert content.format == "base64"
+ assert content.mime_type == "audio/wav"
+ assert base64.b64decode(content.value) == pcm_to_wav(SAMPLE_PCM_BYTES)
+ assert resp.response.model == "gemini-2.5-flash-tts"
+
+ kwargs = create_kwargs(client)
+ assert kwargs["model"] == "gemini-2.5-flash-tts"
+ assert kwargs["generation_config"] == {"speech_config": [{"voice": "Kore"}]}
+ assert kwargs["response_format"] == {"type": "audio"}
+
+ def test_wraps_input_in_transcript_tags(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
+ provider.execute(_tts_config(), query, "Say this text")
+ assert create_kwargs(client)["input"] == [
+ {
+ "type": "user_input",
+ "content": [
+ {"type": "text", "text": "Say this text"}
+ ],
+ }
+ ]
+
+ def test_wav_response_is_stripped_to_pcm(self, provider, client, query):
+ # 3.8+ TTS models return a full WAV; re-wrapping it would nest headers.
+ client.genai.interactions.create.return_value = tts_interaction(
+ pcm_to_wav(SAMPLE_PCM_BYTES)
+ )
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert err is None
+ decoded = base64.b64decode(resp.response.output.content.value)
+ assert decoded == pcm_to_wav(SAMPLE_PCM_BYTES)
+
+ def test_rejects_non_string_input(self, provider, client, query):
+ resp, err = provider.execute(_tts_config(), query, ["not a string"])
assert resp is None
assert "text string as input" in err
+ client.genai.interactions.create.assert_not_called()
- def test_tts_rejects_empty_input(self, provider, tts_config, query):
- resp, err = provider.execute(tts_config, query, " ")
+ def test_rejects_empty_input(self, provider, query):
+ resp, err = provider.execute(_tts_config(), query, " ")
assert resp is None
assert "text input is empty" in err
- def test_tts_missing_audio_returns_error(self, provider, tts_config, query):
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok({"candidates": [{"content": {"parts": []}}]}),
- ):
- resp, err = provider.execute(tts_config, query, "hello")
+ def test_missing_audio_returns_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(text="oops")
+ resp, err = provider.execute(_tts_config(), query, "hello")
+ assert resp is None
+ assert err.startswith("[GOOGLE_GCP] TTS response is missing audio data")
+
+ def test_empty_audio_data_string_returns_missing_audio_error(
+ self, provider, client, query
+ ):
+ client.genai.interactions.create.return_value = make_interaction(
+ text=None, audio_b64=""
+ )
+ resp, err = provider.execute(_tts_config(), query, "hi")
assert resp is None
assert "missing audio data" in err
- def test_tts_language_is_forwarded(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TTS,
- params={"model": "gemini-2.5-flash-preview-tts", "language": "en-US"},
+ def test_non_completed_status_returns_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(
+ text=None, audio=SAMPLE_PCM_BYTES, status="failed"
)
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ) as mock_post:
- provider.execute(config, query, "hi")
- speech = mock_post.call_args.kwargs["json"]["generationConfig"]["speechConfig"]
- assert speech["languageCode"] == "en-US"
-
- # ── execute dispatcher ───────────────────────────────────────────────────
- def test_text_completion_happy_path(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-gcp-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash", "temperature": 0.2},
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert "missing audio data" in err
+
+ def test_wav_with_zero_frames_returns_empty_audio_error(
+ self, provider, client, query
+ ):
+ client.genai.interactions.create.return_value = tts_interaction(pcm_to_wav(b""))
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[GOOGLE_GCP] TTS returned empty audio data")
+
+ def test_invalid_base64_returns_unexpected_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(
+ text=None, audio_b64="not-valid-base64!!"
)
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("generated answer")),
- ) as mock_post:
- resp, err = provider.execute(config, query, "hello")
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Unexpected error")
- assert err is None
- assert resp.response.output.content.value == "generated answer"
- payload = mock_post.call_args.kwargs["json"]
- assert payload["contents"][0]["parts"] == [{"text": "hello"}]
- assert payload["generationConfig"]["temperature"] == 0.2
+ def test_language_is_forwarded(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
+ provider.execute(_tts_config(language="en-US"), query, "hi")
+ assert create_kwargs(client)["generation_config"] == {
+ "speech_config": [{"voice": "Kore", "language": "en-US"}]
+ }
- def test_raw_response_included_when_requested(
- self, provider, stt_config, query, audio_ref
+ def test_director_notes_become_speech_metadata_annotation(
+ self, provider, client, query
):
- raw = _stt_response()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(raw),
- ):
- resp, _ = provider.execute(
- stt_config, query, audio_ref, include_provider_raw_response=True
- )
- assert resp.provider_raw_response == raw
-
- # ── text ─────────────────────────────────────────────────────────────────
- def test_text_forwards_instructions_and_temperature(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TEXT,
- params={
- "model": "gemini-2.5-flash",
- "instructions": "be concise",
- "temperature": 0.4,
- },
+ client.genai.interactions.create.return_value = tts_interaction()
+ config = _tts_config(
+ provider_specific={"gemini": {"director_notes": "whisper"}}
)
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("hi there")),
- ) as mock_post:
- provider.execute(config, query, "hello")
- payload = mock_post.call_args.kwargs["json"]
- assert payload["systemInstruction"] == {"parts": [{"text": "be concise"}]}
- assert payload["generationConfig"]["temperature"] == 0.4
-
- def test_text_accepts_multimodal_list_input(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash"},
- )
- parts = [
- TextContent(value="describe this"),
- ImageContent(format="base64", value="aW1n", mime_type="image/png"),
- ImageContent(
- format="url", value="https://x/img.png", mime_type="image/png"
- ),
- PDFContent(format="base64", value="cGRm", mime_type="application/pdf"),
- PDFContent(
- format="url", value="https://x/doc.pdf", mime_type="application/pdf"
- ),
- ]
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("described")),
- ) as mock_post:
- resp, err = provider.execute(config, query, parts)
- assert err is None
- sent_parts = mock_post.call_args.kwargs["json"]["contents"][0]["parts"]
- assert sent_parts == [
- {"text": "describe this"},
- {"inlineData": {"mimeType": "image/png", "data": "aW1n"}},
- {"fileData": {"mimeType": "image/png", "fileUri": "https://x/img.png"}},
- {"inlineData": {"mimeType": "application/pdf", "data": "cGRm"}},
- {
- "fileData": {
- "mimeType": "application/pdf",
- "fileUri": "https://x/doc.pdf",
- }
- },
+ provider.execute(config, query, "hi")
+ transcript = create_kwargs(client)["input"][0]["content"][0]
+ assert transcript["annotations"] == [
+ {"type": "speech_metadata", "style": "whisper"}
]
- def test_text_accepts_multimodal_input_wrapper(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash"},
- )
- multimodal = MultiModalInput(parts=[TextContent(value="hi")])
+ def test_mp3_conversion_success(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("hi there")),
- ) as mock_post:
- resp, err = provider.execute(config, query, multimodal)
+ f"{GCP_MODULE}.convert_pcm_to_mp3", return_value=(b"mp3bytes", None)
+ ):
+ resp, err = provider.execute(
+ _tts_config(response_format="mp3"), query, "hi"
+ )
assert err is None
- sent_parts = mock_post.call_args.kwargs["json"]["contents"][0]["parts"]
- assert sent_parts == [{"text": "hi"}]
-
- def test_text_missing_content_returns_error(self, provider, query):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash"},
- )
- malformed = {"candidates": [], "modelVersion": "gemini-2.5-flash"}
+ assert resp.response.output.content.mime_type == "audio/mp3"
+ assert base64.b64decode(resp.response.output.content.value) == b"mp3bytes"
+
+ def test_mp3_conversion_failure_returns_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(malformed),
+ f"{GCP_MODULE}.convert_pcm_to_mp3", return_value=(None, "ffmpeg missing")
):
- resp, err = provider.execute(config, query, "hello")
+ resp, err = provider.execute(
+ _tts_config(response_format="mp3"), query, "hi"
+ )
assert resp is None
- assert "Text response is missing generated content" in err
+ assert err.startswith("[KAAPI] Post-processing failure")
+ assert "ffmpeg missing" in err
- def test_text_raw_response_included_when_requested(self, provider, query):
- raw = _stt_response("hi there")
+ def test_ogg_conversion_success(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(raw),
+ f"{GCP_MODULE}.convert_pcm_to_ogg", return_value=(b"oggbytes", None)
):
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash"},
+ resp, err = provider.execute(
+ _tts_config(response_format="ogg"), query, "hi"
)
- resp, _ = provider.execute(
- config, query, "hello", include_provider_raw_response=True
- )
- assert resp.provider_raw_response == raw
-
-
-# ---------------------------------------------------------------------------
-# TTS payload shape — not routing-dependent, kept unskipped for coverage.
-# ---------------------------------------------------------------------------
-def test_tts_wraps_input_in_transcript_tags():
- client = GoogleGCPClient(
- api_key="k",
- project_id="p",
- location="us-central1",
- sa_info={"type": "service_account", "project_id": "p"},
- gcs_bucket="test-bucket",
- )
- provider = GoogleGCPProvider(client=client)
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TTS,
- params={"model": "gemini-2.5-flash-preview-tts", "voice": "Kore"},
- )
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ) as mock_post:
- provider.execute(config, QueryParams(input="ignored"), "Say this text")
-
- parts = mock_post.call_args.kwargs["json"]["contents"][0]["parts"]
- assert parts[0]["text"] == "Say this text"
-
-
-# ---------------------------------------------------------------------------
-# Standalone _post / _execute_tts / execute() coverage — not routing-dependent,
-# kept unskipped (same pattern as test_tts_wraps_input_in_transcript_tags).
-# ---------------------------------------------------------------------------
-def _provider() -> GoogleGCPProvider:
- client = GoogleGCPClient(
- api_key="k",
- project_id="p",
- location="us-central1",
- sa_info={"type": "service_account", "project_id": "p"},
- gcs_bucket="test-bucket",
- )
- return GoogleGCPProvider(client=client)
-
-
-def _tts_config(**params) -> NativeCompletionConfig:
- return NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.TTS,
- params={"model": "gemini-2.5-flash-preview-tts", "voice": "Kore", **params},
- )
-
-
-@pytest.mark.parametrize(
- "status,expected_snippet",
- [
- (400, "Bad request"),
- (401, "Authentication / permission denied"),
- (404, "Resource not found"),
- (429, "Rate limit / quota exceeded"),
- (500, "Server error"),
- (418, "HTTP error"),
- ],
-)
-def test_post_http_error_status_branches(status, expected_snippet):
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_err(status, "boom"),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert expected_snippet in err
-
-
-def test_post_http_error_uses_google_error_envelope():
- provider = _provider()
- resp_mock = MagicMock()
- resp_mock.ok = False
- resp_mock.status_code = 400
- resp_mock.text = "raw text"
- resp_mock.json.return_value = {
- "error": {"message": "invalid field", "status": "INVALID_ARGUMENT"}
- }
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=resp_mock,
- ):
- _, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert "invalid field" in err
- assert "INVALID_ARGUMENT" in err
-
-
-def test_post_timeout_returns_clean_message():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- side_effect=requests.Timeout("too slow"),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "timed out" in err
+ assert err is None
+ assert resp.response.output.content.mime_type == "audio/ogg"
+ assert base64.b64decode(resp.response.output.content.value) == b"oggbytes"
+ def test_ogg_conversion_failure_returns_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
+ with patch(
+ f"{GCP_MODULE}.convert_pcm_to_ogg", return_value=(None, "codec missing")
+ ):
+ resp, err = provider.execute(
+ _tts_config(response_format="ogg"), query, "hi"
+ )
+ assert resp is None
+ assert err.startswith("[KAAPI] Post-processing failure")
+ assert "codec missing" in err
-def test_post_request_exception_returns_clean_message():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- side_effect=requests.RequestException("weird"),
+ def test_unsupported_response_format_falls_back_to_wav(
+ self, provider, client, query
):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "request failed" in err
-
-
-def test_post_non_json_success_response_returns_clean_message():
- provider = _provider()
- resp_mock = MagicMock()
- resp_mock.ok = True
- resp_mock.status_code = 200
- resp_mock.json.side_effect = ValueError("no json")
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=resp_mock,
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "non-JSON success response" in err
-
-
-def test_execute_tts_rejects_non_string_input():
- provider = _provider()
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), [1, 2])
- assert resp is None
- assert "text string as input" in err
+ client.genai.interactions.create.return_value = tts_interaction()
+ resp, err = provider.execute(_tts_config(response_format="flac"), query, "hi")
+ assert err is None
+ assert resp.response.output.content.mime_type == "audio/wav"
+ def test_raw_response_included_when_requested(self, provider, client, query):
+ client.genai.interactions.create.return_value = tts_interaction()
+ resp, _ = provider.execute(
+ _tts_config(), query, "hi", include_provider_raw_response=True
+ )
+ assert resp.provider_raw_response["id"] == "int_123"
-def test_execute_tts_rejects_empty_input():
- provider = _provider()
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), " ")
- assert resp is None
- assert "text input is empty" in err
+class TestText:
+ def test_happy_path_drops_temperature(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(
+ text="generated answer"
+ )
-def test_execute_tts_missing_audio_data_returns_error():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok({"candidates": [{"content": {"parts": []}}]}),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "missing audio data" in err
+ resp, err = provider.execute(_text_config(temperature=0.2), query, "hello")
+ assert err is None
+ assert resp.response.output.content.value == "generated answer"
+ assert resp.response.provider_response_id == "int_123"
+ assert resp.usage.reasoning_tokens == 2
+ assert create_kwargs(client) == {
+ "model": "gemini-2.5-flash",
+ "input": [
+ {"type": "user_input", "content": [{"type": "text", "text": "hello"}]}
+ ],
+ }
-def test_execute_tts_invalid_base64_returns_error():
- provider = _provider()
- bad = {
- "candidates": [
- {"content": {"parts": [{"inlineData": {"data": "not-valid-base64!!"}}]}}
- ]
- }
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(bad),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "invalid base64 audio" in err
+ def test_forwards_system_instruction(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction()
+ provider.execute(_text_config(instructions="be terse"), query, "hi")
+ assert create_kwargs(client)["system_instruction"] == "be terse"
+ def test_forwards_max_output_tokens(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction()
+ provider.execute(_text_config(max_output_tokens=256), query, "hi")
+ assert create_kwargs(client)["generation_config"] == {"max_output_tokens": 256}
-def test_execute_tts_empty_audio_bytes_returns_error():
- provider = _provider()
- empty = {"candidates": [{"content": {"parts": [{"inlineData": {"data": ""}}]}}]}
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(empty),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "empty audio data" in err
-
-
-def test_execute_tts_mp3_conversion_success():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ), patch(
- "app.services.llm.providers.google_gcp.convert_pcm_to_mp3",
- return_value=(b"mp3bytes", None),
+ def test_forwards_output_schema_as_json_response_format(
+ self, provider, client, query
):
- resp, err = provider.execute(
- _tts_config(response_format="mp3"), QueryParams(input="ignored"), "hi"
+ schema = {"type": "OBJECT", "properties": {"band": {"type": "STRING"}}}
+ client.genai.interactions.create.return_value = make_interaction(
+ text='{"band": "high"}'
)
- assert err is None
- assert resp.response.output.content.mime_type == "audio/mp3"
-
-
-def test_execute_tts_mp3_conversion_failure_returns_error():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ), patch(
- "app.services.llm.providers.google_gcp.convert_pcm_to_mp3",
- return_value=(None, "ffmpeg missing"),
- ):
- resp, err = provider.execute(
- _tts_config(response_format="mp3"), QueryParams(input="ignored"), "hi"
- )
- assert resp is None
- assert "unable to convert" in err
- assert "ffmpeg missing" in err
-
-
-def test_execute_tts_ogg_conversion_success():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ), patch(
- "app.services.llm.providers.google_gcp.convert_pcm_to_ogg",
- return_value=(b"oggbytes", None),
- ):
- resp, err = provider.execute(
- _tts_config(response_format="ogg"), QueryParams(input="ignored"), "hi"
- )
- assert err is None
- assert resp.response.output.content.mime_type == "audio/ogg"
-
+ provider.execute(_text_config(output_schema=schema), query, "hi")
+ kwargs = create_kwargs(client)
+ assert kwargs["response_format"] == schema
+ assert kwargs["response_mime_type"] == "application/json"
-def test_execute_tts_unsupported_response_format_falls_back_to_wav():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
+ def test_thinking_config_takes_precedence_over_reasoning(
+ self, provider, client, query
):
- resp, err = provider.execute(
- _tts_config(response_format="flac"), QueryParams(input="ignored"), "hi"
+ client.genai.interactions.create.return_value = make_interaction()
+ config = _text_config(
+ thinking_config={"thinking_level": "high"}, reasoning="low"
)
- assert err is None
- assert resp.response.output.content.mime_type == "audio/wav"
-
-
-def test_execute_text_forwards_max_output_tokens():
- provider = _provider()
- config = NativeCompletionConfig(
- provider="google-gcp-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash", "max_output_tokens": 256},
- )
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("ok")),
- ) as mock_post:
- resp, err = provider.execute(config, QueryParams(input="ignored"), "hi")
-
- assert err is None
- payload = mock_post.call_args.kwargs["json"]
- assert payload["generationConfig"]["maxOutputTokens"] == 256
+ provider.execute(config, query, "hello")
+ assert create_kwargs(client)["generation_config"] == {"thinking_level": "high"}
+
+ def test_reasoning_used_as_thinking_level_fallback(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction()
+ provider.execute(_text_config(reasoning="minimal"), query, "hello")
+ assert create_kwargs(client)["generation_config"] == {
+ "thinking_level": "minimal"
+ }
+ def test_accepts_multimodal_list_input(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(
+ text="described"
+ )
+ parts = [
+ TextContent(value="describe this"),
+ ImageContent(format="base64", value="aW1n", mime_type="image/png"),
+ ImageContent(
+ format="url", value="https://x/img.png", mime_type="image/png"
+ ),
+ PDFContent(format="base64", value="cGRm", mime_type="application/pdf"),
+ PDFContent(
+ format="url", value="https://x/doc.pdf", mime_type="application/pdf"
+ ),
+ ]
+ resp, err = provider.execute(_text_config(), query, parts)
+ assert err is None
+ assert create_kwargs(client)["input"][0]["content"] == [
+ {"type": "text", "text": "describe this"},
+ {"type": "image", "data": "aW1n", "mime_type": "image/png"},
+ {"type": "image", "uri": "https://x/img.png", "mime_type": "image/png"},
+ {"type": "document", "data": "cGRm", "mime_type": "application/pdf"},
+ {
+ "type": "document",
+ "uri": "https://x/doc.pdf",
+ "mime_type": "application/pdf",
+ },
+ ]
-def test_execute_text_http_error_returns_message():
- provider = _provider()
- config = NativeCompletionConfig(
- provider="google-gcp-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash"},
- )
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_err(403, "permission denied"),
- ):
- resp, err = provider.execute(config, QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "403" in err
- assert "permission denied" in err
+ def test_accepts_multimodal_input_wrapper(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction()
+ multimodal = MultiModalInput(parts=[TextContent(value="hi")])
+ resp, err = provider.execute(_text_config(), query, multimodal)
+ assert err is None
+ assert create_kwargs(client)["input"][0]["content"] == [
+ {"type": "text", "text": "hi"}
+ ]
+ def test_missing_content_returns_error(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction(text=None)
+ resp, err = provider.execute(_text_config(), query, "hello")
+ assert resp is None
+ assert err.startswith("[GOOGLE_GCP] Text response is missing generated content")
-def test_execute_text_forwards_system_instruction():
- provider = _provider()
- config = NativeCompletionConfig(
- provider="google-gcp-native",
- type=CompletionType.TEXT,
- params={"model": "gemini-2.5-flash", "instructions": "be terse"},
- )
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_stt_response("ok")),
- ) as mock_post:
- resp, err = provider.execute(config, QueryParams(input="ignored"), "hi")
-
- assert err is None
- payload = mock_post.call_args.kwargs["json"]
- assert payload["systemInstruction"] == {"parts": [{"text": "be terse"}]}
-
-
-def test_execute_tts_language_is_forwarded():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ) as mock_post:
- provider.execute(
- _tts_config(language="en-US"), QueryParams(input="ignored"), "hi"
- )
- speech = mock_post.call_args.kwargs["json"]["generationConfig"]["speechConfig"]
- assert speech["languageCode"] == "en-US"
-
-
-def test_execute_tts_director_notes_set_system_instruction():
- provider = _provider()
- config = _tts_config(provider_specific={"gemini": {"director_notes": "whisper"}})
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ) as mock_post:
- provider.execute(config, QueryParams(input="ignored"), "hi")
- sent = mock_post.call_args.kwargs["json"]
- assert sent["systemInstruction"]["parts"][0]["text"] == "whisper"
-
-
-def test_execute_tts_ogg_conversion_failure_returns_error():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(_tts_response()),
- ), patch(
- "app.services.llm.providers.google_gcp.convert_pcm_to_ogg",
- return_value=(None, "codec missing"),
+ def test_empty_interaction_id_gets_generated_response_id(
+ self, provider, client, query
):
- resp, err = provider.execute(
- _tts_config(response_format="ogg"), QueryParams(input="ignored"), "hi"
+ client.genai.interactions.create.return_value = make_interaction(
+ interaction_id=None
)
- assert resp is None
- assert "unable to convert" in err
- assert "codec missing" in err
-
+ resp, err = provider.execute(_text_config(), query, "hi")
+ assert err is None
+ assert resp.response.provider_response_id.startswith("google-gcp-")
-def test_execute_tts_raw_response_included_when_requested():
- provider = _provider()
- raw = _tts_response()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=_mock_http_ok(raw),
- ):
+ def test_raw_response_included_when_requested(self, provider, client, query):
+ client.genai.interactions.create.return_value = make_interaction()
resp, _ = provider.execute(
- _tts_config(),
- QueryParams(input="ignored"),
- "hi",
- include_provider_raw_response=True,
+ _text_config(), query, "hello", include_provider_raw_response=True
)
- assert resp.provider_raw_response == raw
-
-
-def test_post_connection_error_returns_clean_message():
- provider = _provider()
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- side_effect=requests.ConnectionError("dns boom"),
- ):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "connection failed" in err
-
-
-def test_post_http_error_falls_back_to_raw_text_when_body_not_json():
- provider = _provider()
- resp_mock = MagicMock()
- resp_mock.ok = False
- resp_mock.status_code = 400
- resp_mock.text = "plain text error"
- resp_mock.json.side_effect = ValueError("not json")
- with patch(
- "app.services.llm.providers.google_gcp.requests.post",
- return_value=resp_mock,
+ assert resp.provider_raw_response["id"] == "int_123"
+
+
+class TestExecuteErrors:
+ @pytest.mark.parametrize(
+ ("status_code", "expected_prefix"),
+ [
+ (400, "[GOOGLE_GCP] Bad request (code: 400)"),
+ (401, "[GOOGLE_GCP] Authentication / permission denied (code: 401)"),
+ (403, "[GOOGLE_GCP] Authentication / permission denied (code: 403)"),
+ (404, "[GOOGLE_GCP] Resource not found (code: 404)"),
+ (429, "[GOOGLE_GCP] Rate limit / quota exceeded (code: 429)"),
+ (500, "[GOOGLE_GCP] Server error (code: 500)"),
+ (503, "[GOOGLE_GCP] Server error (code: 503)"),
+ (418, "[GOOGLE_GCP] HTTP error (code: 418)"),
+ ],
+ )
+ def test_api_status_error_branches(
+ self, provider, client, query, status_code, expected_prefix
):
- _, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert "plain text error" in err
-
+ client.genai.interactions.create.side_effect = status_error(
+ status_code, "upstream detail"
+ )
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith(f"{expected_prefix}: upstream detail")
-def test_execute_dispatches_stt_validation():
- """Only the dispatch branch + input validation are exercised here."""
- provider = _provider()
- config = NativeCompletionConfig(
- provider="google-native",
- type=CompletionType.STT,
- params={"model": "gemini-2.5-flash"},
- )
- resp, err = provider.execute(
- config, QueryParams(input="ignored"), "/not/an/audioref"
- )
- assert resp is None
- assert "AudioRef input" in err
-
-
-def test_execute_wraps_type_error():
- provider = _provider()
- with patch.object(provider, "_execute_tts", side_effect=TypeError("bad kwarg")):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "Invalid or unexpected parameter" in err
- assert "bad kwarg" in err
-
-
-def test_execute_wraps_unexpected_exception():
- provider = _provider()
- with patch.object(provider, "_execute_tts", side_effect=RuntimeError("kaboom")):
- resp, err = provider.execute(_tts_config(), QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "Unexpected error" in err
- assert "kaboom" in err
-
-
-def test_execute_unsupported_completion_type_returns_error():
- provider = _provider()
- # SimpleNamespace bypasses NativeCompletionConfig's enum validation to reach
- # the dispatcher's unsupported-type fallthrough.
- from types import SimpleNamespace
-
- config = SimpleNamespace(provider="google-gcp-native", type="embedding")
- resp, err = provider.execute(config, QueryParams(input="ignored"), "hi")
- assert resp is None
- assert "Unsupported completion type" in err
- assert "embedding" in err
-
-
-# ---------------------------------------------------------------------------
-# GoogleGCPClient.endpoint — host changes by location
-# ---------------------------------------------------------------------------
-class TestGoogleGCPEndpoint:
- def _client(self, location: str) -> GoogleGCPClient:
- return GoogleGCPClient(
- api_key="k",
- project_id="my-proj",
- location=location,
- sa_info=None,
- gcs_bucket="b",
+ def test_text_http_error_returns_message(self, provider, client, query):
+ client.genai.interactions.create.side_effect = status_error(
+ 403, "permission denied"
)
+ resp, err = provider.execute(_text_config(), query, "hi")
+ assert resp is None
+ assert "403" in err
+ assert "permission denied" in err
- def test_regional_location_uses_prefixed_host(self):
- url = self._client("us-central1").endpoint("gemini-2.5-pro")
- assert url.startswith("https://us-central1-aiplatform.googleapis.com/")
- assert "/projects/my-proj/locations/us-central1/" in url
- assert url.endswith("/models/gemini-2.5-pro:generateContent")
-
- def test_global_location_uses_bare_host(self):
- """The 'global' location does NOT use a hostname prefix — it must
- resolve to ``aiplatform.googleapis.com``. Caught a real 404 outage
- where a global config produced ``global-aiplatform.googleapis.com``."""
- url = self._client("global").endpoint("gemini-2.5-pro")
- assert url.startswith("https://aiplatform.googleapis.com/")
- assert "global-aiplatform" not in url
- assert "/locations/global/" in url
-
- def test_other_regions_get_prefix(self):
- url = self._client("europe-west4").endpoint("gemini-2.5-flash")
- assert "europe-west4-aiplatform.googleapis.com" in url
-
-
-# ---------------------------------------------------------------------------
-# _load_platform_sa_info — env-var shape handling
-# ---------------------------------------------------------------------------
-class TestLoadPlatformSaInfo:
- """The platform SA can be injected as a raw JSON string via env var or
- secret manager. Cover the parse paths and the unhappy ones."""
-
- def _sample_sa(self) -> dict:
- return {
- "type": "service_account",
- "project_id": "platform-project",
- "client_email": "sa@platform-project.iam.gserviceaccount.com",
- "private_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
- }
+ def test_timeout_returns_clean_message(self, provider, client, query):
+ client.genai.interactions.create.side_effect = compat_errors.APITimeoutError(
+ request=VERTEX_REQUEST
+ )
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Google GCP request timed out")
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_returns_none_when_unset(self, mock_settings):
- mock_settings.GCP_SA_KEY = ""
- assert _load_platform_sa_info() is None
-
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_parses_raw_json_string(self, mock_settings):
- sa = self._sample_sa()
- mock_settings.GCP_SA_KEY = json.dumps(sa)
- assert _load_platform_sa_info() == sa
-
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_strips_surrounding_whitespace(self, mock_settings):
- """env-var injection often leaves trailing newlines — must still parse."""
- sa = self._sample_sa()
- mock_settings.GCP_SA_KEY = "\n " + json.dumps(sa) + " \n"
- assert _load_platform_sa_info() == sa
-
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_returns_none_on_malformed_json(self, mock_settings):
- """A JSON-looking but invalid value must not raise — it returns None
- and lets create_client raise the missing-fields ValueError later."""
- mock_settings.GCP_SA_KEY = "{not valid json"
- assert _load_platform_sa_info() is None
-
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_non_json_string_returns_none(self, mock_settings):
- """Anything not starting with '{' is treated as non-JSON and ignored —
- this guards against accidentally interpreting a path or sentinel as a key."""
- mock_settings.GCP_SA_KEY = "/etc/secrets/sa.json"
- assert _load_platform_sa_info() is None
-
-
-# ---------------------------------------------------------------------------
-# create_client — credential precedence (BYOK overrides platform settings)
-# ---------------------------------------------------------------------------
-class TestCreateClientFallback:
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_byok_overrides_platform_settings(self, mock_settings):
- mock_settings.GCP_VERTEX_API_KEY = "platform-key"
- mock_settings.GCP_PROJECT_ID = "platform-proj"
- mock_settings.GCP_VERTEX_LOCATION = "us-central1"
- mock_settings.GCP_SA_KEY = ""
- mock_settings.GCS_AUDIO_BUCKET = "platform-bucket"
-
- c = GoogleGCPProvider.create_client(
- {
- "api_key": "byok-key",
- "project_id": "byok-proj",
- "location": "europe-west4",
- "sa_key": {"type": "service_account"},
- "gcs_bucket": "byok-bucket",
- }
+ def test_connection_error_returns_clean_message(self, provider, client, query):
+ client.genai.interactions.create.side_effect = compat_errors.APIConnectionError(
+ request=VERTEX_REQUEST
)
- assert c.api_key == "byok-key"
- assert c.project_id == "byok-proj"
- assert c.location == "europe-west4"
- assert c.gcs_bucket == "byok-bucket"
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Google GCP connection failed")
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_partial_byok_fills_from_platform(self, mock_settings):
- """When BYOK only supplies api_key, project/location come from settings."""
- mock_settings.GCP_VERTEX_API_KEY = "platform-key"
- mock_settings.GCP_PROJECT_ID = "platform-proj"
- mock_settings.GCP_VERTEX_LOCATION = "us-central1"
- mock_settings.GCP_SA_KEY = json.dumps({"type": "service_account"})
- mock_settings.GCS_AUDIO_BUCKET = "platform-bucket"
-
- c = GoogleGCPProvider.create_client({"api_key": "byok-key"})
- assert c.api_key == "byok-key"
- assert c.project_id == "platform-proj"
- assert c.location == "us-central1"
+ def test_google_auth_error_returns_auth_failed(self, provider, client, query):
+ # Raised while minting the SA access token, before any HTTP status.
+ client.genai.interactions.create.side_effect = google_auth_errors.RefreshError(
+ "token revoked"
+ )
+ resp, err = provider.execute(_text_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[GOOGLE_GCP] Authentication failed (RefreshError)")
+ assert "token revoked" in err
- @patch("app.services.llm.providers.google_gcp.settings")
- def test_missing_everything_raises_value_error(self, mock_settings):
- mock_settings.GCP_VERTEX_API_KEY = ""
- mock_settings.GCP_PROJECT_ID = ""
- mock_settings.GCP_VERTEX_LOCATION = ""
- mock_settings.GCP_SA_KEY = ""
- mock_settings.GCS_AUDIO_BUCKET = ""
+ def test_wraps_type_error(self, provider, client, query):
+ client.genai.interactions.create.side_effect = TypeError("bad kwarg")
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Invalid or unexpected parameter")
+ assert "bad kwarg" in err
- with pytest.raises(ValueError) as exc_info:
- GoogleGCPProvider.create_client({})
- msg = str(exc_info.value)
- assert "api_key" in msg
- assert "project_id" in msg
- assert "location" in msg
- assert "sa_key" in msg
- assert "gcs_bucket" in msg
+ def test_wraps_unexpected_exception(self, provider, client, query):
+ client.genai.interactions.create.side_effect = RuntimeError("kaboom")
+ resp, err = provider.execute(_tts_config(), query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Unexpected error")
+ assert "kaboom" in err
+
+ def test_unsupported_completion_type_returns_error(self, provider, query):
+ # SimpleNamespace bypasses NativeCompletionConfig's enum validation to
+ # reach the dispatcher's unsupported-type fallthrough.
+ config = SimpleNamespace(provider="google-gcp-native", type="embedding")
+ resp, err = provider.execute(config, query, "hi")
+ assert resp is None
+ assert err.startswith("[KAAPI] Unsupported completion type")
+ assert "embedding" in err
diff --git a/backend/app/tests/services/llm/providers/test_registry.py b/backend/app/tests/services/llm/providers/test_registry.py
index 16e585c3f..530746ac8 100644
--- a/backend/app/tests/services/llm/providers/test_registry.py
+++ b/backend/app/tests/services/llm/providers/test_registry.py
@@ -117,6 +117,9 @@ def test_get_llm_provider_google_gcp_falls_back_to_platform_credentials(
with (
patch("app.crud.credentials.get_provider_credential") as mock_get_creds,
patch("app.services.llm.providers.google_gcp.settings") as mock_settings,
+ patch(
+ "app.services.llm.providers.google_gcp.genai.Client"
+ ) as mock_genai_client,
):
mock_get_creds.return_value = None
mock_settings.GCP_VERTEX_API_KEY = "platform-key"
@@ -133,7 +136,12 @@ def test_get_llm_provider_google_gcp_falls_back_to_platform_credentials(
)
assert isinstance(provider, GoogleGCPProvider)
- assert provider.client.api_key == "platform-key"
+ # The platform SA stub has no private key, so it degrades to the API key.
+ assert mock_genai_client.call_args.kwargs["api_key"] == "platform-key"
+ assert provider.client.genai is mock_genai_client.return_value
+ assert provider.client.project_id == "platform-project"
+ assert provider.client.gcs_bucket == "platform-bucket"
+ assert provider.client.sa_info is None
def test_get_llm_provider_google_gcp_raises_when_platform_credentials_missing(
self, db: Session
@@ -161,7 +169,7 @@ def test_get_llm_provider_google_gcp_raises_when_platform_credentials_missing(
organization_id=project.organization_id,
)
- assert "missing required fields" in str(exc_info.value)
+ assert "Google GCP credentials missing" in str(exc_info.value)
def test_google_native_routes_to_aistudio(self, db: Session):
"""``google`` / ``google-native`` currently route to GoogleAIProvider
diff --git a/backend/app/tests/services/llm/test_mappers.py b/backend/app/tests/services/llm/test_mappers.py
index e36f539df..56bcf8bc0 100644
--- a/backend/app/tests/services/llm/test_mappers.py
+++ b/backend/app/tests/services/llm/test_mappers.py
@@ -7,6 +7,7 @@
from sqlmodel import Session
+from app.models.llm.constants import DEFAULT_ELEVENLABS_TTS_MODEL
from app.models.llm.request import (
NativeCompletionConfig,
STTLLMParams,
@@ -210,7 +211,6 @@ def test_text_completion_basic(self):
assert result == {
"model": "gemini-2.5-pro",
- "temperature": 0.7,
"video_part_config": {"videoMetadata": {"fps": 1.0}},
"media_resolution": "MEDIA_RESOLUTION_LOW",
}
@@ -228,7 +228,7 @@ def test_text_completion_with_reasoning(self):
assert result["model"] == "gemini-2.5-pro"
assert result["reasoning"] == "high"
- assert result["temperature"] == 0.5
+ assert "temperature" not in result
assert warnings == []
def test_text_completion_knowledge_base_passthrough(self):
@@ -271,10 +271,31 @@ def test_text_completion_top_p_and_max_output_tokens(self):
kaapi_params.model_dump(exclude_none=True), completion_type="text"
)
- assert result["top_p"] == 0.8
+ assert "top_p" not in result
assert result["max_output_tokens"] == 512
assert warnings == []
+ def test_text_completion_temperature_and_top_p_dropped_silently(self):
+ kaapi_params = TextLLMParams(model="gemini-2.5-pro", temperature=0.0, top_p=0.5)
+
+ result, warnings = map_kaapi_to_google_params(
+ kaapi_params.model_dump(exclude_none=True), completion_type="text"
+ )
+
+ assert "temperature" not in result
+ assert "top_p" not in result
+ assert warnings == []
+
+ def test_stt_completion_drops_temperature_silently(self):
+ kaapi_params = STTLLMParams(model="gemini-2.5-pro", temperature=0.3)
+
+ result, warnings = map_kaapi_to_google_params(
+ kaapi_params.model_dump(exclude_none=True), completion_type="stt"
+ )
+
+ assert "temperature" not in result
+ assert warnings == []
+
def test_text_completion_thinking_level_maps_to_thinking_config(self):
kaapi_params = TextLLMParams(model="gemini-2.5-pro", thinking_level="high")
@@ -336,9 +357,8 @@ def test_tts_completion_with_voice(self):
assert result["model"] == "gemini-2.5-pro"
assert result["voice"] == "Orus"
- assert (
- result["language"] == "en"
- ) # Mapped from en-IN to en via BCP47_LOCALE_TO_GEMINI_LANG
+ # Full locale is kept: collapsing en-IN to "en" loses the Indian accent.
+ assert result["language"] == "en-IN"
assert result["response_format"] == "wav" # Default
assert warnings == []
@@ -400,12 +420,12 @@ def test_tts_language_missing_uses_auto_detect(self):
assert "language" not in result # Not set = auto-detect
assert warnings == []
- def test_tts_unsupported_language_warns_and_auto_detects(self):
- """Test TTS with unsupported language generates warning and uses auto-detect."""
+ @pytest.mark.parametrize("language", ["fr-FR", "as-IN"])
+ def test_tts_unsupported_language_falls_back_to_auto_detect(self, language):
kaapi_params = TTSLLMParams(
model="gemini-2.5-pro",
voice="Kore",
- language="fr-FR", # French not in BCP47_LOCALE_TO_GEMINI_LANG
+ language=language,
)
result, warnings = map_kaapi_to_google_params(
@@ -414,14 +434,36 @@ def test_tts_unsupported_language_warns_and_auto_detects(self):
assert result["model"] == "gemini-2.5-pro"
assert result["voice"] == "Kore"
- assert result["response_format"] == "wav" # Default
- assert "language" not in result # Not set, falls back to auto-detect
- assert len(warnings) == 1
- assert "Unsupported language 'fr-FR'" in warnings[0]
- assert "auto-detect" in warnings[0]
+ assert result["response_format"] == "wav"
+ assert "language" not in result
+ assert warnings == [
+ f"Unsupported language '{language}' for Gemini TTS, using auto-detect"
+ ]
+
+ def test_tts_odia_maps_to_standard_locale(self):
+ # Kaapi's canonical Odia tag is the non-standard od-IN.
+ kaapi_params = TTSLLMParams(
+ model="gemini-2.5-pro", voice="Kore", language="odia"
+ )
- def test_tts_supported_language_maps_correctly(self):
- """Test TTS with supported BCP-47 language maps to Gemini language code."""
+ result, warnings = map_kaapi_to_google_params(
+ kaapi_params.model_dump(exclude_none=True), completion_type="tts"
+ )
+
+ assert result["language"] == "or-IN"
+ assert warnings == []
+
+ def test_tts_without_language_leaves_it_unset(self):
+ kaapi_params = TTSLLMParams(model="gemini-2.5-pro", voice="Kore")
+
+ result, warnings = map_kaapi_to_google_params(
+ kaapi_params.model_dump(exclude_none=True), completion_type="tts"
+ )
+
+ assert "language" not in result
+ assert warnings == []
+
+ def test_tts_indic_language_keeps_full_locale(self):
kaapi_params = TTSLLMParams(
model="gemini-2.5-pro", voice="Kore", language="hi-IN"
)
@@ -432,9 +474,7 @@ def test_tts_supported_language_maps_correctly(self):
assert result["model"] == "gemini-2.5-pro"
assert result["voice"] == "Kore"
- assert (
- result["language"] == "hi"
- ) # BCP47_LOCALE_TO_GEMINI_LANG maps hi-IN -> hi
+ assert result["language"] == "hi-IN"
assert result["response_format"] == "wav" # Default
assert warnings == []
@@ -898,7 +938,7 @@ def test_missing_model_falls_back_to_default(self):
kaapi_params, completion_type="tts"
)
- assert result["model_id"] == "eleven_v3"
+ assert result["model_id"] == DEFAULT_ELEVENLABS_TTS_MODEL
assert result["voice_id"] == "EXAVITQu4vr4xnSDxMaL"
assert result["language_code"] == "en"
assert warnings == []
@@ -1186,9 +1226,7 @@ def test_text_completion_maps_via_google_mapper(self, db: Session):
assert native_config.provider == "google-native"
assert native_config.params["model"] == "gemini-2.5-pro"
- def test_unsupported_language_emits_warning(self, db: Session):
- """Languages not in BCP47_LOCALE_TO_GEMINI_LANG fall back to auto-detect
- and surface a warning, rather than silently being dropped."""
+ def test_unlisted_language_falls_back_to_auto_detect(self, db: Session):
kaapi_config = build_kaapi_completion_config(
provider="google",
type="tts",
@@ -1203,9 +1241,10 @@ def test_unsupported_language_emits_warning(self, db: Session):
)
assert native_config.provider == "google-native"
- assert "language" not in native_config.params # dropped
- assert len(warnings) == 1
- assert "xx-YY" in warnings[0]
+ assert "language" not in native_config.params
+ assert warnings == [
+ "Unsupported language 'xx-YY' for Gemini TTS, using auto-detect"
+ ]
class TestTransformGoogleGCPRouting:
@@ -1424,7 +1463,7 @@ def test_transform_google_text_completion(self, db: Session):
assert result.provider == "google-aistudio-native"
assert result.type == "text"
assert result.params["model"] == "gemini-2.5-pro"
- assert result.params["temperature"] == 0.7
+ assert "temperature" not in result.params
assert result.params["reasoning"] == "high"
assert warnings == []
@@ -1468,7 +1507,7 @@ def test_transform_google_tts_completion(self, db: Session):
assert result.type == "tts"
assert result.params["model"] == "gemini-2.5-flash-preview-tts"
assert result.params["voice"] == "Kore"
- assert result.params["language"] == "hi" # Mapped from hi-IN
+ assert result.params["language"] == "hi-IN"
assert result.params["response_format"] == "wav" # Default
assert warnings == []
diff --git a/backend/app/tests/services/llm/test_multimodal.py b/backend/app/tests/services/llm/test_multimodal.py
index b7e8acce2..b34574192 100644
--- a/backend/app/tests/services/llm/test_multimodal.py
+++ b/backend/app/tests/services/llm/test_multimodal.py
@@ -1,6 +1,8 @@
import pytest
from unittest.mock import MagicMock
+from google.genai.interactions import Interaction
+
from app.models.llm.request import (
TextInput,
AudioInput,
@@ -224,15 +226,12 @@ class TestGoogleAIFormatParts:
def test_text_part(self):
parts = [TextContent(value="hello")]
result = GoogleAIProvider.format_parts(parts)
- assert result == [{"text": "hello"}]
+ assert result == [{"type": "text", "text": "hello"}]
def test_image_base64_part(self):
parts = [ImageContent(format="base64", value="abc123", mime_type="image/png")]
result = GoogleAIProvider.format_parts(parts)
- assert len(result) == 1
- assert result[0] == {
- "inline_data": {"data": "abc123", "mime_type": "image/png"}
- }
+ assert result == [{"type": "image", "data": "abc123", "mime_type": "image/png"}]
def test_image_url_part(self):
parts = [
@@ -243,22 +242,27 @@ def test_image_url_part(self):
)
]
result = GoogleAIProvider.format_parts(parts)
- assert result[0] == {
- "file_data": {
- "file_uri": "https://example.com/img.jpg",
+ assert result == [
+ {
+ "type": "image",
+ "uri": "https://example.com/img.jpg",
"mime_type": "image/jpeg",
- "display_name": None,
}
- }
+ ]
+
+ def test_image_without_mime_type_omits_key(self):
+ parts = [ImageContent(format="url", value="https://example.com/img.jpg")]
+ result = GoogleAIProvider.format_parts(parts)
+ assert result == [{"type": "image", "uri": "https://example.com/img.jpg"}]
def test_pdf_base64_part(self):
parts = [
PDFContent(format="base64", value="pdf123", mime_type="application/pdf")
]
result = GoogleAIProvider.format_parts(parts)
- assert result[0] == {
- "inline_data": {"data": "pdf123", "mime_type": "application/pdf"}
- }
+ assert result == [
+ {"type": "document", "data": "pdf123", "mime_type": "application/pdf"}
+ ]
def test_pdf_url_part(self):
parts = [
@@ -269,13 +273,13 @@ def test_pdf_url_part(self):
)
]
result = GoogleAIProvider.format_parts(parts)
- assert result[0] == {
- "file_data": {
- "file_uri": "https://example.com/doc.pdf",
+ assert result == [
+ {
+ "type": "document",
+ "uri": "https://example.com/doc.pdf",
"mime_type": "application/pdf",
- "display_name": None,
}
- }
+ ]
def test_mixed_parts(self):
parts = [
@@ -286,10 +290,9 @@ def test_mixed_parts(self):
PDFContent(format="base64", value="pdf", mime_type="application/pdf"),
]
result = GoogleAIProvider.format_parts(parts)
- assert len(result) == 3
- assert "text" in result[0]
- assert "file_data" in result[1]
- assert "inline_data" in result[2]
+ assert [item["type"] for item in result] == ["text", "image", "document"]
+ assert result[1]["uri"] == "https://img.com/a.jpg"
+ assert result[2]["data"] == "pdf"
class TestResolveImageContent:
@@ -433,15 +436,24 @@ def test_string_input(self):
class TestGoogleAIExecuteTextRouting:
def _make_provider(self):
mock_client = MagicMock()
- mock_resp = MagicMock()
- mock_resp.response_id = "resp_gai_123"
- mock_resp.model_version = "gemini-2.0-flash"
- mock_resp.text = "response text"
- mock_resp.usage_metadata.prompt_token_count = 10
- mock_resp.usage_metadata.candidates_token_count = 5
- mock_resp.usage_metadata.total_token_count = 15
- mock_resp.usage_metadata.thoughts_token_count = 0
- mock_client.models.generate_content.return_value = mock_resp
+ mock_client.interactions.create.return_value = Interaction.model_validate(
+ {
+ "id": "int_gai_123",
+ "status": "completed",
+ "model": "gemini-2.0-flash",
+ "steps": [
+ {
+ "type": "model_output",
+ "content": [{"type": "text", "text": "response text"}],
+ }
+ ],
+ "usage": {
+ "total_input_tokens": 10,
+ "total_output_tokens": 5,
+ "total_tokens": 15,
+ },
+ }
+ )
return GoogleAIProvider(client=mock_client), mock_client
def _make_config(self, **extra_params):
@@ -454,6 +466,13 @@ def _make_config(self, **extra_params):
def _make_query(self):
return QueryParams(input="test")
+ @staticmethod
+ def _user_content(mock_client):
+ user_input = mock_client.interactions.create.call_args.kwargs["input"]
+ assert len(user_input) == 1
+ assert user_input[0]["type"] == "user_input"
+ return user_input[0]["content"]
+
def test_multimodal_input(self):
provider, mock_client = self._make_provider()
mm = MultiModalInput(
@@ -468,9 +487,10 @@ def test_multimodal_input(self):
resolved_input=mm,
)
assert error is None
- call_kwargs = mock_client.models.generate_content.call_args[1]
- assert call_kwargs["contents"][0]["role"] == "user"
- assert len(call_kwargs["contents"][0]["parts"]) == 2
+ assert self._user_content(mock_client) == [
+ {"type": "text", "text": "describe"},
+ {"type": "image", "data": "img", "mime_type": "image/png"},
+ ]
def test_list_input(self):
provider, mock_client = self._make_provider()
@@ -481,8 +501,9 @@ def test_list_input(self):
resolved_input=parts,
)
assert error is None
- call_kwargs = mock_client.models.generate_content.call_args[1]
- assert call_kwargs["contents"][0]["role"] == "user"
+ assert self._user_content(mock_client) == [
+ {"type": "image", "data": "img", "mime_type": "image/png"}
+ ]
def test_string_input(self):
provider, mock_client = self._make_provider()
@@ -492,11 +513,10 @@ def test_string_input(self):
resolved_input="hello",
)
assert error is None
- call_kwargs = mock_client.models.generate_content.call_args[1]
- assert call_kwargs["contents"][0]["parts"] == [{"text": "hello"}]
+ assert self._user_content(mock_client) == [{"type": "text", "text": "hello"}]
+ assert response.response.output.content.value == "response text"
def test_missing_model_falls_back_to_default(self):
- """No model in params → provider uses DEFAULT_TEXT_MODELS['google']."""
provider, mock_client = self._make_provider()
config = NativeCompletionConfig(
provider="google-aistudio-native", type="text", params={}
@@ -508,10 +528,11 @@ def test_missing_model_falls_back_to_default(self):
)
assert error is None
assert response is not None
- call_kwargs = mock_client.models.generate_content.call_args[1]
- assert call_kwargs["model"] == "gemini-2.5-pro"
+ assert mock_client.interactions.create.call_args.kwargs["model"] == (
+ "gemini-2.5-pro"
+ )
- def test_instructions_passed_to_config(self):
+ def test_instructions_passed_as_system_instruction(self):
provider, mock_client = self._make_provider()
response, error = provider.execute(
completion_config=self._make_config(instructions="be helpful"),
@@ -519,14 +540,12 @@ def test_instructions_passed_to_config(self):
resolved_input="hello",
)
assert error is None
- call_kwargs = mock_client.models.generate_content.call_args[1]
- config = call_kwargs["config"]
- assert config.system_instruction == "be helpful"
+ call_kwargs = mock_client.interactions.create.call_args.kwargs
+ assert call_kwargs["system_instruction"] == "be helpful"
- def test_no_usage_metadata(self):
+ def test_no_usage(self):
provider, mock_client = self._make_provider()
- mock_resp = mock_client.models.generate_content.return_value
- mock_resp.usage_metadata = None
+ mock_client.interactions.create.return_value.usage = None
response, error = provider.execute(
completion_config=self._make_config(),
query=self._make_query(),
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 0ca77e4fd..c1eda18d6 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -47,7 +47,7 @@ dependencies = [
"celery>=5.3.0,<6.0.0",
"redis>=5.0.0,<6.0.0",
"flower>=2.0.1",
- "google-genai>=1.59.0",
+ "google-genai>=2.25.0",
"sarvamai>=0.1.25",
"pydub>=0.25.1",
"jiwer>=3.1.0",
diff --git a/backend/uv.lock b/backend/uv.lock
index 1f5c8ed49..4021933fe 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -341,7 +341,7 @@ requires-dist = [
{ name = "gevent", specifier = ">=25.9.1" },
{ name = "google-auth", specifier = ">=2.49.1" },
{ name = "google-cloud-storage", specifier = ">=3.10.1" },
- { name = "google-genai", specifier = ">=1.59.0" },
+ { name = "google-genai", specifier = ">=2.25.0" },
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" },
{ name = "indic-nlp-library", specifier = ">=0.92" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
@@ -1379,15 +1379,15 @@ wheels = [
[[package]]
name = "google-auth"
-version = "2.49.1"
+version = "2.61.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/0b/9788e913f2202da49068c27ce821eebcf96319240a89d7bb11f206d6471f/google_auth-2.61.0.tar.gz", hash = "sha256:37f0815967322e8c32b12bf422531e8b637cafdaae0acbb9141117cfe6a96f23", size = 398379, upload-time = "2026-10-07T20:13:14.827Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/68/c2ff6043d93fda69e5c12a834d0be3b3fa6c762e89732160532566be621b/google_auth-2.61.0-py3-none-any.whl", hash = "sha256:ca60266a37475ae68b63bac007272f46094b3d571abe92aded329b6cfb568025", size = 268021, upload-time = "2026-10-07T20:13:13.2Z" },
]
[package.optional-dependencies]
@@ -1450,7 +1450,7 @@ wheels = [
[[package]]
name = "google-genai"
-version = "1.67.0"
+version = "2.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1464,9 +1464,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a2/07/59a498f81f2c7b0649eacda2ea470b7fd8bd7149f20caba22962081bdd51/google_genai-1.67.0.tar.gz", hash = "sha256:897195a6a9742deb6de240b99227189ada8b2d901d61bdfba836c3092021eab6", size = 506972, upload-time = "2026-03-12T20:39:16.241Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/2b/977616ef350e51d3d263eb43be23a91be793544cd937b51c0fac313be233/google_genai-2.29.0.tar.gz", hash = "sha256:124d3eef9ff6b9e4ea548f95ff5b7721683e7990c0a936b006d9e2ab61ea897d", size = 706902, upload-time = "2026-10-07T22:22:57.153Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/c2/562aa1f086e53529ffbeb5b43d5d8bc42c1b968102b5e2163fad005ce298/google_genai-1.67.0-py3-none-any.whl", hash = "sha256:58b0484ff2d4335fa53c724b489e9f807fcca8115d9cdbd8fdf341121fbd6d2d", size = 733542, upload-time = "2026-03-12T20:39:14.615Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/27/a3dede4a1deb21f7fde6a171daa039bf0ea149a5e7826a785e8f109a6e13/google_genai-2.29.0-py3-none-any.whl", hash = "sha256:7adda55cb6746fb46d38ad3202c3372840b96eede964b5fb3df1469cd4998570", size = 1167603, upload-time = "2026-10-07T22:22:55.215Z" },
]
[[package]]
diff --git a/docs/wiki/modules/llm-call.md b/docs/wiki/modules/llm-call.md
index cc61df5d4..7a0203478 100644
--- a/docs/wiki/modules/llm-call.md
+++ b/docs/wiki/modules/llm-call.md
@@ -29,13 +29,14 @@ All paths relative to `backend/app/`.
- `LLMCallConfig` — one-of: saved reference (`id` + `version`) XOR ad-hoc `blob` (validator-enforced)
- `ConfigBlob` — `completion` + optional `prompt_template` (`PromptTemplate.template`, plain string; `{{input}}` interpolation is llm-chain-only) + `input_guardrails`/`output_guardrails`. Each guardrail entry is either a `Validator` reference (`{validator_config_id}`) or an `InlineValidator` spec (`{type, on_fail, ...per-type params}`, `type` limited to `InlineValidatorTypeEnum`: gender_assumption_bias, pii_remover, profanity_free, topic_relevance_llm); tagged union `GuardrailEntry`. Both carry `is_enabled` (default true). Disabled entries are never fetched or sent. `services/llm/guardrails.apply_guardrails` POSTs fetched reference rows plus inline specs (a blob is all-refs or all-inline), with `is_enabled` stripped and `stage` stamped on inline specs.
- `CompletionConfig` — discriminated union on `provider`: `KaapiCompletionConfig` (standardized params: `TextLLMParams`/`STTLLMParams`/`TTSLLMParams`), `NativeCompletionConfig` (pass-through), `ProxyCompletionConfig` (client's own endpoint)
-- `TextLLMParams` reasoning knobs: `reasoning` + `effort` (OpenAI-style), `thinking` (Anthropic adaptive-thinking container, forwarded as-is) and `thinking_level` (Gemini). A knob must be **declared here** to survive config save — pydantic's default extra policy is ignore, so an undeclared key is dropped silently at validation with no error.
+- `TextLLMParams` reasoning knobs: `reasoning` + `effort` (OpenAI-style), `thinking` (Anthropic adaptive-thinking container, forwarded as-is) and `thinking_level` (Gemini: `minimal`/`low`/`medium`/`high`; `minimal` is flash-only). A knob must be **declared here** to survive config save — pydantic's default extra policy is ignore, so an undeclared key is dropped silently at validation with no error.
- `QueryParams` — per-call input + `ConversationConfig`
- `models/guardrails/` — validator config shapes
## Services / CRUD
- `services/llm/` — `mappers.py` (Kaapi params → provider API), `providers/`, `chain/`, `guardrails.py`, `jobs.py`
- `jobs.py::execute_llm_call` is the single shared invocation, reached from `/llm/call`, from `ChainBlock.execute`, and from fast evaluation runs. Two knobs shape what it records: `record_call` (default `True`) turns off the `LlmCall` row, the AI spans and the LLM metrics in one switch, for traffic that is not production. The switch lives on `_CallContext` (`ctx.span`, `ctx.metrics_started/finished`, and `_create_call_record` check it), so add a new span, metric or row write through `ctx` rather than calling the tracer or `record_llm_call_*` directly. Langfuse is turned off separately by passing `langfuse_credentials=None`. `BlockResult.guardrail_outcome` (`GuardrailOutcomeEnum.BLOCKED` / `.REPHRASED` / `None`, a `StrEnum` in `chain/types.py`) tells a caller a guardrail verdict apart from a provider failure, since both otherwise arrive as a bare string in `error` — see [guardrails.md](guardrails.md) §4, §6. `BlockResult.retryable` answers the other question that string cannot: whether re-running the identical call could succeed. It defaults to `False` and is set only where the provider or the proxy call actually failed, so a failure path added later fails fast instead of inheriting three attempts.
+- **Gemini/Vertex retries:** both `genai.Client`s set `HttpRetryOptions(attempts=3)`. Interactions reads `attempts` as the retry count (4 attempts, on 408/409/429/5xx) while main-client calls such as AI Studio STT `files.upload` read it as total attempts (3).
- `mappers.py` is the **only** param mapper in the codebase; the assessment fork was merged back into it. Callers: `crud/evaluations/{batch,fast,judge}.py`, `crud/assessment/batch.py`, `services/assessment/api/batch.py`, `services/assessment/prefilter/request_builder.py`. Structured output is keyed `output_schema` for every provider (OpenAI `text.format`, Anthropic `output_config.format`, Gemini `output_schema`), and Anthropic reads `effort` into `output_config.effort`. Every param that only assessment set (`top_p`, `max_output_tokens`, `thinking_level`, `thinking`, `output_schema`) defaults to `None` on `TextLLMParams` and is dropped by `ParamSerialization._dump_compact`, so a config that does not set it maps exactly as before. Anthropic never receives `temperature`/`top_p`: the Messages API returns 400 for a non-default sampling value on every Claude model Kaapi serves, so the mapper drops both, warning only when the value was not Anthropic's own default of 1.0. `normalize_llm_text` no longer lives here; it moved to `services/assessment/validators.py`, the only place that calls it.
- `services/guardrails/` — validator execution
- `crud/llm.py`, `crud/llm_chain.py`, `crud/config/` — persistence
@@ -45,6 +46,7 @@ All paths relative to `backend/app/`.
## External
- OpenAI / Gemini / Anthropic (via `services/llm/providers/`), proxy endpoints, Langfuse tracing.
+- Both Gemini providers call the **Interactions API** (`client.interactions.create`; TTS strips the WAV header when present — 3.8 models return WAV, preview models return raw 24 kHz PCM and reject `audio/l16` — then Kaapi re-wraps to wav/mp3/ogg). AI Studio (`providers/google_aistudio.py`) uses an API key and `files.upload` for STT audio; Vertex (`providers/google_gcp.py`) uses `genai.Client(vertexai=True)` and stages STT audio in GCS as a `gs://` URI. Vertex auth: each credential field falls back to platform settings independently; the SA key (+ project, defaulting to the key's own `project_id`) is preferred, else the API key in express mode — the SDK rejects both together. A malformed SA key is logged and skipped, not raised. Vertex may return an empty interaction id, so it falls back to `google-gcp-`. Thinking level resolves the same way in both: `thinking_config.thinking_level` → `reasoning`, omitted if neither; STT defaults to `low`. Interactions errors come from `google.genai._gaos.lib.compat_errors`, not `google.genai.errors` (which `files.upload` still raises).
## Gotchas
- Saved config resolution = `config_id` + `version` pinned; ad-hoc blob never persisted.