* feat: add comprehensive OpenTelemetry tracing - Add tool execution spans for reflect operations - Add tool call information (names, params) to spans - Change verification scope from 'test' to 'verification' - Add hindsight.reflect_generation span for done() processing - Implement no-op tracer for improved code readability - Update documentation for OTEL configuration - Resolve merge conflicts from rebase * fix: properly serialize Pydantic models in span recording - Add _serialize_for_span() helper to handle Pydantic models - Update all providers to use the helper function - Fixes test failures with 'Object of type X is not JSON serializable' * feat: add Grafana LGTM stack for unified local observability Add Grafana LGTM (Loki, Grafana, Tempo, Mimir) as the recommended local development observability stack. This provides traces, metrics, and logs in a single Docker container instead of separate tools. Changes: - Add scripts/dev/grafana/ with docker-compose and README - Add scripts/dev/start-grafana.sh startup script - Update .env.example to reference Grafana LGTM - Update configuration docs to emphasize Grafana LGTM as primary option - Reorder OTLP backend list to show Grafana LGTM first Benefits: - Single container vs multiple separate tools (Jaeger, SigNoz, etc.) - ~515MB image with full observability stack - Compatible with existing OTLP configuration - Simpler local development setup * chore: remove SigNoz scripts and references Remove SigNoz observability stack in favor of Grafana LGTM as the sole recommended local development tracing solution. Changes: - Delete scripts/dev/signoz/ directory and all SigNoz configurations - Delete scripts/dev/start-signoz.sh startup script - Remove SigNoz references from .env.example - Remove SigNoz from OTLP backends list in configuration docs Grafana LGTM provides the same capabilities (traces, metrics, logs) in a simpler single-container setup. * feat: add consolidation span hierarchy for tracing Add parent-child span structure for consolidation operations: - hindsight.consolidation: Parent span for each memory being processed - hindsight.consolidation_recall: Child span for finding related observations - LLM call span: Automatically created by LLM provider (scope="consolidation") This enables detailed timing breakdown in Grafana Tempo: - Total consolidation time per memory - Time spent in recall - Time spent in LLM call - Time spent executing actions (create/update) All consolidation tests pass (31/31). * feat: add Prometheus metrics and GenAI dashboard to Grafana stack Add comprehensive metrics and dashboarding to the Grafana LGTM stack: Metrics Collection: - Configure Prometheus to scrape Hindsight API /metrics endpoint - Scrape interval: 10 seconds - Targets hindsight-api on host.docker.internal:8888 GenAI Dashboard: - Pre-configured dashboard with 6 panels: - LLM call rate (by provider/model) - LLM call duration (p50/p95 by scope) - Token usage - input tokens/sec by scope - Token usage - output tokens/sec by scope - Operations rate (retain/recall/reflect/consolidation) - Operation duration p95 by operation type Configuration: - Mount prometheus.yml for metrics scraping - Mount dashboards directory for auto-provisioning - Add host.docker.internal mapping for container->host access - Dashboard provisioning with auto-reload every 10s Documentation: - Updated README with metrics viewing instructions - Added PromQL query examples - Documented dashboard access and navigation This provides full observability: traces (Tempo) + metrics (Prometheus/Mimir) + dashboards (Grafana) * refactor: merge Grafana setup into existing monitoring stack Consolidate the separate scripts/dev/grafana/ setup into the existing scripts/dev/monitoring/ stack, using Grafana LGTM (Loki, Grafana, Tempo, Mimir). Changes: - Remove separate scripts/dev/grafana/ directory and start-grafana.sh - Rewrite scripts/dev/monitoring/start.sh to use Docker + Grafana LGTM (was: download native Prometheus/Grafana binaries) - Add docker-compose.yaml for Grafana LGTM container - Add prometheus.yml for scraping Hindsight API metrics - Mount existing dashboards from monitoring/grafana/dashboards/ - Add comprehensive README.md Benefits: - Single unified monitoring command: ./scripts/dev/start-monitoring.sh - Uses existing dashboard files (hindsight-operations, hindsight-llm, hindsight-api-service) - Simpler setup: Docker-based vs downloading/running native binaries - Full observability: traces + metrics + logs + dashboards in one container - Standard ports: Grafana on 3000, OTLP on 4317/4318 Architecture: - Grafana LGTM container (~515MB) provides all components - Dashboards auto-provisioned from monitoring/grafana/dashboards/ - Prometheus scrapes host.docker.internal:8888/metrics - Shared hindsight-network for future service-to-service tracing * fix: run monitoring stack in foreground for easy Ctrl+C stop Change docker-compose from detached (-d) to foreground mode. Users can now stop the stack with Ctrl+C instead of needing to run docker-compose down separately. * fix: remove invalid home dashboard path and obsolete version field - Remove GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH environment variable (was pointing to wrong path causing 'Failed to load home dashboard' error) - Remove obsolete 'version' field from docker-compose.yaml (docker-compose v2+ doesn't require version field) * fix: load Hindsight dashboards in Grafana LGTM Mount Hindsight dashboard JSON files and custom provisioning config to make dashboards visible in Grafana. Changes: - Mount hindsight-operations.json, hindsight-llm.json, hindsight-api-service.json to /otel-lgtm/ - Create grafana-dashboards.yaml with all dashboard providers (default + Hindsight) - Mount custom provisioning config to override LGTM default All 3 Hindsight dashboards now appear in Grafana UI with metrics from Prometheus scraping the Hindsight API /metrics endpoint. * fix: configure Prometheus to scrape Hindsight API metrics Update prometheus.yml to include both OTLP receiver config (from LGTM) and scrape_configs for pulling metrics from Hindsight API. Changes: - Mount prometheus.yml to /otel-lgtm/prometheus.yaml (where LGTM reads it) - Add scrape_configs section to pull from host.docker.internal:8888/metrics - Keep OTLP receiver configuration for trace metrics - Set scrape_interval to 5s Verified: Prometheus now successfully scrapes hindsight_llm_calls_total and other Hindsight metrics. Dashboards now show live data! * feat: add comprehensive tracing for recall and improve reflect/mental_model_refresh spans - Add recall operation tracing with parent-child span hierarchy - Parent: hindsight.recall with attributes (bank_id, query, fact_types, etc.) - Children: recall_embedding, recall_retrieval, recall_fusion, recall_rerank - Fixed context propagation using start_as_current_span() - Improve reflect tracing spans - Remove reflect_generation spans, use reflect instead - Change done() tool processing to hindsight.reflect_tool_call - Fix mental_model_refresh span nesting - Add _skip_span parameter to reflect_async to avoid duplicate hindsight.reflect spans - Mental model refresh now has clean span hierarchy without nested reflect parent - Add comprehensive tracing verification tests - Test span hierarchy and attributes for all operations - Verify parent-child relationships - 5 passing tests covering recall, reflect, consolidation, and mental_model_refresh * refactor: remove redundant is_tracing_enabled() checks - Remove all is_tracing_enabled() conditional checks before tracing calls - NoOpTracer/NoOpSpan handle disabled tracing automatically - Simplify code by always calling tracer methods directly - Fix NoOpTracer.start_as_current_span() to yield NoOpSpan instead of None Changes: - memory_engine.py: Remove 5 is_tracing_enabled checks in recall spans - agent.py: Remove 2 is_tracing_enabled checks in reflect tool spans - tracing.py: Fix NoOpTracer context manager to yield proper NoOpSpan This eliminates ~50 lines of redundant conditional code while maintaining identical behavior. * docs: simplify distributed tracing section in monitoring.md - Make tracing documentation more concise - Focus on span hierarchy and attributes - Remove verbose troubleshooting and performance sections - Keep configuration.md for env vars only
621 lines
23 KiB
Python
621 lines
23 KiB
Python
"""
|
|
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
|
|
|
|
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
|
|
without separate OpenAI Platform API credits. It uses OAuth tokens from
|
|
~/.codex/auth.json and communicates with the ChatGPT backend API.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
|
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
|
from hindsight_api.metrics import get_metrics_collector
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CodexLLM(LLMInterface):
|
|
"""
|
|
LLM provider using OpenAI Codex OAuth authentication.
|
|
|
|
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
|
|
and makes API calls to chatgpt.com/backend-api/codex/responses.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
|
|
base_url: str,
|
|
model: str,
|
|
reasoning_effort: str = "low",
|
|
**kwargs: Any,
|
|
):
|
|
"""Initialize Codex LLM provider."""
|
|
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
|
|
|
# Load Codex OAuth credentials
|
|
try:
|
|
self.access_token, self.account_id = self._load_codex_auth()
|
|
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
|
|
except Exception as e:
|
|
raise RuntimeError(
|
|
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
|
|
"To set up Codex authentication:\n"
|
|
"1. Install Codex CLI: npm install -g @openai/codex\n"
|
|
"2. Login: codex auth login\n"
|
|
"3. Verify: ls ~/.codex/auth.json\n\n"
|
|
"Or use a different provider (openai, anthropic, gemini) with API keys."
|
|
) from e
|
|
|
|
# Use ChatGPT backend API endpoint
|
|
if not self.base_url:
|
|
self.base_url = "https://chatgpt.com/backend-api"
|
|
|
|
# Normalize model name (strip openai/ prefix if present)
|
|
if self.model.startswith("openai/"):
|
|
self.model = self.model[len("openai/") :]
|
|
|
|
# Map reasoning effort to Codex reasoning summary format
|
|
# Codex supports: "auto", "concise", "detailed"
|
|
self.reasoning_summary = self._map_reasoning_effort(reasoning_effort)
|
|
|
|
# HTTP client for SSE streaming
|
|
self._client = httpx.AsyncClient(timeout=120.0)
|
|
|
|
def _load_codex_auth(self) -> tuple[str, str]:
|
|
"""
|
|
Load OAuth credentials from ~/.codex/auth.json.
|
|
|
|
Returns:
|
|
Tuple of (access_token, account_id).
|
|
|
|
Raises:
|
|
FileNotFoundError: If auth file doesn't exist.
|
|
ValueError: If auth file is invalid.
|
|
"""
|
|
auth_file = Path.home() / ".codex" / "auth.json"
|
|
|
|
if not auth_file.exists():
|
|
raise FileNotFoundError(
|
|
f"Codex auth file not found: {auth_file}\nRun 'codex auth login' to authenticate with ChatGPT Plus/Pro."
|
|
)
|
|
|
|
with open(auth_file) as f:
|
|
data = json.load(f)
|
|
|
|
# Validate auth structure
|
|
auth_mode = data.get("auth_mode")
|
|
if auth_mode != "chatgpt":
|
|
raise ValueError(f"Expected auth_mode='chatgpt', got: {auth_mode}")
|
|
|
|
tokens = data.get("tokens", {})
|
|
access_token = tokens.get("access_token")
|
|
account_id = tokens.get("account_id")
|
|
|
|
if not access_token:
|
|
raise ValueError("No access_token found in Codex auth file. Run 'codex auth login' again.")
|
|
|
|
return access_token, account_id
|
|
|
|
def _map_reasoning_effort(self, effort: str) -> str:
|
|
"""
|
|
Map standard reasoning effort to Codex reasoning summary format.
|
|
|
|
Args:
|
|
effort: Standard effort level ("low", "medium", "high", "xhigh").
|
|
|
|
Returns:
|
|
Codex reasoning summary: "concise", "detailed", or "auto".
|
|
"""
|
|
mapping = {
|
|
"low": "concise",
|
|
"medium": "auto",
|
|
"high": "detailed",
|
|
"xhigh": "detailed",
|
|
}
|
|
return mapping.get(effort.lower(), "auto")
|
|
|
|
async def verify_connection(self) -> None:
|
|
"""Verify Codex connection by making a simple test call."""
|
|
try:
|
|
logger.info(f"Verifying Codex LLM: model={self.model}, account={self.account_id}...")
|
|
await self.call(
|
|
messages=[{"role": "user", "content": "Say 'ok'"}],
|
|
max_completion_tokens=10,
|
|
max_retries=2,
|
|
initial_backoff=0.5,
|
|
max_backoff=2.0,
|
|
scope="verification",
|
|
)
|
|
logger.info(f"Codex LLM verified: {self.model}")
|
|
except Exception as e:
|
|
raise RuntimeError(f"Codex LLM connection verification failed for {self.model}: {e}") from e
|
|
|
|
async def call(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None = None,
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "memory",
|
|
max_retries: int = 10,
|
|
initial_backoff: float = 1.0,
|
|
max_backoff: float = 60.0,
|
|
skip_validation: bool = False,
|
|
strict_schema: bool = False,
|
|
return_usage: bool = False,
|
|
) -> Any:
|
|
"""Make API call to Codex backend with SSE streaming."""
|
|
start_time = time.time()
|
|
|
|
# Prepare system instructions
|
|
system_instruction = ""
|
|
user_messages = []
|
|
|
|
for msg in messages:
|
|
role = msg.get("role", "user")
|
|
content = msg.get("content", "")
|
|
|
|
if role == "system":
|
|
system_instruction += ("\n\n" + content) if system_instruction else content
|
|
else:
|
|
user_messages.append(msg)
|
|
|
|
# Add JSON schema instruction if response_format is provided
|
|
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
|
schema = response_format.model_json_schema()
|
|
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
|
system_instruction += schema_msg
|
|
|
|
# gpt-5.2-codex only supports "detailed" reasoning summary
|
|
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
|
|
|
|
# Build Codex request payload
|
|
payload = {
|
|
"model": self.model,
|
|
"instructions": system_instruction,
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": msg.get("role", "user"),
|
|
"content": msg.get("content", ""),
|
|
}
|
|
for msg in user_messages
|
|
],
|
|
"tools": [],
|
|
"tool_choice": "auto",
|
|
"parallel_tool_calls": True,
|
|
"reasoning": {"summary": reasoning_summary},
|
|
"store": False, # Codex uses stateless mode
|
|
"stream": True, # SSE streaming
|
|
"include": ["reasoning.encrypted_content"],
|
|
"prompt_cache_key": str(uuid.uuid4()),
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.access_token}",
|
|
"Content-Type": "application/json",
|
|
"OpenAI-Account-ID": self.account_id,
|
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
"Origin": "https://chatgpt.com",
|
|
}
|
|
|
|
url = f"{self.base_url}/codex/responses"
|
|
last_exception = None
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
|
response.raise_for_status()
|
|
|
|
# Parse SSE stream
|
|
content = await self._parse_sse_stream(response)
|
|
|
|
# Handle structured output
|
|
if response_format is not None:
|
|
# Models may wrap JSON in markdown
|
|
clean_content = content
|
|
if "```json" in content:
|
|
clean_content = content.split("```json")[1].split("```")[0].strip()
|
|
elif "```" in content:
|
|
clean_content = content.split("```")[1].split("```")[0].strip()
|
|
|
|
try:
|
|
json_data = json.loads(clean_content)
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
await asyncio.sleep(backoff)
|
|
last_exception = e
|
|
continue
|
|
raise
|
|
|
|
if skip_validation:
|
|
result = json_data
|
|
else:
|
|
result = response_format.model_validate(json_data)
|
|
else:
|
|
result = content
|
|
|
|
# Record metrics
|
|
duration = time.time() - start_time
|
|
metrics = get_metrics_collector()
|
|
metrics.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
duration=duration,
|
|
input_tokens=0, # Codex doesn't report token counts in SSE
|
|
output_tokens=0,
|
|
success=True,
|
|
)
|
|
|
|
# Record trace span
|
|
from hindsight_api.tracing import get_span_recorder
|
|
|
|
# Estimate tokens for tracing
|
|
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
|
|
estimated_output = len(content) // 4
|
|
span_recorder = get_span_recorder()
|
|
span_recorder.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content=result if isinstance(result, str) else json.dumps(result),
|
|
input_tokens=estimated_input,
|
|
output_tokens=estimated_output,
|
|
duration=duration,
|
|
finish_reason=None,
|
|
error=None,
|
|
)
|
|
|
|
if return_usage:
|
|
# Codex doesn't provide token counts, estimate based on content
|
|
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
|
|
estimated_output = len(content) // 4
|
|
token_usage = TokenUsage(
|
|
input_tokens=estimated_input,
|
|
output_tokens=estimated_output,
|
|
total_tokens=estimated_input + estimated_output,
|
|
)
|
|
return result, token_usage
|
|
|
|
return result
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
last_exception = e
|
|
status_code = e.response.status_code
|
|
|
|
# Fast fail on auth errors
|
|
if status_code in (401, 403):
|
|
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
|
|
raise RuntimeError(
|
|
"Codex authentication failed. Your OAuth token may have expired.\n"
|
|
"Run 'codex auth login' to re-authenticate."
|
|
) from e
|
|
|
|
# Log the actual error message from the API
|
|
error_detail = e.response.text[:500] if hasattr(e.response, "text") else str(e)
|
|
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
logger.warning(
|
|
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
|
|
)
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(
|
|
f"Codex HTTP error after {max_retries + 1} attempts: Status {status_code}, Detail: {error_detail}"
|
|
)
|
|
raise
|
|
|
|
except httpx.RequestError as e:
|
|
last_exception = e
|
|
if attempt < max_retries:
|
|
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
|
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
|
await asyncio.sleep(backoff)
|
|
continue
|
|
else:
|
|
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
|
|
raise
|
|
|
|
if last_exception:
|
|
raise last_exception
|
|
raise RuntimeError("Codex call failed after all retries")
|
|
|
|
async def _parse_sse_stream(self, response: httpx.Response) -> str:
|
|
"""
|
|
Parse Server-Sent Events (SSE) stream from Codex API.
|
|
|
|
Args:
|
|
response: HTTP response with SSE stream.
|
|
|
|
Returns:
|
|
Extracted text content from stream.
|
|
"""
|
|
full_text = ""
|
|
event_type = None
|
|
|
|
async for line in response.aiter_lines():
|
|
if not line:
|
|
continue
|
|
|
|
# Track event type
|
|
if line.startswith("event: "):
|
|
event_type = line[7:]
|
|
|
|
# Parse data
|
|
elif line.startswith("data: "):
|
|
data_str = line[6:]
|
|
if data_str == "[DONE]":
|
|
break
|
|
|
|
try:
|
|
data = json.loads(data_str)
|
|
|
|
# Extract content based on event type
|
|
if event_type == "response.text.delta" and "delta" in data:
|
|
full_text += data["delta"]
|
|
elif event_type == "response.content_part.delta" and "delta" in data:
|
|
full_text += data["delta"]
|
|
# Check for item content
|
|
elif "item" in data:
|
|
item = data["item"]
|
|
if "content" in item:
|
|
content = item["content"]
|
|
if isinstance(content, list):
|
|
for part in content:
|
|
if isinstance(part, dict) and "text" in part:
|
|
full_text += part["text"]
|
|
elif isinstance(content, str):
|
|
full_text += content
|
|
|
|
except json.JSONDecodeError:
|
|
# Skip malformed JSON events
|
|
pass
|
|
|
|
return full_text
|
|
|
|
async def call_with_tools(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "tools",
|
|
max_retries: int = 5,
|
|
initial_backoff: float = 1.0,
|
|
max_backoff: float = 30.0,
|
|
tool_choice: str | dict[str, Any] = "auto",
|
|
) -> LLMToolCallResult:
|
|
"""
|
|
Make API call with tool calling support.
|
|
|
|
Parses Codex SSE stream to extract tool calls from response.output_item.done events.
|
|
Tools are converted from OpenAI format to Codex format (flat structure at top level).
|
|
|
|
Args:
|
|
messages: List of message dicts. Can include tool results with role='tool'.
|
|
tools: List of tool definitions in OpenAI format.
|
|
max_completion_tokens: Maximum tokens in response.
|
|
temperature: Sampling temperature.
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Maximum retry attempts.
|
|
initial_backoff: Initial backoff time in seconds.
|
|
max_backoff: Maximum backoff time in seconds.
|
|
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
|
|
|
|
Returns:
|
|
LLMToolCallResult with content and/or tool_calls.
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# Prepare system instructions
|
|
system_instruction = ""
|
|
user_messages = []
|
|
|
|
for msg in messages:
|
|
role = msg.get("role", "user")
|
|
content = msg.get("content", "")
|
|
|
|
if role == "system":
|
|
system_instruction += ("\n\n" + content) if system_instruction else content
|
|
elif role == "tool":
|
|
# Handle tool results
|
|
user_messages.append(
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": f"Tool result: {content}",
|
|
}
|
|
)
|
|
else:
|
|
user_messages.append(
|
|
{
|
|
"type": "message",
|
|
"role": role,
|
|
"content": content,
|
|
}
|
|
)
|
|
|
|
# Convert tools to Codex format
|
|
# Codex expects tools with type and name/description/parameters at top level
|
|
codex_tools = []
|
|
for tool in tools:
|
|
func = tool.get("function", {})
|
|
codex_tools.append(
|
|
{
|
|
"type": "function",
|
|
"name": func.get("name", ""),
|
|
"description": func.get("description", ""),
|
|
"parameters": func.get("parameters", {}),
|
|
}
|
|
)
|
|
|
|
# gpt-5.2-codex only supports "detailed" reasoning summary
|
|
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
|
|
|
|
payload = {
|
|
"model": self.model,
|
|
"instructions": system_instruction,
|
|
"input": user_messages,
|
|
"tools": codex_tools,
|
|
"tool_choice": tool_choice,
|
|
"parallel_tool_calls": True,
|
|
"reasoning": {"summary": reasoning_summary},
|
|
"store": False,
|
|
"stream": True,
|
|
"include": ["reasoning.encrypted_content"],
|
|
"prompt_cache_key": str(uuid.uuid4()),
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.access_token}",
|
|
"Content-Type": "application/json",
|
|
"OpenAI-Account-ID": self.account_id,
|
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
"Origin": "https://chatgpt.com",
|
|
}
|
|
|
|
url = f"{self.base_url}/codex/responses"
|
|
|
|
# Debug logging for troubleshooting
|
|
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
|
|
|
|
try:
|
|
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
|
|
|
# Log response details on error
|
|
if response.status_code != 200:
|
|
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
|
|
|
|
response.raise_for_status()
|
|
|
|
# Parse SSE for tool calls and content
|
|
content, tool_calls = await self._parse_sse_tool_stream(response)
|
|
|
|
duration = time.time() - start_time
|
|
metrics = get_metrics_collector()
|
|
metrics.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
duration=duration,
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
success=True,
|
|
)
|
|
|
|
# Record OpenTelemetry span
|
|
from hindsight_api.tracing import get_span_recorder
|
|
|
|
span_recorder = get_span_recorder()
|
|
# Convert LLMToolCall objects to dicts for span recording
|
|
tool_calls_dict = (
|
|
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
|
|
)
|
|
span_recorder.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content=content,
|
|
input_tokens=0, # Codex doesn't provide token counts
|
|
output_tokens=0,
|
|
duration=duration,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
error=None,
|
|
tool_calls=tool_calls_dict,
|
|
)
|
|
|
|
return LLMToolCallResult(
|
|
content=content,
|
|
tool_calls=tool_calls,
|
|
finish_reason="tool_calls" if tool_calls else "stop",
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Codex tool call error: {e}")
|
|
raise
|
|
|
|
async def _parse_sse_tool_stream(self, response: httpx.Response) -> tuple[str | None, list[LLMToolCall]]:
|
|
"""
|
|
Parse SSE stream for tool calls and content.
|
|
|
|
Returns:
|
|
Tuple of (content, tool_calls).
|
|
"""
|
|
content = ""
|
|
tool_calls: list[LLMToolCall] = []
|
|
event_type = None
|
|
|
|
async for line in response.aiter_lines():
|
|
if not line:
|
|
continue
|
|
|
|
if line.startswith("event: "):
|
|
event_type = line[7:]
|
|
|
|
elif line.startswith("data: "):
|
|
data_str = line[6:]
|
|
if data_str == "[DONE]":
|
|
break
|
|
|
|
try:
|
|
data = json.loads(data_str)
|
|
|
|
# Extract text content
|
|
if event_type == "response.text.delta" and "delta" in data:
|
|
content += data["delta"]
|
|
|
|
# Extract completed tool calls from response.output_item.done
|
|
elif event_type == "response.output_item.done":
|
|
item = data.get("item", {})
|
|
if item.get("type") == "function_call" and item.get("status") == "completed":
|
|
tool_name = item.get("name", "")
|
|
arguments_str = item.get("arguments", "{}")
|
|
call_id = item.get("call_id", "")
|
|
|
|
try:
|
|
arguments = json.loads(arguments_str)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
|
arguments = {}
|
|
|
|
tool_calls.append(
|
|
LLMToolCall(
|
|
id=call_id,
|
|
name=tool_name,
|
|
arguments=arguments,
|
|
)
|
|
)
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(f"Failed to parse SSE data: {e}, data_str: {data_str[:200]}")
|
|
|
|
return content if content else None, tool_calls
|
|
|
|
async def cleanup(self) -> None:
|
|
"""Clean up HTTP client."""
|
|
await self._client.aclose()
|