feat(integrations): add OCI Generative AI provider

Adds OCIGenAILlm under integrations/oci/, for Google Gemini and other models
hosted on Oracle Cloud Infrastructure Generative AI. Optional install:
pip install google-adk[oci]. LLMRegistry auto-routing and the
google.adk.models import surface are preserved.

The OpenAI-compatible transport from the source PR (OCIGenAIOpenAILlm) is
not taken. It reimplemented the message, tool and response conversion plus
the streaming loop that OpenAILlm already provides; the right form is a
small subclass overriding the OpenAI client, which cannot live in
integrations/ while OpenAILlm is still experimental. It can land separately
once that settles.

The OCI client is now built once per instance rather than per request, so a
call no longer re-reads the OCI config from disk.

Merge https://github.com/google/adk-python/pull/5285

Closes #5069

Co-authored-by: George Weale <gweale@google.com>
COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5285 from fede-kamel:feat/oci-generative-ai 0230acc0a93b7e43014f2ef3a8b89de463a50bd8
PiperOrigin-RevId: 955453382
This commit is contained in:
Fede Kamelhar
2026-07-28 13:55:54 -07:00
committed by Copybara-Service
parent 94832a5151
commit 625ef1aa69
6 changed files with 2796 additions and 0 deletions
+3
View File
@@ -196,6 +196,9 @@ optional-dependencies.mcp = [
"anyio>=4.9,<5",
"mcp>=1.24,<2",
]
optional-dependencies.oci = [
"oci>=2.126", # OCI Generative AI native SDK (OCIGenAILlm)
]
optional-dependencies.otel-gcp = [
"opentelemetry-instrumentation-google-genai>=0.7b1,<1",
"opentelemetry-instrumentation-grpc>=0.43b0,<1",
@@ -0,0 +1,43 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OCI Generative AI integration.
Model providers for Google Gemini and other models hosted on Oracle Cloud
Infrastructure (OCI) Generative AI. Install with: pip install google-adk[oci]
"""
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
from ._oci_genai_llm import OCIGenAILlm
_lazy_imports = {
"OCIGenAILlm": "._oci_genai_llm",
}
def __getattr__(name: str) -> typing.Any:
if name in _lazy_imports:
import importlib
module = importlib.import_module(_lazy_imports[name], __name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__() -> list[str]:
return list(_lazy_imports.keys())
@@ -0,0 +1,653 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OCI Generative AI integration for ADK models."""
from __future__ import annotations
import asyncio
import base64
from functools import cached_property
import importlib.util
import json
import logging
import os
from typing import Any
from typing import AsyncGenerator
from typing import Optional
from typing import TYPE_CHECKING
from google.genai import types
from typing_extensions import override
if not TYPE_CHECKING and importlib.util.find_spec("oci") is None:
raise ImportError(
"OCI Generative AI support requires: pip install google-adk[oci]"
"\nOr: pip install oci"
)
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
if TYPE_CHECKING:
from google.adk.models.llm_request import LlmRequest
__all__ = ["OCIGenAILlm"]
logger = logging.getLogger("google_adk." + __name__)
def _to_oci_role(role: Optional[str]) -> str:
"""Map ADK content role to OCI GenAI role string."""
if role in ("model", "assistant"):
return "ASSISTANT"
return "USER"
def _build_response_format(
cfg: types.GenerateContentConfig, oci_models: Any
) -> Optional[Any]:
"""Map google.genai response config to OCI ResponseFormat.
- ``response_schema`` (Pydantic class, dict, or genai Schema) →
``JsonSchemaResponseFormat`` (strict structured output).
- ``response_mime_type == "application/json"`` only →
``JsonObjectResponseFormat``.
- ``response_mime_type == "text/plain"`` → ``TextResponseFormat`` (default
behaviour; only emitted when explicitly requested).
"""
schema = cfg.response_schema
mime = cfg.response_mime_type or ""
if schema is not None:
schema_dict: dict[str, Any]
if hasattr(schema, "model_json_schema"):
# Pydantic v2 model class
schema_dict = schema.model_json_schema()
elif hasattr(schema, "to_json_dict"):
# google.genai Schema instance
schema_dict = schema.to_json_dict()
elif isinstance(schema, dict):
schema_dict = schema
else:
return None
return oci_models.JsonSchemaResponseFormat(
type="JSON_SCHEMA",
json_schema=oci_models.ResponseJsonSchema(
name=schema_dict.get("title", "response"),
description=schema_dict.get("description"),
schema=schema_dict,
is_strict=True,
),
)
if mime == "application/json":
return oci_models.JsonObjectResponseFormat(type="JSON_OBJECT")
if mime == "text/plain":
return oci_models.TextResponseFormat(type="TEXT")
return None
def _media_blocks_for_part(part: types.Part) -> list[Any]:
"""Map a multimodal Part (inline_data / file_data) to OCI ChatContent blocks.
OCI Generative AI Inference (/20231130/) accepts ImageContent / AudioContent /
VideoContent / DocumentContent, each carrying a URL in ``{kind}_url.url``.
Inline bytes are wrapped as ``data:<mime>;base64,<...>``; file_data passes
the ``file_uri`` through.
Returns an empty list for parts that have no media payload.
"""
import oci.generative_ai_inference.models as oci_models
url: Optional[str] = None
mime: Optional[str] = None
if part.inline_data and part.inline_data.data is not None:
mime = part.inline_data.mime_type or "application/octet-stream"
raw = part.inline_data.data
if isinstance(raw, (bytes, bytearray)):
encoded = base64.b64encode(bytes(raw)).decode("ascii")
else:
encoded = str(raw)
url = f"data:{mime};base64,{encoded}"
elif part.file_data and part.file_data.file_uri:
url = part.file_data.file_uri
mime = part.file_data.mime_type
if not url:
return []
category = (mime or "").split("/", 1)[0].lower()
if category == "image":
return [
oci_models.ImageContent(
type="IMAGE", image_url=oci_models.ImageUrl(url=url)
)
]
if category == "audio":
return [
oci_models.AudioContent(
type="AUDIO", audio_url=oci_models.AudioUrl(url=url)
)
]
if category == "video":
return [
oci_models.VideoContent(
type="VIDEO", video_url=oci_models.VideoUrl(url=url)
)
]
# Documents (application/pdf, text/*, etc.) and any other mime
return [
oci_models.DocumentContent(
type="DOCUMENT", document_url=oci_models.DocumentUrl(url=url)
)
]
def _content_to_oci_message(content: types.Content) -> Any:
"""Convert an ADK Content object to an OCI GenAI message.
OCI GenAI uses:
- ``UserMessage`` for user turns
- ``AssistantMessage`` for model turns (may include ``FunctionCall`` items
in ``tool_calls``)
- ``ToolMessage`` for tool results (function_response parts)
"""
import oci.generative_ai_inference.models as oci_models
text_parts: list[str] = []
media_blocks: list[Any] = []
tool_calls: list[Any] = []
tool_results: list[tuple[str, str]] = [] # (tool_call_id, result_text)
for part in content.parts or []:
if part.text:
text_parts.append(part.text)
elif part.function_call:
# FunctionCall is the OCI subtype of ToolCall that carries name+arguments
tool_calls.append(
oci_models.FunctionCall(
id=part.function_call.id or "",
type=oci_models.FunctionCall.TYPE_FUNCTION,
name=part.function_call.name,
arguments=json.dumps(part.function_call.args or {}),
)
)
elif part.function_response:
result = part.function_response.response or {}
tool_results.append((
part.function_response.id or "",
json.dumps(result) if isinstance(result, dict) else str(result),
))
elif part.inline_data or part.file_data:
media_blocks.extend(_media_blocks_for_part(part))
role = _to_oci_role(content.role)
# Tool results map to ToolMessage (one per result)
if tool_results:
call_id, result_text = tool_results[0]
return oci_models.ToolMessage(
role=oci_models.ToolMessage.ROLE_TOOL,
tool_call_id=call_id,
content=[oci_models.TextContent(type="TEXT", text=result_text)],
)
if role == "ASSISTANT":
oci_content: list[Any] = []
if text_parts:
oci_content.append(
oci_models.TextContent(type="TEXT", text="\n".join(text_parts))
)
return oci_models.AssistantMessage(
role=oci_models.AssistantMessage.ROLE_ASSISTANT,
content=oci_content,
tool_calls=tool_calls or None,
)
user_content: list[Any] = []
if text_parts:
user_content.append(
oci_models.TextContent(type="TEXT", text="\n".join(text_parts))
)
user_content.extend(media_blocks)
return oci_models.UserMessage(
role=oci_models.UserMessage.ROLE_USER,
content=user_content,
)
def _oci_response_to_llm_response(response: Any) -> LlmResponse:
"""Convert an OCI GenAI chat response to an LlmResponse."""
chat_response = response.data.chat_response
parts: list[types.Part] = []
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
if hasattr(chat_response, "usage"):
usage = chat_response.usage
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
output_tokens = getattr(usage, "completion_tokens", 0) or 0
details = getattr(usage, "completion_tokens_details", None)
if details is not None:
reasoning_tokens = getattr(details, "reasoning_tokens", 0) or 0
if hasattr(chat_response, "choices") and chat_response.choices:
choice = chat_response.choices[0]
message = getattr(choice, "message", None)
if message:
# Text content
for block in getattr(message, "content", None) or []:
if hasattr(block, "text") and block.text:
parts.append(types.Part.from_text(text=block.text))
# Tool calls — OCI returns FunctionCall objects directly in tool_calls
for fc in getattr(message, "tool_calls", None) or []:
args: dict[str, Any] = {}
try:
args = json.loads(fc.arguments) if fc.arguments else {}
except (json.JSONDecodeError, TypeError):
args = {}
part = types.Part.from_function_call(
name=fc.name,
args=args,
)
if part.function_call is not None:
part.function_call.id = getattr(fc, "id", "") or ""
parts.append(part)
return LlmResponse(
content=types.Content(role="model", parts=parts),
usage_metadata=types.GenerateContentResponseUsageMetadata(
prompt_token_count=input_tokens,
candidates_token_count=output_tokens,
total_token_count=input_tokens + output_tokens,
thoughts_token_count=reasoning_tokens or None,
),
)
def _function_declaration_to_oci_tool(
fn: types.FunctionDeclaration,
) -> Any:
"""Convert an ADK FunctionDeclaration to an OCI GenAI Tool."""
import oci.generative_ai_inference.models as oci_models
parameters: dict[str, Any] = {"type": "object", "properties": {}}
if fn.parameters_json_schema:
parameters = fn.parameters_json_schema
elif fn.parameters and fn.parameters.properties:
props = {}
for k, v in fn.parameters.properties.items():
props[k] = v.model_dump(by_alias=True, exclude_none=True)
parameters = {
"type": "object",
"properties": props,
}
if fn.parameters.required:
parameters["required"] = fn.parameters.required
return oci_models.FunctionDefinition(
type=oci_models.FunctionDefinition.TYPE_FUNCTION,
name=fn.name,
description=fn.description or "",
parameters=parameters,
)
class OCIGenAILlm(BaseLlm):
"""Integration with OCI Generative AI models.
Supports models hosted on Oracle Cloud Infrastructure Generative AI service,
including Meta Llama, Google Gemini, Google Gemma, and other GenericChat
compatible models.
Example usage::
from google.adk.integrations.oci import OCIGenAILlm
from google.adk.agents import LlmAgent
agent = LlmAgent(
model=OCIGenAILlm(
model="google.gemini-2.0-flash-001",
compartment_id="ocid1.compartment.oc1...",
),
...
)
Attributes:
model: OCI model ID (e.g. ``google.gemini-2.0-flash-001``). Used as the
``model_id`` for on-demand serving. For dedicated serving, set
``endpoint_id`` instead; ``model`` is then informational only.
endpoint_id: Dedicated endpoint OCID (``ocid1.generativeaiendpoint...``).
When set, requests use ``DedicatedServingMode``; otherwise on-demand
mode is used. Falls back to ``OCI_ENDPOINT_ID`` env var when not set.
compartment_id: OCI compartment OCID. Falls back to the
``OCI_COMPARTMENT_ID`` environment variable when not set.
service_endpoint: OCI Generative AI service endpoint URL. Defaults to
the us-chicago-1 endpoint or ``OCI_SERVICE_ENDPOINT`` env var.
auth_type: OCI authentication type. One of ``API_KEY`` (default),
``INSTANCE_PRINCIPAL``, or ``RESOURCE_PRINCIPAL``.
auth_profile: Config profile to use for ``API_KEY`` auth (default:
``DEFAULT``).
auth_file_location: Path to the OCI config file used for ``API_KEY``
auth (default: ``~/.oci/config``).
max_tokens: Maximum number of tokens to generate (default: 2048).
reasoning_effort: Reasoning-token budget for reasoning-capable models.
One of ``"NONE"``, ``"MINIMAL"``, ``"LOW"``, ``"MEDIUM"``, ``"HIGH"``,
or ``None`` (default — let OCI pick). Honoured by GPT-5 family,
Gemini 2.5, Grok reasoning variants, and Cohere Command-A-Reasoning;
ignored by non-reasoning models. The single most impactful cost knob
for reasoning models — ``"LOW"`` typically cuts reasoning-token spend
5-10× vs the default.
"""
model: str = "google.gemini-2.5-flash"
endpoint_id: Optional[str] = None
compartment_id: Optional[str] = None
service_endpoint: Optional[str] = None
auth_type: str = "API_KEY"
auth_profile: str = "DEFAULT"
auth_file_location: str = "~/.oci/config"
max_tokens: int = 2048
reasoning_effort: Optional[str] = None
@classmethod
@override
def supported_models(cls) -> list[str]:
return [
r"meta\.llama-.*",
r"google\.gemini-.*",
r"google\.gemma-.*",
r"xai\.grok-.*",
r"mistralai\.mistral-.*",
r"mistralai\.mixtral-.*",
r"nvidia\..*",
]
@override
async def generate_content_async(
self,
llm_request: LlmRequest,
stream: bool = False,
) -> AsyncGenerator[LlmResponse, None]:
if stream:
async for response in self._generate_content_streaming(llm_request):
yield response
else:
response = await asyncio.to_thread(self._call_oci, llm_request)
yield _oci_response_to_llm_response(response)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _resolve_compartment_id(self) -> str:
compartment_id = self.compartment_id or os.environ.get("OCI_COMPARTMENT_ID")
if not compartment_id:
raise ValueError(
"compartment_id must be set on OCIGenAILlm or via the"
" OCI_COMPARTMENT_ID environment variable."
)
return compartment_id
def _resolve_service_endpoint(self) -> str:
return (
self.service_endpoint
or os.environ.get("OCI_SERVICE_ENDPOINT")
or "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com"
)
@cached_property
def _oci_client(self) -> Any:
return self._build_client(self._resolve_service_endpoint())
def _build_client(self, service_endpoint: str) -> Any:
"""Create an OCI GenerativeAiInferenceClient from auth config."""
import oci
import oci.auth.signers
import oci.generative_ai_inference
if self.auth_type == "INSTANCE_PRINCIPAL":
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
return oci.generative_ai_inference.GenerativeAiInferenceClient(
config={},
signer=signer,
service_endpoint=service_endpoint,
)
elif self.auth_type == "RESOURCE_PRINCIPAL":
signer = oci.auth.signers.get_resource_principals_signer()
return oci.generative_ai_inference.GenerativeAiInferenceClient(
config={},
signer=signer,
service_endpoint=service_endpoint,
)
else: # API_KEY (default)
config = oci.config.from_file(
file_location=self.auth_file_location,
profile_name=self.auth_profile,
)
return oci.generative_ai_inference.GenerativeAiInferenceClient(
config=config,
service_endpoint=service_endpoint,
)
def _build_chat_details(
self, llm_request: LlmRequest, is_stream: bool = False
) -> Any:
"""Build OCI ChatDetails from an LlmRequest."""
import oci.generative_ai_inference.models as oci_models
messages = [_content_to_oci_message(c) for c in llm_request.contents or []]
# Prepend SystemMessage when a system instruction is present
if llm_request.config and llm_request.config.system_instruction:
si = llm_request.config.system_instruction
if isinstance(si, str) and si:
messages = [
oci_models.SystemMessage(
role=oci_models.SystemMessage.ROLE_SYSTEM,
content=[oci_models.TextContent(type="TEXT", text=si)],
)
] + messages
# Convert tool declarations if present
oci_tools: Optional[list[Any]] = None
if llm_request.config and llm_request.config.tools:
first_tool = llm_request.config.tools[0]
if (
isinstance(first_tool, types.Tool)
and first_tool.function_declarations
):
oci_tools = [
_function_declaration_to_oci_tool(fn)
for fn in first_tool.function_declarations
]
chat_request_kwargs: dict[str, Any] = dict(
api_format=oci_models.BaseChatRequest.API_FORMAT_GENERIC,
messages=messages,
max_tokens=self.max_tokens,
)
# Sampling and decoding parameters from llm_request.config.
cfg = getattr(llm_request, "config", None)
if cfg is not None:
if cfg.max_output_tokens is not None:
chat_request_kwargs["max_tokens"] = cfg.max_output_tokens
if cfg.temperature is not None:
chat_request_kwargs["temperature"] = cfg.temperature
if cfg.top_p is not None:
chat_request_kwargs["top_p"] = cfg.top_p
if cfg.top_k is not None:
chat_request_kwargs["top_k"] = int(cfg.top_k)
if cfg.frequency_penalty is not None:
chat_request_kwargs["frequency_penalty"] = cfg.frequency_penalty
if cfg.presence_penalty is not None:
chat_request_kwargs["presence_penalty"] = cfg.presence_penalty
if cfg.seed is not None:
chat_request_kwargs["seed"] = cfg.seed
if cfg.stop_sequences:
chat_request_kwargs["stop"] = list(cfg.stop_sequences)
# Structured-output: response_schema (Pydantic / dict / google.genai
# Schema) → JsonSchemaResponseFormat. response_mime_type alone (without
# a schema) → JsonObjectResponseFormat (free-form JSON).
response_format = _build_response_format(cfg, oci_models)
if response_format is not None:
chat_request_kwargs["response_format"] = response_format
# Constructor-level reasoning_effort applies regardless of per-request cfg.
if self.reasoning_effort is not None:
chat_request_kwargs["reasoning_effort"] = self.reasoning_effort
if oci_tools:
chat_request_kwargs["tools"] = oci_tools
if is_stream:
chat_request_kwargs["is_stream"] = True
chat_request_kwargs["stream_options"] = oci_models.StreamOptions(
is_include_usage=True
)
return oci_models.ChatDetails(
compartment_id=self._resolve_compartment_id(),
serving_mode=self._build_serving_mode(oci_models),
chat_request=oci_models.GenericChatRequest(**chat_request_kwargs),
)
def _build_serving_mode(self, oci_models: Any) -> Any:
endpoint_id = self.endpoint_id or os.environ.get("OCI_ENDPOINT_ID")
if endpoint_id:
return oci_models.DedicatedServingMode(endpoint_id=endpoint_id)
return oci_models.OnDemandServingMode(model_id=self.model)
def _call_oci(self, llm_request: LlmRequest) -> Any:
"""Synchronous non-streaming OCI GenAI call, run in a thread pool."""
chat_details = self._build_chat_details(llm_request, is_stream=False)
logger.debug("Sending request to OCI GenAI: model=%s", self.model)
return self._oci_client.chat(chat_details)
def _call_oci_stream(self, llm_request: LlmRequest) -> list[dict[str, Any]]:
"""Synchronous streaming call — collects all SSE event dicts in a thread.
The OCI SDK wraps an SSE response in ``oci._vendor.sseclient.SSEClient``
when ``is_stream=True`` is set on the request body. Each event's
``data`` field is an OpenAI-compatible JSON chunk or the sentinel
``[DONE]``.
"""
chat_details = self._build_chat_details(llm_request, is_stream=True)
logger.debug("Sending streaming request to OCI GenAI: model=%s", self.model)
response = self._oci_client.chat(chat_details)
chunks: list[dict[str, Any]] = []
try:
for event in response.data.events():
raw = getattr(event, "data", None)
if not raw or raw.strip() == "[DONE]":
break
try:
chunks.append(json.loads(raw))
except (json.JSONDecodeError, TypeError):
logger.debug("Could not parse SSE event data: %r", raw)
finally:
close = getattr(response.data, "close", None)
if callable(close):
close()
return chunks
async def _generate_content_streaming(
self, llm_request: LlmRequest
) -> AsyncGenerator[LlmResponse, None]:
"""Yield partial then final LlmResponse from an OCI SSE stream.
The OCI SDK is synchronous, so every SSE chunk is collected in a background
thread before any response is emitted. Partial responses are therefore not
delivered incrementally.
"""
chunks = await asyncio.to_thread(self._call_oci_stream, llm_request)
text_acc: str = ""
tool_acc: dict[int, dict[str, Any]] = {}
input_tokens: int = 0
output_tokens: int = 0
reasoning_tokens: int = 0
for chunk in chunks:
# Usage chunk (camelCase per OCI GenAI /20231130/ schema).
usage = chunk.get("usage")
if usage:
input_tokens = usage.get("promptTokens", 0) or 0
output_tokens = usage.get("completionTokens", 0) or 0
details = usage.get("completionTokensDetails") or {}
reasoning_tokens = details.get("reasoningTokens", 0) or 0
continue
message = chunk.get("message")
if not message:
continue
# Text content: list of {type: TEXT, text: ...} blocks.
for block in message.get("content") or []:
if block.get("type") == "TEXT" and block.get("text"):
delta_text = block["text"]
text_acc += delta_text
yield LlmResponse(
content=types.Content(
role="model",
parts=[types.Part.from_text(text=delta_text)],
),
partial=True,
)
# Tool calls: OCI emits the whole call in one chunk for Gemini, but
# accumulate name/arguments defensively in case other providers split
# them across events.
for tc_idx, tc in enumerate(message.get("toolCalls") or []):
idx = tc.get("index", tc_idx)
if idx not in tool_acc:
tool_acc[idx] = {"id": "", "name": "", "arguments": ""}
if tc.get("id"):
tool_acc[idx]["id"] = tc["id"]
if tc.get("name"):
tool_acc[idx]["name"] = tc["name"]
if tc.get("arguments"):
tool_acc[idx]["arguments"] += tc["arguments"]
# Build final aggregated response
all_parts: list[types.Part] = []
if text_acc:
all_parts.append(types.Part.from_text(text=text_acc))
for tc in sorted(tool_acc.values(), key=lambda x: x.get("name", "")):
args: dict[str, Any] = {}
try:
args = json.loads(tc["arguments"]) if tc["arguments"] else {}
except (json.JSONDecodeError, TypeError):
args = {}
part = types.Part.from_function_call(name=tc["name"], args=args)
if part.function_call is not None:
part.function_call.id = tc["id"]
all_parts.append(part)
yield LlmResponse(
content=types.Content(role="model", parts=all_parts),
usage_metadata=types.GenerateContentResponseUsageMetadata(
prompt_token_count=input_tokens,
candidates_token_count=output_tokens,
total_token_count=input_tokens + output_tokens,
thoughts_token_count=reasoning_tokens or None,
),
partial=False,
)
+13
View File
@@ -26,6 +26,7 @@ from .llm_response import LlmResponse
from .registry import LLMRegistry
if TYPE_CHECKING:
from google.adk.integrations.oci._oci_genai_llm import OCIGenAILlm
from google.adk.labs.openai import OpenAILlm
from .anthropic_llm import AnthropicGenerateContentConfig
@@ -91,6 +92,18 @@ _LAZY_PROVIDERS: dict[str, tuple[list[str], str]] = {
],
'lite_llm',
),
'OCIGenAILlm': (
[
r'meta\.llama-.*',
r'google\.gemini-.*',
r'google\.gemma-.*',
r'xai\.grok-.*',
r'mistralai\.mistral-.*',
r'mistralai\.mixtral-.*',
r'nvidia\..*',
],
'google.adk.integrations.oci._oci_genai_llm',
),
}
for _name, (_patterns, _module) in _LAZY_PROVIDERS.items():
@@ -0,0 +1,681 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests for OCIGenAILlm against live OCI Generative AI service.
Required environment variables:
OCI_COMPARTMENT_ID — OCI compartment OCID
OCI_REGION — OCI region (default: us-chicago-1)
Optional:
OCI_AUTH_TYPE — API_KEY | INSTANCE_PRINCIPAL | RESOURCE_PRINCIPAL
(default: API_KEY)
OCI_AUTH_PROFILE — OCI config profile (default: DEFAULT)
OCI_AUTH_FILE — path to OCI config file (default: ~/.oci/config)
"""
import json
import os
from google.adk.integrations.oci._oci_genai_llm import OCIGenAILlm
from google.adk.models.llm_request import LlmRequest
from google.genai import types
from google.genai.types import Content
from google.genai.types import Part
import pytest
# ---------------------------------------------------------------------------
# Skip the entire module when required env vars are absent
# ---------------------------------------------------------------------------
# OCI tests do not use any Google backend (GOOGLE_AI / Vertex AI).
# Override the autouse llm_backend fixture from the integration conftest so
# these tests are not duplicated across backends.
@pytest.fixture(autouse=True)
def llm_backend():
yield
pytestmark = pytest.mark.skipif(
not os.environ.get("OCI_COMPARTMENT_ID"),
reason=(
"OCI integration tests require OCI_COMPARTMENT_ID to be set. "
"Set OCI_COMPARTMENT_ID (and optionally OCI_REGION) to run."
),
)
_COMPARTMENT_ID = os.environ.get("OCI_COMPARTMENT_ID", "")
_REGION = os.environ.get("OCI_REGION", "us-chicago-1")
_SERVICE_ENDPOINT = (
f"https://inference.generativeai.{_REGION}.oci.oraclecloud.com"
)
_AUTH_TYPE = os.environ.get("OCI_AUTH_TYPE", "API_KEY")
_AUTH_PROFILE = os.environ.get("OCI_AUTH_PROFILE", "DEFAULT")
_AUTH_FILE = os.environ.get("OCI_AUTH_FILE", "~/.oci/config")
_GEMINI_MODEL = "google.gemini-2.5-flash"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def gemini_llm() -> OCIGenAILlm:
return OCIGenAILlm(
model=_GEMINI_MODEL,
compartment_id=_COMPARTMENT_ID,
service_endpoint=_SERVICE_ENDPOINT,
auth_type=_AUTH_TYPE,
auth_profile=_AUTH_PROFILE,
auth_file_location=_AUTH_FILE,
max_tokens=512,
)
def _simple_request(
model: str, text: str = "Reply with one word: hello."
) -> LlmRequest:
return LlmRequest(
model=model,
contents=[Content(role="user", parts=[Part.from_text(text=text)])],
)
def _request_with_system(model: str) -> LlmRequest:
return LlmRequest(
model=model,
contents=[
Content(
role="user",
parts=[Part.from_text(text="What is your name?")],
)
],
config=types.GenerateContentConfig(
system_instruction=(
"Your name is Oracle. Always introduce yourself as Oracle."
),
),
)
def _request_with_tool(model: str) -> LlmRequest:
return LlmRequest(
model=model,
contents=[
Content(
role="user",
parts=[Part.from_text(text="What is the weather in Chicago?")],
)
],
config=types.GenerateContentConfig(
tools=[
types.Tool(
function_declarations=[
types.FunctionDeclaration(
name="get_weather",
description="Get the current weather for a city.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"city": types.Schema(
type=types.Type.STRING,
description="The city name.",
)
},
required=["city"],
),
)
]
)
]
),
)
# ---------------------------------------------------------------------------
# Gemini (google.gemini-2.0-flash-001) tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_generate_content_text(gemini_llm):
"""Gemini on OCI returns a non-empty text response."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_simple_request(_GEMINI_MODEL), stream=False
)
]
assert len(responses) == 1
assert responses[0].content.role == "model"
assert responses[0].content.parts
assert responses[0].content.parts[0].text.strip()
@pytest.mark.asyncio
async def test_gemini_generate_content_usage_metadata(gemini_llm):
"""Response includes token usage metadata."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_simple_request(_GEMINI_MODEL), stream=False
)
]
usage = responses[0].usage_metadata
assert usage.prompt_token_count > 0
assert usage.candidates_token_count > 0
assert usage.total_token_count == (
usage.prompt_token_count + usage.candidates_token_count
)
@pytest.mark.asyncio
async def test_gemini_generate_content_with_system_instruction(gemini_llm):
"""System instruction is respected."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_request_with_system(_GEMINI_MODEL), stream=False
)
]
text = responses[0].content.parts[0].text.lower()
assert "oracle" in text
@pytest.mark.asyncio
async def test_gemini_generate_content_tool_call(gemini_llm):
"""Gemini returns a function call when a tool is provided."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_request_with_tool(_GEMINI_MODEL), stream=False
)
]
parts = responses[0].content.parts
function_calls = [p for p in parts if p.function_call]
assert function_calls, "Expected at least one function call in the response"
fc = function_calls[0].function_call
assert fc.name == "get_weather"
assert "city" in fc.args
@pytest.mark.asyncio
async def test_gemini_generate_content_streaming_text(gemini_llm):
"""Streaming returns partial chunks followed by a final non-partial response."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_simple_request(_GEMINI_MODEL), stream=True
)
]
assert responses, "Expected at least one response chunk"
partial_responses = [r for r in responses if r.partial]
final_responses = [r for r in responses if not r.partial]
assert partial_responses, "Expected at least one partial (streaming) chunk"
assert (
len(final_responses) == 1
), "Expected exactly one final (non-partial) response"
full_text = "".join(
p.text for r in partial_responses for p in r.content.parts or [] if p.text
)
assert full_text.strip(), "Streamed text should be non-empty"
@pytest.mark.asyncio
async def test_gemini_generate_content_streaming_usage_metadata(gemini_llm):
"""Final streaming response includes token usage metadata."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_simple_request(_GEMINI_MODEL), stream=True
)
]
final = next(r for r in responses if not r.partial)
usage = final.usage_metadata
assert usage is not None
assert usage.prompt_token_count > 0
assert usage.candidates_token_count > 0
assert usage.total_token_count == (
usage.prompt_token_count + usage.candidates_token_count
)
@pytest.mark.asyncio
async def test_gemini_generate_content_streaming_tool_call(gemini_llm):
"""Streaming returns a function call when a tool is provided."""
responses = [
r
async for r in gemini_llm.generate_content_async(
_request_with_tool(_GEMINI_MODEL), stream=True
)
]
final = next(r for r in responses if not r.partial)
parts = final.content.parts or []
function_calls = [p for p in parts if p.function_call]
assert (
function_calls
), "Expected at least one function call in the streaming response"
fc = function_calls[0].function_call
assert fc.name == "get_weather"
assert "city" in fc.args
@pytest.mark.asyncio
async def test_gemini_generate_content_concurrent(gemini_llm):
"""Multiple concurrent non-streaming requests complete independently."""
import asyncio
async def single_call(text: str) -> str:
responses = [
r
async for r in gemini_llm.generate_content_async(
_simple_request(_GEMINI_MODEL, text=text), stream=False
)
]
return responses[0].content.parts[0].text
results = await asyncio.gather(
*[single_call(f"Reply with the number {i} only.") for i in range(3)]
)
assert len(results) == 3
for result in results:
assert result.strip(), "Each concurrent response should be non-empty"
@pytest.mark.asyncio
async def test_gemini_multi_turn(gemini_llm):
"""Multi-turn conversation passes history correctly."""
history = [
Content(
role="user",
parts=[Part.from_text(text="My favourite colour is blue.")],
),
Content(
role="model",
parts=[Part.from_text(text="Got it, blue is a great colour!")],
),
]
follow_up = Content(
role="user",
parts=[Part.from_text(text="What is my favourite colour?")],
)
request = LlmRequest(
model=_GEMINI_MODEL,
contents=history + [follow_up],
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
text = responses[0].content.parts[0].text.lower()
assert "blue" in text
# ---------------------------------------------------------------------------
# Cross-provider on-demand smoke tests
#
# Skipped unless the corresponding model env var is set so cost stays opt-in.
# Set OCI_LLAMA_MODEL / OCI_MISTRAL_MODEL / OCI_GROK_MODEL / OCI_NVIDIA_MODEL
# to a model id available in your tenancy/region (e.g. "meta.llama-3.3-70b-instruct").
# ---------------------------------------------------------------------------
def _provider_llm(env_var: str) -> "OCIGenAILlm | None":
model_id = os.environ.get(env_var)
if not model_id:
return None
return OCIGenAILlm(
model=model_id,
compartment_id=_COMPARTMENT_ID,
service_endpoint=_SERVICE_ENDPOINT,
auth_type=_AUTH_TYPE,
auth_profile=_AUTH_PROFILE,
auth_file_location=_AUTH_FILE,
max_tokens=256,
)
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OCI_LLAMA_MODEL"),
reason="Set OCI_LLAMA_MODEL=<meta.llama-...-id> to enable.",
)
async def test_llama_on_demand_generate_text():
llm = _provider_llm("OCI_LLAMA_MODEL")
responses = [
r
async for r in llm.generate_content_async(
_simple_request(llm.model), stream=False
)
]
assert len(responses) == 1
assert responses[0].content.parts[0].text.strip()
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OCI_MISTRAL_MODEL"),
reason="Set OCI_MISTRAL_MODEL=<mistralai...-id> to enable.",
)
async def test_mistral_on_demand_generate_text():
llm = _provider_llm("OCI_MISTRAL_MODEL")
responses = [
r
async for r in llm.generate_content_async(
_simple_request(llm.model), stream=False
)
]
assert responses[0].content.parts[0].text.strip()
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OCI_GROK_MODEL"),
reason="Set OCI_GROK_MODEL=<xai.grok-...-id> to enable.",
)
async def test_grok_on_demand_generate_text():
llm = _provider_llm("OCI_GROK_MODEL")
responses = [
r
async for r in llm.generate_content_async(
_simple_request(llm.model), stream=False
)
]
assert responses[0].content.parts[0].text.strip()
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.environ.get("OCI_NVIDIA_MODEL"),
reason="Set OCI_NVIDIA_MODEL=<nvidia...-id> to enable.",
)
async def test_nvidia_on_demand_generate_text():
llm = _provider_llm("OCI_NVIDIA_MODEL")
responses = [
r
async for r in llm.generate_content_async(
_simple_request(llm.model), stream=False
)
]
assert responses[0].content.parts[0].text.strip()
# ---------------------------------------------------------------------------
# Dedicated serving mode
#
# Set OCI_DEDICATED_ENDPOINT_ID=ocid1.generativeaiendpoint.oc1... to enable.
# OCI_DEDICATED_MODEL is informational; defaults to the dedicated endpoint's
# bound model (the SDK ignores `model` when serving_mode is dedicated).
# ---------------------------------------------------------------------------
_DEDICATED_ENDPOINT_ID = os.environ.get("OCI_DEDICATED_ENDPOINT_ID", "")
_DEDICATED_MODEL = os.environ.get(
"OCI_DEDICATED_MODEL", "meta.llama-3.3-70b-instruct"
)
@pytest.fixture
def dedicated_llm() -> OCIGenAILlm:
return OCIGenAILlm(
model=_DEDICATED_MODEL,
endpoint_id=_DEDICATED_ENDPOINT_ID,
compartment_id=_COMPARTMENT_ID,
service_endpoint=_SERVICE_ENDPOINT,
auth_type=_AUTH_TYPE,
auth_profile=_AUTH_PROFILE,
auth_file_location=_AUTH_FILE,
max_tokens=256,
)
@pytest.mark.asyncio
@pytest.mark.skipif(
not _DEDICATED_ENDPOINT_ID,
reason=(
"Set OCI_DEDICATED_ENDPOINT_ID to a dedicated endpoint OCID to enable."
),
)
async def test_dedicated_generate_content_text(dedicated_llm):
responses = [
r
async for r in dedicated_llm.generate_content_async(
_simple_request(_DEDICATED_MODEL), stream=False
)
]
assert len(responses) == 1
assert responses[0].content.parts[0].text.strip()
@pytest.mark.asyncio
@pytest.mark.skipif(
not _DEDICATED_ENDPOINT_ID,
reason=(
"Set OCI_DEDICATED_ENDPOINT_ID to a dedicated endpoint OCID to enable."
),
)
async def test_dedicated_generate_content_streaming(dedicated_llm):
chunks = []
async for r in dedicated_llm.generate_content_async(
_simple_request(_DEDICATED_MODEL, text="Count from 1 to 3."),
stream=True,
):
chunks.append(r)
assert len(chunks) >= 2 # at least one partial + one final
final = chunks[-1]
assert final.usage_metadata is not None
# ---------------------------------------------------------------------------
# Sampling parameters (live)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_max_output_tokens_caps_response(gemini_llm):
"""max_output_tokens is honoured: completion tokens never exceed the budget.
Note: Gemini 2.5 spends part of the budget on reasoning tokens before any
visible output. We pick a budget large enough to leave some text but small
enough to clearly cap an alphabet-recitation response, and we assert on the
reported token count rather than character count (which is flaky).
"""
budget = 64
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[
Part.from_text(
text="Recite the alphabet, A through Z, comma separated."
)
],
)
],
config=types.GenerateContentConfig(max_output_tokens=budget),
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
um = responses[0].usage_metadata
assert um.candidates_token_count is not None
assert um.candidates_token_count <= budget
@pytest.mark.asyncio
async def test_gemini_low_temperature_deterministic_with_seed(gemini_llm):
"""temperature=0 + seed should yield consistent answers across two calls."""
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[Part.from_text(text="Reply with exactly: 'green'")],
)
],
config=types.GenerateContentConfig(temperature=0.0, seed=12345),
)
call_a = [r async for r in gemini_llm.generate_content_async(request)]
call_b = [r async for r in gemini_llm.generate_content_async(request)]
assert "green" in call_a[0].content.parts[0].text.lower()
assert "green" in call_b[0].content.parts[0].text.lower()
@pytest.mark.asyncio
async def test_gemini_stop_sequences_terminate_output(gemini_llm):
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[Part.from_text(text="Print: APPLE | BANANA | CHERRY")],
)
],
config=types.GenerateContentConfig(
temperature=0.0, stop_sequences=["BANANA"]
),
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
text = responses[0].content.parts[0].text
assert "BANANA" not in text
# ---------------------------------------------------------------------------
# Multimodal: inline image (live)
#
# Uses a tiny 1x1 red PNG so the request is cheap. Gemini 2.5 Flash on OCI
# supports image inputs via ImageContent.
# ---------------------------------------------------------------------------
def _make_red_png_1x1() -> bytes:
"""Generate a guaranteed-valid 1x1 red PNG with correct CRCs."""
import struct
import zlib
sig = b"\x89PNG\r\n\x1a\n"
def chunk(t: bytes, d: bytes) -> bytes:
return (
struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1x1 RGB
idat = zlib.compress(b"\x00\xff\x00\x00") # filter byte + RGB(255,0,0)
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
_TINY_RED_PNG = _make_red_png_1x1()
@pytest.mark.asyncio
async def test_gemini_inline_image_input(gemini_llm):
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[
Part.from_text(
text=(
"What is the dominant colour of this image? "
"Reply with just the colour name."
)
),
Part(
inline_data=types.Blob(
mime_type="image/png", data=_TINY_RED_PNG
)
),
],
)
],
config=types.GenerateContentConfig(
temperature=0.0, max_output_tokens=256
),
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
parts = responses[0].content.parts
assert parts, "Expected the model to produce a visible answer"
text = parts[0].text.lower()
assert "red" in text
# ---------------------------------------------------------------------------
# Structured output: response_schema (live)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_response_schema_returns_valid_json(gemini_llm):
schema = {
"title": "CityFact",
"type": "object",
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
"additionalProperties": False,
}
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[Part.from_text(text="Give me a fact about Paris.")],
)
],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=schema,
temperature=0.0,
),
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
raw = responses[0].content.parts[0].text
payload = json.loads(raw)
assert "city" in payload
assert "country" in payload
# ---------------------------------------------------------------------------
# Reasoning-token surfacing (live)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_reasoning_tokens_reported(gemini_llm):
"""Gemini 2.5 emits reasoningTokens in completionTokensDetails — surface them."""
request = LlmRequest(
model=_GEMINI_MODEL,
contents=[
Content(
role="user",
parts=[
Part.from_text(
text=(
"If a train travels 60km in 30 minutes, what is its"
" speed?"
)
)
],
)
],
config=types.GenerateContentConfig(temperature=0.0),
)
responses = [r async for r in gemini_llm.generate_content_async(request)]
um = responses[0].usage_metadata
assert um is not None
# Reasoning tokens are optional; assert it's an int when present
assert um.thoughts_token_count is None or um.thoughts_token_count > 0
File diff suppressed because it is too large Load Diff