From 89f177b7b6124cea05f47f0b9a88938fd7a76dbe Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 5 Jun 2026 21:33:17 +0530 Subject: [PATCH] fix(galileo): use ingest traces API and standard logging payload (#29651) * fix(galileo): use ingest traces API and standard logging payload Switch hosted Galileo logging to /ingest/traces with nested trace/span payloads, read metrics from standard_logging_object, and include cost and total tokens on trace metrics. Co-authored-by: Cursor * fix(galileo): route username/password auth to v2 traces ingest Hosted Galileo no longer serves /observe/ingest; JWT login should post the same trace payload to /v2/projects/{project_id}/traces. Co-authored-by: Cursor * fix(galileo): address Greptile review on logging and timestamps Use debug-level logs for per-request Galileo callback messages and fall back to start_time/end_time when standard_logging_object omits startTime/endTime. Co-authored-by: Cursor * feat(galileo): add Galileo to proxy UI callback configuration Expose Galileo in the admin callback selector and config APIs so credentials can be configured through the dashboard instead of YAML only. Co-authored-by: Cursor * fix(galileo): align response type logging with Langfuse Mirror Langfuse input/output handling for rerank, speech, transcription, realtime, pass-through, and other response types so Galileo ingest no longer skips supported call types. Co-authored-by: Cursor * fix(galileo): redact trace payload in debug logs and format with black Avoid logging prompts and model responses in flush debug output while keeping structural metadata for troubleshooting. Co-authored-by: Cursor * fix(galileo): stop logging full trace payload in debug output Log only flush URL and trace count so prompts and model responses are not written to application logs when debug logging is enabled. Co-authored-by: Cursor * Fix Galileo token totals and prompt messages --------- Co-authored-by: Cursor --- litellm/integrations/callback_configs.json | 45 ++ litellm/integrations/galileo.py | 628 +++++++++++++++--- .../out/assets/logos/galileo.ico | Bin 0 -> 9714 bytes litellm/proxy/_types.py | 13 + .../test_litellm/integrations/test_galileo.py | 336 +++++++++- .../test_callback_management_endpoints.py | 9 + .../public/assets/logos/galileo.ico | Bin 0 -> 9714 bytes .../src/components/callback_info_helpers.tsx | 15 + 8 files changed, 956 insertions(+), 90 deletions(-) create mode 100644 litellm/proxy/_experimental/out/assets/logos/galileo.ico create mode 100644 ui/litellm-dashboard/public/assets/logos/galileo.ico diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index c2b0c4ddce..3a69c9a793 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -104,6 +104,51 @@ }, "description": "Datadog Custom Metrics Integration" }, + { + "id": "galileo", + "displayName": "Galileo", + "logo": "galileo.ico", + "supports_key_team_logging": false, + "dynamic_params": { + "GALILEO_API_KEY": { + "type": "password", + "ui_name": "API Key", + "description": "Galileo Cloud API key (app.galileo.ai). Omit for enterprise username/password auth.", + "required": false + }, + "GALILEO_PROJECT_ID": { + "type": "text", + "ui_name": "Project ID", + "description": "Galileo project ID to log traces to", + "required": true + }, + "GALILEO_LOG_STREAM_ID": { + "type": "text", + "ui_name": "Log Stream ID", + "description": "Galileo log stream ID for v2 spans logging (optional)", + "required": false + }, + "GALILEO_BASE_URL": { + "type": "text", + "ui_name": "Base URL", + "description": "Galileo API base URL (e.g. https://api.galileo.ai for Cloud, or your enterprise API URL)", + "required": false + }, + "GALILEO_USERNAME": { + "type": "text", + "ui_name": "Username", + "description": "Galileo enterprise username (legacy Observe auth; use instead of API key)", + "required": false + }, + "GALILEO_PASSWORD": { + "type": "password", + "ui_name": "Password", + "description": "Galileo enterprise password (legacy Observe auth)", + "required": false + } + }, + "description": "Galileo AI Observability Integration" + }, { "id": "datadog_cost_management", "displayName": "Datadog Cost Management", diff --git a/litellm/integrations/galileo.py b/litellm/integrations/galileo.py index a598124f61..8fef90c24e 100644 --- a/litellm/integrations/galileo.py +++ b/litellm/integrations/galileo.py @@ -1,8 +1,13 @@ +from __future__ import annotations + import json import os import re -from typing import Any, Dict, List, Optional, Tuple, cast +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple, Union, cast +import httpx from pydantic import BaseModel, Field import litellm @@ -12,11 +17,15 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_content_list_to_str, get_content_from_model_response, ) +from litellm.types.llms.openai import ( + AllMessageValues, + HttpxBinaryResponseContent, + ResponsesAPIResponse, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.types.llms.openai import AllMessageValues GALILEO_CLOUD_API_BASE_URL = "https://api.galileo.ai" # Cap the in-memory buffer so persistent flush failures (e.g. Galileo @@ -33,6 +42,11 @@ class LLMResponse(BaseModel): model: str num_input_tokens: int num_output_tokens: int + num_total_tokens: int + cost: Optional[float] = Field( + default=None, + description="Total cost of the LLM call in USD as computed by LiteLLM.", + ) output_logprobs: Optional[Dict[str, Any]] = Field( default=None, description="Optional. When available, logprobs are used to compute Uncertainty.", @@ -121,10 +135,14 @@ class GalileoObserve(CustomLogger): @staticmethod def _galileo_input_messages( - messages: Optional[List[Any]], input_text: str + messages: Optional[Any], input_text: str ) -> List[Dict[str, str]]: + if isinstance(messages, dict): + messages = messages.get("messages") if not messages: return [{"role": "user", "content": input_text}] + if not isinstance(messages, list): + return [{"role": "user", "content": input_text}] galileo_messages: List[Dict[str, str]] = [] for message in messages: @@ -147,13 +165,59 @@ class GalileoObserve(CustomLogger): return [{"role": "user", "content": input_text}] @staticmethod - def _record_to_v2_span(record: Dict[str, Any]) -> Dict[str, Any]: - created_at = record.get("created_at", "") + def _local_timezone(): + return datetime.now().astimezone().tzinfo or timezone.utc + + @staticmethod + def _format_created_at(dt: Union[datetime, Any]) -> str: + """Serialize timestamps as UTC ISO-8601 for Galileo.""" + if not isinstance(dt, datetime): + return str(dt) + + if dt.tzinfo is None: + # LiteLLM often passes naive datetimes in local time; convert to UTC + # instead of appending Z to local time (which shifts Traces tab sorting). + dt = dt.replace(tzinfo=GalileoObserve._local_timezone()) + + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + @staticmethod + def _normalize_created_at(created_at: str) -> str: if created_at and not re.search(r"(Z|[+-]\d{2}:?\d{2})$", created_at): - created_at = f"{created_at}Z" + return f"{created_at}Z" + return created_at + + @staticmethod + def _token_metrics_from_record(record: Dict[str, Any]) -> Dict[str, Any]: + num_input_tokens = int(record.get("num_input_tokens") or 0) + num_output_tokens = int(record.get("num_output_tokens") or 0) + num_total_tokens = int(record.get("num_total_tokens") or 0) + if num_total_tokens == 0 and (num_input_tokens or num_output_tokens): + num_total_tokens = num_input_tokens + num_output_tokens + metrics: Dict[str, Any] = { + "num_input_tokens": num_input_tokens, + "num_output_tokens": num_output_tokens, + "num_total_tokens": num_total_tokens, + } + cost = record.get("cost") + if cost is not None: + metrics["cost"] = float(cost) + return metrics + + @staticmethod + def _record_to_v2_span( + record: Dict[str, Any], + *, + trace_id: str, + span_id: str, + ) -> Dict[str, Any]: + created_at = GalileoObserve._normalize_created_at(record.get("created_at", "")) span: Dict[str, Any] = { "type": "llm", + "id": span_id, + "trace_id": trace_id, + "parent_id": trace_id, "name": record.get("node_type", "litellm"), "created_at": created_at, "input": GalileoObserve._galileo_input_messages( @@ -167,14 +231,49 @@ class GalileoObserve(CustomLogger): "model": record.get("model"), "metrics": { "duration_ns": int(record.get("latency_ms", 0)) * 1_000_000, - "num_input_tokens": record.get("num_input_tokens"), - "num_output_tokens": record.get("num_output_tokens"), + **GalileoObserve._token_metrics_from_record(record), }, } if record.get("tags"): span["tags"] = record["tags"] return span + @staticmethod + def _record_to_v2_trace(record: Dict[str, Any]) -> Dict[str, Any]: + trace_id = str(uuid.uuid4()) + span_id = str(uuid.uuid4()) + created_at = GalileoObserve._normalize_created_at(record.get("created_at", "")) + + return { + "type": "trace", + "id": trace_id, + "name": record.get("node_type", "litellm"), + "created_at": created_at, + "input": record.get("input_text", ""), + "output": record.get("output_text", ""), + "status_code": record.get("status_code", 200), + "metrics": { + "duration_ns": int(record.get("latency_ms", 0)) * 1_000_000, + **GalileoObserve._token_metrics_from_record(record), + }, + "spans": [ + GalileoObserve._record_to_v2_span( + record, trace_id=trace_id, span_id=span_id + ) + ], + } + + def _build_traces_payload(self, records: List[dict]) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "traces": [self._record_to_v2_trace(record) for record in records], + "logging_method": "api_direct", + "reliable": False, + "is_complete": True, + } + if self.log_stream_id: + payload["log_stream_id"] = self.log_stream_id + return payload + def _get_ingest_request(self) -> Optional[Tuple[str, Dict[str, Any]]]: if not self.base_url or not self.project_id: return None @@ -184,105 +283,457 @@ class GalileoObserve(CustomLogger): # flush_in_memory_records) aren't silently dropped when we later clear # the in-memory buffer. records = list(self.in_memory_records) + payload = self._build_traces_payload(records) if self.use_v2_api: - payload: Dict[str, Any] = { - "spans": [self._record_to_v2_span(record) for record in records], - "reliable": False, - } - if self.log_stream_id: - payload["log_stream_id"] = self.log_stream_id return ( - f"{self.base_url}/v2/projects/{self.project_id}/spans", + f"{self.base_url}/ingest/traces/{self.project_id}", payload, ) + # Username/password auth logs in for a JWT and uses the standard v2 traces API. return ( - f"{self.base_url}/projects/{self.project_id}/observe/ingest", - {"records": records}, + f"{self.base_url}/v2/projects/{self.project_id}/traces", + payload, ) + @staticmethod + def _redact_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]: + if not headers: + return {} + redacted: Dict[str, str] = {} + for key, value in headers.items(): + if key.lower() in {"authorization", "galileo-api-key"} and value: + redacted[key] = ( + f"{value[:8]}...{value[-4:]}" if len(value) > 12 else "***" + ) + else: + redacted[key] = value + return redacted + + def _log_flush_config(self) -> None: + verbose_logger.debug( + "Galileo Logger flush config: use_v2_api=%s base_url=%s project_id=%s " + "log_stream_id=%s api_key_set=%s username_set=%s record_count=%s", + self.use_v2_api, + self.base_url, + self.project_id, + self.log_stream_id, + bool(self.api_key), + bool(self.username), + len(self.in_memory_records), + ) + + @staticmethod + def _log_v2_payload_validation(payload: Dict[str, Any]) -> None: + missing_fields: List[str] = [] + traces = payload.get("traces", []) + if not traces: + missing_fields.append("traces") + + for trace_index, trace in enumerate(traces): + if not isinstance(trace, dict): + continue + for field in ("id", "type", "spans"): + if field not in trace: + missing_fields.append(f"traces[{trace_index}].{field}") + + trace_id = trace.get("id") + for span_index, span in enumerate(trace.get("spans", [])): + if not isinstance(span, dict): + continue + for field in ("id", "trace_id", "parent_id"): + if field not in span: + missing_fields.append( + f"traces[{trace_index}].spans[{span_index}].{field}" + ) + if trace_id and span.get("trace_id") != trace_id: + missing_fields.append( + f"traces[{trace_index}].spans[{span_index}].trace_id mismatch" + ) + + if missing_fields: + verbose_logger.debug( + "Galileo Logger: ingest /traces payload validation issues: %s", + missing_fields, + ) + + def _log_flush_payload(self, url: str, payload: Dict[str, Any]) -> None: + traces = payload.get("traces", []) + verbose_logger.debug( + "Galileo Logger flush URL: %s trace_count=%s", + url, + len(traces) if isinstance(traces, list) else 0, + ) + if self.use_v2_api and "/ingest/traces/" in url: + self._log_v2_payload_validation(payload) + + @staticmethod + def _log_http_status_error(error: httpx.HTTPStatusError, url: str) -> None: + response = error.response + verbose_logger.debug( + "Galileo Logger HTTP error: status=%s url=%s", + response.status_code, + url, + ) + verbose_logger.debug( + "Galileo Logger HTTP error response body: %s", + response.text, + ) + try: + verbose_logger.debug( + "Galileo Logger HTTP error response json: %s", + response.json(), + ) + except Exception: + pass + + @staticmethod + def _build_prompt(kwargs: Dict[str, Any]) -> Dict[str, Any]: + optional_params = kwargs.get("optional_params", {}) or {} + prompt: Dict[str, Any] = {"messages": kwargs.get("messages")} + if optional_params.get("functions") is not None: + prompt["functions"] = optional_params["functions"] + if optional_params.get("tools") is not None: + prompt["tools"] = optional_params["tools"] + return prompt + + @staticmethod + def _serialize_galileo_output(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + + def _json_default(obj: Any) -> Any: + if hasattr(obj, "model_dump"): + return obj.model_dump() + return str(obj) + + return json.dumps(value, default=_json_default) + + @staticmethod + def _prompt_to_input_text(prompt: Dict[str, Any]) -> str: + messages = prompt.get("messages") + if messages is not None: + text = GalileoObserve._input_text_from_messages(messages) + if text: + return text + return json.dumps(prompt, default=str) + + @staticmethod + def _get_chat_content_for_galileo(response_obj: litellm.ModelResponse) -> Any: + if response_obj.choices and len(response_obj.choices) > 0: + message = response_obj["choices"][0]["message"] + if hasattr(message, "json"): + message_json = message.json() + if isinstance(message_json, str): + return json.loads(message_json) + return message_json + return message + return None + + @staticmethod + def _get_text_completion_content_for_galileo( + response_obj: litellm.TextCompletionResponse, + ) -> Optional[str]: + if response_obj.choices and len(response_obj.choices) > 0: + return response_obj.choices[0].text + return None + + @staticmethod + def _get_responses_api_content_for_galileo( + response_obj: ResponsesAPIResponse, + ) -> Any: + if hasattr(response_obj, "output") and response_obj.output: + return response_obj.output + return None + + @staticmethod + def _langfuse_style_rerank_prompt(kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Match Langfuse rerank input: prompt = {"messages": kwargs.get("messages")}.""" + return {"messages": kwargs.get("messages")} + + def _get_galileo_input_output_content( + self, + kwargs: Dict[str, Any], + response_obj: Any, + level: str = "DEFAULT", + status_message: Optional[str] = None, + ) -> Tuple[str, Optional[str], Any]: + """ + Mirror Langfuse _get_langfuse_input_output_content for Galileo ingest. + + Returns (input_text, output_text, messages_for_span). output_text None skips ingest. + """ + call_type = kwargs.get("call_type") + prompt = self._build_prompt(kwargs) + + if ( + level == "ERROR" + and status_message is not None + and isinstance(status_message, str) + ): + return self._prompt_to_input_text(prompt), status_message, prompt + + if response_obj is not None and ( + call_type == "embedding" + or isinstance(response_obj, litellm.EmbeddingResponse) + ): + return self._prompt_to_input_text(prompt), None, prompt + + if response_obj is not None and isinstance(response_obj, litellm.ModelResponse): + output = self._get_chat_content_for_galileo(response_obj) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + kwargs.get("messages") or [], + ) + + if response_obj is not None and isinstance( + response_obj, HttpxBinaryResponseContent + ): + return self._prompt_to_input_text(prompt), "speech-output", prompt + + if response_obj is not None and isinstance( + response_obj, litellm.TextCompletionResponse + ): + output = self._get_text_completion_content_for_galileo(response_obj) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + kwargs.get("messages") or [], + ) + + if response_obj is not None and isinstance(response_obj, litellm.ImageResponse): + output = response_obj.get("data", None) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + prompt, + ) + + if response_obj is not None and isinstance( + response_obj, litellm.TranscriptionResponse + ): + output = response_obj.get("text", None) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + prompt, + ) + + if response_obj is not None and isinstance( + response_obj, litellm.RerankResponse + ): + output = response_obj.results + rerank_prompt = self._langfuse_style_rerank_prompt(kwargs) + return ( + json.dumps(rerank_prompt, default=str), + self._serialize_galileo_output(output), + rerank_prompt, + ) + + if response_obj is not None and isinstance(response_obj, ResponsesAPIResponse): + output = self._get_responses_api_content_for_galileo(response_obj) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + kwargs.get("messages") or [], + ) + + if ( + call_type == "_arealtime" + and response_obj is not None + and isinstance(response_obj, list) + ): + input_val = kwargs.get("input") + return ( + self._serialize_galileo_output(input_val) or "", + self._serialize_galileo_output(response_obj), + input_val, + ) + + if ( + call_type == "pass_through_endpoint" + and response_obj is not None + and isinstance(response_obj, dict) + ): + output = response_obj.get("response", "") + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + prompt, + ) + + if response_obj is not None and isinstance(response_obj, dict): + output = get_content_from_model_response(response_obj) + return ( + self._prompt_to_input_text(prompt), + self._serialize_galileo_output(output), + kwargs.get("messages") or [], + ) + + return self._prompt_to_input_text(prompt), None, kwargs.get("messages") or [] + def get_output_str_from_response( self, response_obj: Any, kwargs: Dict[str, Any] ) -> Optional[str]: - if response_obj is None: - return None - if kwargs.get("call_type", None) == "embedding" or isinstance( - response_obj, litellm.EmbeddingResponse - ): - return None - if isinstance(response_obj, litellm.TextCompletionResponse): - return response_obj.choices[0].text - if isinstance(response_obj, litellm.ImageResponse): - return json.dumps(response_obj["data"], default=str) - if isinstance(response_obj, (litellm.ModelResponse, dict)): - return get_content_from_model_response(response_obj) - return None + _, output_text, _ = self._get_galileo_input_output_content( + kwargs=kwargs, response_obj=response_obj + ) + return output_text + + @staticmethod + def _input_text_from_messages(messages: Any) -> str: + """Return a plain-string summary of the input suitable for the trace-level input field.""" + if isinstance(messages, str): + return messages + if not isinstance(messages, list): + return "" + # Use the last user/human message so the trace table shows the actual prompt + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if str(msg.get("role", "")).lower() in ("user", "human"): + content = msg.get("content") or "" + if isinstance(content, list): + content = " ".join( + b.get("text", "") if isinstance(b, dict) else str(b) + for b in content + ) + if content: + return str(content) + # Fallback: first non-empty content of any role + for msg in messages: + if isinstance(msg, dict): + content = msg.get("content") or "" + if isinstance(content, list): + content = " ".join( + b.get("text", "") if isinstance(b, dict) else str(b) + for b in content + ) + if content: + return str(content) + return "" async def async_log_success_event( self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any ): verbose_logger.debug("On Async Success") + try: + await self._async_log_success_event_impl( + kwargs=kwargs, + response_obj=response_obj, + start_time=start_time, + end_time=end_time, + ) + except Exception: + verbose_logger.exception( + "Galileo Logger: unexpected error in async_log_success_event" + ) + async def _async_log_success_event_impl( + self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any + ): if not self._is_configured(): verbose_logger.debug( - "Galileo Logger: skipping flush — set GALILEO_PROJECT_ID and " - "either GALILEO_API_KEY (hosted) or GALILEO_USERNAME/GALILEO_PASSWORD " - "(enterprise Observe)." + "Galileo Logger: skipping — GALILEO_PROJECT_ID=%s GALILEO_API_KEY=%s GALILEO_BASE_URL=%s", + bool(self.project_id), + bool(self.api_key), + bool(self.base_url), ) return - _latency_ms = int((end_time - start_time).total_seconds() * 1000) - _call_type = kwargs.get("call_type", "litellm") - input_text = litellm.utils.get_formatted_prompt( - data=kwargs, call_type=_call_type + slo: Optional[Dict[str, Any]] = kwargs.get("standard_logging_object") + if slo is None: + verbose_logger.debug( + "Galileo Logger: no standard_logging_object in kwargs, skipping" + ) + return + + _call_type: str = str( + slo.get("call_type") or kwargs.get("call_type") or "litellm" ) - _usage = response_obj.get("usage", {}) or {} - num_input_tokens = _usage.get("prompt_tokens", 0) - num_output_tokens = _usage.get("completion_tokens", 0) + input_text, output_text, messages = self._get_galileo_input_output_content( + kwargs=kwargs, response_obj=response_obj + ) + if output_text is None: + verbose_logger.debug( + "Galileo Logger: skipping %s — no text output to log", _call_type + ) + return - output_text = self.get_output_str_from_response( - response_obj=response_obj, kwargs=kwargs + raw_start = slo.get("startTime") + raw_end = slo.get("endTime") + if raw_start is None or raw_end is None: + verbose_logger.debug( + "Galileo Logger: standard_logging_object missing startTime/endTime, " + "falling back to start_time/end_time params" + ) + if not isinstance(start_time, datetime) or not isinstance( + end_time, datetime + ): + return + start_ts = start_time + end_ts = end_time + if start_ts.tzinfo is None: + start_ts = start_ts.replace(tzinfo=GalileoObserve._local_timezone()) + if end_ts.tzinfo is None: + end_ts = end_ts.replace(tzinfo=GalileoObserve._local_timezone()) + start_ts = start_ts.astimezone(timezone.utc) + end_ts = end_ts.astimezone(timezone.utc) + else: + start_ts = datetime.fromtimestamp(float(raw_start), tz=timezone.utc) + end_ts = datetime.fromtimestamp(float(raw_end), tz=timezone.utc) + _latency_ms = max(0, int((end_ts - start_ts).total_seconds() * 1000)) + num_input_tokens = int(slo.get("prompt_tokens") or 0) + num_output_tokens = int(slo.get("completion_tokens") or 0) + num_total_tokens = int(slo.get("total_tokens") or 0) + if num_total_tokens == 0 and (num_input_tokens or num_output_tokens): + num_total_tokens = num_input_tokens + num_output_tokens + + request_record = LLMResponse( + latency_ms=_latency_ms, + status_code=200, + input_text=input_text, + output_text=output_text, + node_type=_call_type, + model=str(slo.get("model") or kwargs.get("model") or "-"), + num_input_tokens=num_input_tokens, + num_output_tokens=num_output_tokens, + num_total_tokens=num_total_tokens, + cost=slo.get("response_cost"), + created_at=GalileoObserve._format_created_at(start_ts), ) - if output_text is not None: - request_record = LLMResponse( - latency_ms=_latency_ms, - status_code=200, - input_text=input_text, - output_text=output_text, - node_type=_call_type, - model=kwargs.get("model", "-"), - num_input_tokens=num_input_tokens, - num_output_tokens=num_output_tokens, - created_at=start_time.strftime( - "%Y-%m-%dT%H:%M:%S" - ), # timestamp str constructed in "%Y-%m-%dT%H:%M:%S" format + request_dict = request_record.model_dump() + if isinstance(messages, dict): + messages = messages.get("messages") + if isinstance(messages, list) and messages: + request_dict["messages"] = messages + self.in_memory_records.append(request_dict) + verbose_logger.debug( + "Galileo Logger: queued record, in_memory=%d", len(self.in_memory_records) + ) + + # Bound the buffer so persistent flush failures cannot grow it + # without limit. Drop the oldest records once we exceed the cap. + if len(self.in_memory_records) > GALILEO_MAX_IN_MEMORY_RECORDS: + dropped = len(self.in_memory_records) - GALILEO_MAX_IN_MEMORY_RECORDS + self.in_memory_records = self.in_memory_records[ + -GALILEO_MAX_IN_MEMORY_RECORDS: + ] + verbose_logger.warning( + "Galileo Logger: in-memory buffer exceeded %s records; " + "dropped %s oldest record(s). Check Galileo connectivity/credentials.", + GALILEO_MAX_IN_MEMORY_RECORDS, + dropped, ) - request_dict = request_record.model_dump() - messages = kwargs.get("messages") - if messages: - request_dict["messages"] = messages - self.in_memory_records.append(request_dict) - - # Bound the buffer so persistent flush failures cannot grow it - # without limit. Drop the oldest records once we exceed the cap. - if len(self.in_memory_records) > GALILEO_MAX_IN_MEMORY_RECORDS: - dropped = len(self.in_memory_records) - GALILEO_MAX_IN_MEMORY_RECORDS - self.in_memory_records = self.in_memory_records[ - -GALILEO_MAX_IN_MEMORY_RECORDS: - ] - verbose_logger.warning( - "Galileo Logger: in-memory buffer exceeded %s records; " - "dropped %s oldest record(s). Check Galileo connectivity/credentials.", - GALILEO_MAX_IN_MEMORY_RECORDS, - dropped, - ) - - if len(self.in_memory_records) >= self.batch_size: - await self.flush_in_memory_records() + if len(self.in_memory_records) >= self.batch_size: + await self.flush_in_memory_records() async def flush_in_memory_records(self): if not self.in_memory_records: @@ -296,15 +747,23 @@ class GalileoObserve(CustomLogger): ingest_request = self._get_ingest_request() if ingest_request is None: verbose_logger.debug( - "Galileo Logger: missing GALILEO_BASE_URL or GALILEO_PROJECT_ID" + "Galileo Logger: missing GALILEO_BASE_URL or GALILEO_PROJECT_ID — skipping flush" ) return if not await self._ensure_headers(): - verbose_logger.debug("Galileo Logger: could not set request headers") + verbose_logger.debug( + "Galileo Logger: could not set request headers — skipping flush" + ) return url, payload = ingest_request + self._log_flush_config() + self._log_flush_payload(url=url, payload=payload) + verbose_logger.debug( + "Galileo Logger flush headers: %s", + self._redact_headers(self.headers), + ) verbose_logger.debug("flushing in memory records to %s", url) try: @@ -313,6 +772,12 @@ class GalileoObserve(CustomLogger): headers=self.headers, json=payload, ) + except httpx.HTTPStatusError as e: + self._log_http_status_error(error=e, url=url) + verbose_logger.debug( + "Galileo Logger: failed to flush in memory records: %s", e + ) + return except Exception as e: verbose_logger.debug( "Galileo Logger: failed to flush in memory records: %s", e @@ -323,6 +788,11 @@ class GalileoObserve(CustomLogger): verbose_logger.debug( "Galileo Logger: successfully flushed in memory records" ) + verbose_logger.debug( + "Galileo Logger flush response: status=%s body=%s", + response.status_code, + response.text, + ) del self.in_memory_records[:records_in_payload] else: verbose_logger.debug("Galileo Logger: failed to flush in memory records") diff --git a/litellm/proxy/_experimental/out/assets/logos/galileo.ico b/litellm/proxy/_experimental/out/assets/logos/galileo.ico new file mode 100644 index 0000000000000000000000000000000000000000..c50b9de4df53fe68ea8d2e22e71f2b8f105423e2 GIT binary patch literal 9714 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 literal 0 HcmV?d00001 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6de8f0a3cd..5059a6f2e5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3516,6 +3516,19 @@ class AllCallbacks(LiteLLMPydanticObjectBase): ui_callback_name="Traceloop", ) + galileo: CallbackOnUI = CallbackOnUI( + litellm_callback_name="galileo", + litellm_callback_params=[ + "GALILEO_API_KEY", + "GALILEO_PROJECT_ID", + "GALILEO_LOG_STREAM_ID", + "GALILEO_BASE_URL", + "GALILEO_USERNAME", + "GALILEO_PASSWORD", + ], + ui_callback_name="Galileo", + ) + class SpendLogsMetadata(TypedDict): """ diff --git a/tests/test_litellm/integrations/test_galileo.py b/tests/test_litellm/integrations/test_galileo.py index aab220f46b..8ce5eb776f 100644 --- a/tests/test_litellm/integrations/test_galileo.py +++ b/tests/test_litellm/integrations/test_galileo.py @@ -1,5 +1,6 @@ import os import sys +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,6 +8,8 @@ import pytest sys.path.insert(0, os.path.abspath("../..")) from litellm.integrations.galileo import GalileoObserve +from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse +from litellm.types.rerank import RerankResponse from litellm.types.utils import ( Choices, EmbeddingResponse, @@ -15,6 +18,7 @@ from litellm.types.utils import ( Message, ModelResponse, TextCompletionResponse, + TranscriptionResponse, ) @@ -46,16 +50,97 @@ async def test_galileo_v2_ingest_url_and_headers(galileo_v2_env): url, payload = logger._get_ingest_request() assert ( url - == "https://api.galileo.ai/v2/projects/86ff8ebe-a297-4134-b167-748bdd8d2c20/spans" + == "https://api.galileo.ai/ingest/traces/86ff8ebe-a297-4134-b167-748bdd8d2c20" ) assert payload["log_stream_id"] == "76c4ea50-8aa3-4771-a0d7-8567b112210f" - assert payload["spans"][0]["type"] == "llm" - assert payload["spans"][0]["output"]["content"] == "hello" + assert payload["is_complete"] is True + assert payload["traces"][0]["type"] == "trace" + assert payload["traces"][0]["spans"][0]["type"] == "llm" + assert payload["traces"][0]["spans"][0]["output"]["content"] == "hello" + assert payload["traces"][0]["spans"][0]["metrics"]["num_total_tokens"] == 3 + assert payload["traces"][0]["metrics"]["num_input_tokens"] == 1 + assert payload["traces"][0]["metrics"]["num_output_tokens"] == 2 + assert payload["traces"][0]["metrics"]["num_total_tokens"] == 3 + assert payload["traces"][0]["spans"][0]["trace_id"] == payload["traces"][0]["id"] assert await logger._ensure_headers() is True assert logger.headers["Galileo-API-Key"] == "test-api-key" +def test_galileo_token_metrics_from_record_falls_back_to_sum(): + metrics = GalileoObserve._token_metrics_from_record( + {"num_input_tokens": 5, "num_output_tokens": 7} + ) + assert metrics == { + "num_input_tokens": 5, + "num_output_tokens": 7, + "num_total_tokens": 12, + } + + +def test_galileo_token_metrics_from_record_sums_zero_total(): + metrics = GalileoObserve._token_metrics_from_record( + {"num_input_tokens": 5, "num_output_tokens": 7, "num_total_tokens": 0} + ) + assert metrics == { + "num_input_tokens": 5, + "num_output_tokens": 7, + "num_total_tokens": 12, + } + + +def test_galileo_token_metrics_from_record_includes_cost(): + metrics = GalileoObserve._token_metrics_from_record( + { + "num_input_tokens": 1, + "num_output_tokens": 2, + "num_total_tokens": 3, + "cost": 0.000855, + } + ) + assert metrics["cost"] == 0.000855 + + +def test_galileo_input_text_from_messages(): + assert GalileoObserve._input_text_from_messages("hello") == "hello" + assert ( + GalileoObserve._input_text_from_messages( + [{"role": "user", "content": "test responses api 1"}] + ) + == "test responses api 1" + ) + + +def test_galileo_get_output_str_responses_api(galileo_v2_env): + from litellm.types.llms.openai import ResponsesAPIResponse + + logger = GalileoObserve() + resp_dict = { + "id": "resp_123", + "created_at": 1, + "output": [ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Hi! How can I help?", + "annotations": [], + } + ], + } + ], + } + response = ResponsesAPIResponse(**resp_dict) + result = logger.get_output_str_from_response(response, {"call_type": "aresponses"}) + assert result is not None + assert '"Hi! How can I help?"' in result + assert '"type": "message"' in result + + def test_galileo_v2_span_preserves_message_roles(galileo_v2_env): record = { "latency_ms": 1, @@ -72,7 +157,36 @@ def test_galileo_v2_span_preserves_message_roles(galileo_v2_env): {"role": "user", "content": "hello"}, ], } - span = GalileoObserve._record_to_v2_span(record) + span = GalileoObserve._record_to_v2_span( + record, trace_id="trace-id", span_id="span-id" + ) + assert span["input"] == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hello"}, + ] + + +def test_galileo_v2_span_unwraps_prompt_messages(galileo_v2_env): + record = { + "latency_ms": 1, + "status_code": 200, + "input_text": "fallback", + "output_text": "ok", + "node_type": "pass_through_endpoint", + "model": "gpt-5.2", + "num_input_tokens": 0, + "num_output_tokens": 0, + "created_at": "2026-05-25T12:00:00", + "messages": { + "messages": [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hello"}, + ] + }, + } + span = GalileoObserve._record_to_v2_span( + record, trace_id="trace-id", span_id="span-id" + ) assert span["input"] == [ {"role": "system", "content": "be helpful"}, {"role": "user", "content": "hello"}, @@ -94,7 +208,8 @@ def test_galileo_output_text_from_model_response(galileo_v2_env): ) output = logger.get_output_str_from_response(response, {"call_type": "acompletion"}) - assert output == "assistant reply" + assert output is not None + assert '"assistant reply"' in output @pytest.mark.asyncio @@ -199,6 +314,19 @@ def test_galileo_input_messages_fallbacks(): ) == [{"role": "user", "content": "fallback"}] +def test_galileo_format_created_at_converts_local_naive_to_utc(): + from datetime import timedelta + + ist = timezone(timedelta(hours=5, minutes=30)) + + with patch.object(GalileoObserve, "_local_timezone", return_value=ist): + local_naive = datetime(2026, 6, 4, 9, 44, 49) + assert GalileoObserve._format_created_at(local_naive) == "2026-06-04T04:14:49Z" + + aware_utc = datetime(2026, 6, 4, 4, 14, 49, tzinfo=timezone.utc) + assert GalileoObserve._format_created_at(aware_utc) == "2026-06-04T04:14:49Z" + + def test_galileo_record_to_v2_span_with_tags_and_offset(): span = GalileoObserve._record_to_v2_span( { @@ -212,13 +340,17 @@ def test_galileo_record_to_v2_span_with_tags_and_offset(): "num_output_tokens": 2, "created_at": "2026-05-25T12:00:00", "tags": ["t1"], - } + }, + trace_id="trace-id", + span_id="span-id", ) assert span["tags"] == ["t1"] assert span["created_at"].endswith("Z") offset = GalileoObserve._record_to_v2_span( - {"created_at": "2026-05-25T12:00:00-05:00"} + {"created_at": "2026-05-25T12:00:00-05:00"}, + trace_id="trace-id", + span_id="span-id", ) assert offset["created_at"] == "2026-05-25T12:00:00-05:00" @@ -243,9 +375,118 @@ def test_galileo_get_output_str_variants(galileo_v2_env): image_resp = ImageResponse(data=[ImageObject(url="https://x/y.png")]) assert "y.png" in logger.get_output_str_from_response(image_resp, {}) + speech_resp = HttpxBinaryResponseContent(response=MagicMock()) + assert ( + logger.get_output_str_from_response(speech_resp, {"call_type": "aspeech"}) + == "speech-output" + ) + + transcription_resp = TranscriptionResponse(text="hello world") + assert ( + logger.get_output_str_from_response( + transcription_resp, {"call_type": "atranscription"} + ) + == "hello world" + ) + + realtime_output = [{"type": "response", "text": "hi"}] + assert ( + logger.get_output_str_from_response( + realtime_output, + {"call_type": "_arealtime", "input": {"session": "abc"}}, + ) + == '[{"type": "response", "text": "hi"}]' + ) + + pass_through_output = {"response": "passthrough-body", "status": 200} + assert ( + logger.get_output_str_from_response( + pass_through_output, {"call_type": "pass_through_endpoint"} + ) + == "passthrough-body" + ) + + model_resp = ModelResponse( + choices=[Choices(message=Message(content="chat reply", role="assistant"))] + ) + assert '"chat reply"' in logger.get_output_str_from_response( + model_resp, + {"call_type": "acompletion", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert logger.get_output_str_from_response("not-a-supported-type", {}) is None +def test_galileo_get_input_output_error_status_message(galileo_v2_env): + logger = GalileoObserve() + input_text, output_text, _ = logger._get_galileo_input_output_content( + kwargs={"messages": [{"role": "user", "content": "fail me"}]}, + response_obj=None, + level="ERROR", + status_message="provider timeout", + ) + assert input_text == "fail me" + assert output_text == "provider timeout" + + +def test_galileo_get_output_str_rerank_response(galileo_v2_env): + logger = GalileoObserve() + rerank_response = RerankResponse( + results=[ + {"index": 2, "relevance_score": 0.98}, + {"index": 0, "relevance_score": 0.12}, + ] + ) + output = logger.get_output_str_from_response( + rerank_response, {"call_type": "arerank"} + ) + assert output is not None + assert '"index": 2' in output + assert '"relevance_score": 0.98' in output + + +@pytest.mark.asyncio +async def test_galileo_async_log_success_rerank(galileo_v2_env): + import datetime + + logger = GalileoObserve() + rerank_response = RerankResponse(results=[{"index": 1, "relevance_score": 0.95}]) + + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 201 + + with patch.object(logger.async_httpx_handler, "post", return_value=mock_response): + await logger.async_log_success_event( + kwargs={ + "call_type": "arerank", + "model": "cohere/rerank-english-v3.0", + "query": "What is the capital of the United States?", + "documents": ["doc-a", "doc-b"], + "standard_logging_object": { + "call_type": "arerank", + "model": "cohere/rerank-english-v3.0", + "messages": "What is the capital of the United States?", + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "response_cost": 0.0, + "startTime": datetime.datetime( + 2026, 5, 25, 12, 0, 0, tzinfo=datetime.timezone.utc + ).timestamp(), + "endTime": datetime.datetime( + 2026, 5, 25, 12, 0, 1, tzinfo=datetime.timezone.utc + ).timestamp(), + }, + }, + response_obj=rerank_response, + start_time=datetime.datetime(2026, 5, 25, 12, 0, 0), + end_time=datetime.datetime(2026, 5, 25, 12, 0, 1), + ) + + assert logger.in_memory_records == [] + + def test_galileo_get_ingest_request_unconfigured(monkeypatch): monkeypatch.delenv("GALILEO_API_KEY", raising=False) monkeypatch.delenv("GALILEO_BASE_URL", raising=False) @@ -260,11 +501,27 @@ def test_galileo_get_ingest_request_legacy(monkeypatch): monkeypatch.setenv("GALILEO_PASSWORD", "pw") monkeypatch.setenv("GALILEO_BASE_URL", "https://galileo.example/") monkeypatch.setenv("GALILEO_PROJECT_ID", "proj") + monkeypatch.setenv("GALILEO_LOG_STREAM_ID", "stream-id") logger = GalileoObserve() - logger.in_memory_records = [{"foo": "bar"}] + logger.in_memory_records = [ + { + "latency_ms": 1, + "status_code": 200, + "input_text": "hi", + "output_text": "ok", + "node_type": "acompletion", + "model": "gpt", + "num_input_tokens": 1, + "num_output_tokens": 1, + "num_total_tokens": 2, + "created_at": "2026-05-25T12:00:00", + } + ] url, payload = logger._get_ingest_request() - assert url == "https://galileo.example/projects/proj/observe/ingest" - assert payload == {"records": [{"foo": "bar"}]} + assert url == "https://galileo.example/v2/projects/proj/traces" + assert "traces" in payload + assert payload["log_stream_id"] == "stream-id" + assert payload["traces"][0]["input"] == "hi" @pytest.mark.asyncio @@ -360,6 +617,48 @@ async def test_galileo_flush_resets_headers_on_401(monkeypatch): assert logger.in_memory_records == [{"records": "x"}] +@pytest.mark.asyncio +async def test_galileo_async_log_success_preserves_passthrough_messages( + galileo_v2_env, +): + import datetime + + logger = GalileoObserve() + logger.batch_size = 2 + messages = [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + ] + + await logger.async_log_success_event( + kwargs={ + "call_type": "pass_through_endpoint", + "model": "gpt", + "messages": messages, + "standard_logging_object": { + "call_type": "pass_through_endpoint", + "model": "gpt", + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 0, + "response_cost": 0.001, + "startTime": datetime.datetime( + 2026, 5, 25, 12, 0, 0, tzinfo=datetime.timezone.utc + ).timestamp(), + "endTime": datetime.datetime( + 2026, 5, 25, 12, 0, 1, tzinfo=datetime.timezone.utc + ).timestamp(), + }, + }, + response_obj={"response": "ok"}, + start_time=datetime.datetime(2026, 5, 25, 12, 0, 0), + end_time=datetime.datetime(2026, 5, 25, 12, 0, 1), + ) + + assert logger.in_memory_records[0]["messages"] == messages + assert logger.in_memory_records[0]["num_total_tokens"] == 3 + + @pytest.mark.asyncio async def test_galileo_async_log_success_appends_and_flushes(galileo_v2_env): import datetime @@ -387,11 +686,26 @@ async def test_galileo_async_log_success_appends_and_flushes(galileo_v2_env): "call_type": "acompletion", "model": "gpt", "messages": [{"role": "user", "content": "hi"}], + "standard_logging_object": { + "call_type": "acompletion", + "model": "gpt", + "messages": [{"role": "user", "content": "hi"}], + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + "response_cost": 0.001, + "startTime": datetime.datetime( + 2026, 5, 25, 12, 0, 0, tzinfo=datetime.timezone.utc + ).timestamp(), + "endTime": datetime.datetime( + 2026, 5, 25, 12, 0, 1, tzinfo=datetime.timezone.utc + ).timestamp(), + }, }, response_obj=response, start_time=datetime.datetime(2026, 5, 25, 12, 0, 0), end_time=datetime.datetime(2026, 5, 25, 12, 0, 1), ) - assert "/v2/projects/" in flushed_url["url"] + assert "/ingest/traces/" in flushed_url["url"] assert logger.in_memory_records == [] diff --git a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py index 1befa9a72b..dfc9f0361c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py @@ -257,3 +257,12 @@ class TestCallbackManagementEndpoints: assert ( has_detailed_params ), "Expected at least one callback to have detailed parameter configuration" + + galileo_config = next( + (config for config in response_data if config.get("id") == "galileo"), + None, + ) + assert galileo_config is not None + assert galileo_config["displayName"] == "Galileo" + assert "GALILEO_API_KEY" in galileo_config["dynamic_params"] + assert "GALILEO_PROJECT_ID" in galileo_config["dynamic_params"] diff --git a/ui/litellm-dashboard/public/assets/logos/galileo.ico b/ui/litellm-dashboard/public/assets/logos/galileo.ico new file mode 100644 index 0000000000000000000000000000000000000000..c50b9de4df53fe68ea8d2e22e71f2b8f105423e2 GIT binary patch literal 9714 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 literal 0 HcmV?d00001 diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 187c05a468..0334c55f66 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -43,6 +43,21 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ }, description: "Custom Callback API Logging Integration", }, + { + id: "galileo", + displayName: "Galileo", + logo: `${asset_logos_folder}galileo.ico`, + supports_key_team_logging: false, + dynamic_params: { + GALILEO_API_KEY: "password", + GALILEO_PROJECT_ID: "text", + GALILEO_LOG_STREAM_ID: "text", + GALILEO_BASE_URL: "text", + GALILEO_USERNAME: "text", + GALILEO_PASSWORD: "password", + }, + description: "Galileo AI Observability Integration", + }, { id: "datadog", displayName: "Datadog",