fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
121 lines
4.9 KiB
Python
121 lines
4.9 KiB
Python
import base64
|
|
import binascii
|
|
import uuid
|
|
from typing import Any, Dict, Generator, Optional, Union
|
|
|
|
from application.llm.handlers.base import LLMHandler, LLMResponse, ToolCall
|
|
|
|
|
|
def _encode_thought_signature(sig: Optional[Union[bytes, str]]) -> Optional[str]:
|
|
# Gemini's Python SDK returns thought_signature as raw bytes, but the
|
|
# field is typed Optional[str] downstream and gets json.dumps'd into
|
|
# SSE events. Encode once at ingress so callers only ever see a str.
|
|
if isinstance(sig, bytes):
|
|
return base64.b64encode(sig).decode("ascii")
|
|
return sig
|
|
|
|
|
|
def _decode_thought_signature(
|
|
sig: Optional[Union[bytes, str]],
|
|
) -> Optional[Union[bytes, str]]:
|
|
# Reverse of _encode_thought_signature — Gemini's SDK expects bytes
|
|
# back when we replay a tool call. ``validate=True`` keeps ASCII
|
|
# strings that happen to be loosely decodable from being silently
|
|
# turned into bytes; non-base64 inputs pass through unchanged.
|
|
if isinstance(sig, str):
|
|
try:
|
|
return base64.b64decode(sig.encode("ascii"), validate=True)
|
|
except (binascii.Error, ValueError):
|
|
return sig
|
|
return sig
|
|
|
|
|
|
class GoogleLLMHandler(LLMHandler):
|
|
"""Handler for Google's GenAI API."""
|
|
|
|
def parse_response(self, response: Any) -> LLMResponse:
|
|
"""Parse Google response into standardized format."""
|
|
|
|
if isinstance(response, str):
|
|
return LLMResponse(
|
|
content=response,
|
|
tool_calls=[],
|
|
finish_reason="stop",
|
|
raw_response=response,
|
|
)
|
|
if hasattr(response, "candidates"):
|
|
parts = response.candidates[0].content.parts if response.candidates else []
|
|
tool_calls = []
|
|
for idx, part in enumerate(parts):
|
|
if hasattr(part, "function_call") and part.function_call is not None:
|
|
has_sig = hasattr(part, "thought_signature") and part.thought_signature is not None
|
|
thought_sig = _encode_thought_signature(part.thought_signature) if has_sig else None
|
|
tool_calls.append(
|
|
ToolCall(
|
|
id=str(uuid.uuid4()),
|
|
name=part.function_call.name,
|
|
arguments=part.function_call.args,
|
|
index=idx,
|
|
thought_signature=thought_sig,
|
|
)
|
|
)
|
|
|
|
content = " ".join(
|
|
part.text
|
|
for part in parts
|
|
if hasattr(part, "text") and part.text is not None
|
|
)
|
|
return LLMResponse(
|
|
content=content,
|
|
tool_calls=tool_calls,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
raw_response=response,
|
|
)
|
|
else:
|
|
# This branch handles individual Part objects from streaming responses
|
|
tool_calls = []
|
|
if hasattr(response, "function_call") and response.function_call is not None:
|
|
has_sig = hasattr(response, "thought_signature") and response.thought_signature is not None
|
|
thought_sig = _encode_thought_signature(response.thought_signature) if has_sig else None
|
|
tool_calls.append(
|
|
ToolCall(
|
|
id=str(uuid.uuid4()),
|
|
name=response.function_call.name,
|
|
arguments=response.function_call.args,
|
|
thought_signature=thought_sig,
|
|
)
|
|
)
|
|
return LLMResponse(
|
|
content=response.text if hasattr(response, "text") else "",
|
|
tool_calls=tool_calls,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
raw_response=response,
|
|
)
|
|
|
|
def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict:
|
|
"""Create a tool result message in the standard internal format."""
|
|
import json as _json
|
|
|
|
from application.storage.db.serialization import PGNativeJSONEncoder
|
|
|
|
# PostgresTool results commonly include PG-native types
|
|
# (datetime / UUID / Decimal / bytea) when SELECT touches
|
|
# timestamptz / numeric / uuid / bytea columns. The shared
|
|
# encoder handles all five — bytes get base64 (lossless) instead
|
|
# of the ``str(b'...')`` repr that ``default=str`` would emit.
|
|
content = (
|
|
_json.dumps(result, cls=PGNativeJSONEncoder)
|
|
if not isinstance(result, str)
|
|
else result
|
|
)
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": tool_call.id,
|
|
"content": content,
|
|
}
|
|
|
|
def _iterate_stream(self, response: Any) -> Generator:
|
|
"""Iterate through Google streaming response."""
|
|
for chunk in response:
|
|
yield chunk
|