first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,21 @@
"""
Web Interface module for CADDesigner.
Provides a Web service interface compatible with the OpenAI API specification.
"""
from .server import app
from .models import *
__all__ = [
"app",
"ChatCompletionRequest",
"ChatCompletionResponse",
"ChatMessage",
"ChatChoice",
"Usage",
"DeltaMessage",
"ChatCompletionChunk",
"ModelInfo",
"ModelListResponse",
"ErrorResponse",
]
@@ -0,0 +1,211 @@
from __future__ import annotations
from pathlib import Path
import mimetypes
import re
from typing import Any, Dict, Iterable, Optional
PROJECT_ROOT = Path(__file__).resolve().parents[1]
CODE_FILE_PATTERN = re.compile(r"<\|code_file\|>([^<]*?)</\|code_file(?:\|)?>")
OUTPUT_FILE_PATTERN = re.compile(r"<\|output_file\|>([^<]*?)</\|output_file(?:\|)?>")
WORKSPACE_DIRNAME = "workspace"
MODEL_CONTENT_TYPES = {
".stl": "model/stl",
".obj": "text/plain; charset=utf-8",
".ply": "application/octet-stream",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".step": "model/step",
".stp": "model/step",
}
def content_to_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
fragments: list[str] = []
for item in content:
if isinstance(item, dict):
item_type = item.get("type")
if item_type == "text":
fragments.append(str(item.get("text", "")))
elif item_type == "image_url":
fragments.append("[Image]")
else:
fragments.append(str(item))
else:
fragments.append(str(item))
return "\n".join(fragment for fragment in fragments if fragment).strip()
return str(content)
def resolve_project_path(
path_text: str, project_root: Path = PROJECT_ROOT
) -> Optional[Path]:
candidate_text = path_text.strip()
if not candidate_text:
return None
candidate = Path(candidate_text).expanduser()
if not candidate.is_absolute():
candidate = project_root / candidate
try:
resolved_project_root = project_root.resolve(strict=False)
resolved = candidate.resolve(strict=False)
resolved.relative_to(resolved_project_root)
except (OSError, ValueError):
return None
return resolved
def resolve_artifact_path(
path_text: str,
project_root: Path = PROJECT_ROOT,
reference_path: Optional[Path] = None,
) -> Optional[Path]:
candidate_text = path_text.strip()
if not candidate_text:
return None
candidate_paths: list[Path] = []
direct_path = resolve_project_path(candidate_text, project_root=project_root)
if direct_path is not None:
candidate_paths.append(direct_path)
raw_candidate = Path(candidate_text).expanduser()
if raw_candidate.is_absolute():
return (
direct_path if direct_path is not None and direct_path.is_file() else None
)
normalized_relative = candidate_text.replace("\\", "/")
while normalized_relative.startswith("./"):
normalized_relative = normalized_relative[2:]
relative_fragments = [candidate_text]
if normalized_relative and normalized_relative != candidate_text:
relative_fragments.append(normalized_relative)
search_roots: list[Path] = []
if reference_path is not None:
search_roots.append(reference_path.parent)
workspace_root = project_root / WORKSPACE_DIRNAME
if workspace_root.exists():
search_roots.append(workspace_root)
try:
seen: set[Path] = set(candidate_paths)
for root in search_roots:
for fragment in relative_fragments:
resolved = resolve_project_path(
str(root / fragment), project_root=project_root
)
if resolved is not None and resolved not in seen:
candidate_paths.append(resolved)
seen.add(resolved)
except Exception:
return None
for candidate_path in candidate_paths:
if candidate_path.is_file():
return candidate_path
return None
def remember_recent_path(paths: list[Path], next_path: Path) -> None:
try:
paths.remove(next_path)
except ValueError:
pass
paths.append(next_path)
def paths_by_recency(paths: list[Path]) -> list[Path]:
return list(reversed(paths))
def guess_content_type(path: Path) -> str:
suffix = path.suffix.lower()
explicit = MODEL_CONTENT_TYPES.get(suffix)
if explicit:
return explicit
guessed, _ = mimetypes.guess_type(path.name)
return guessed or "application/octet-stream"
def latest_stl_for_code_file(code_path: Path) -> Optional[Path]:
if code_path.name != "model.py" or not code_path.exists():
return None
stl_files = [path for path in code_path.parent.glob("*.stl") if path.is_file()]
if not stl_files:
return None
return max(stl_files, key=lambda item: item.stat().st_mtime)
def extract_latest_artifacts(
messages: Iterable[Dict[str, Any]], project_root: Path = PROJECT_ROOT
) -> Dict[str, Any]:
latest_code_path: Optional[Path] = None
latest_model_path: Optional[Path] = None
code_paths: list[Path] = []
model_paths: list[Path] = []
output_file_paths: list[Path] = []
for message in messages:
if message.get("role") != "assistant":
continue
text = content_to_text(message.get("content"))
for match in CODE_FILE_PATTERN.findall(text):
resolved = resolve_artifact_path(match, project_root=project_root)
if resolved is not None:
latest_code_path = resolved
remember_recent_path(code_paths, resolved)
for match in OUTPUT_FILE_PATTERN.findall(text):
resolved = resolve_artifact_path(
match,
project_root=project_root,
reference_path=latest_code_path,
)
if resolved is not None:
remember_recent_path(output_file_paths, resolved)
latest_model_path = resolved
remember_recent_path(model_paths, resolved)
if latest_code_path is not None and latest_model_path is None:
latest_model_path = latest_stl_for_code_file(latest_code_path)
if latest_model_path is not None:
remember_recent_path(model_paths, latest_model_path)
return {
"code_path": latest_code_path,
"code_paths": paths_by_recency(code_paths),
"model_path": latest_model_path,
"model_paths": paths_by_recency(model_paths),
"output_paths": paths_by_recency(output_file_paths),
}
__all__ = [
"PROJECT_ROOT",
"content_to_text",
"extract_latest_artifacts",
"guess_content_type",
"resolve_artifact_path",
"resolve_project_path",
]
@@ -0,0 +1,38 @@
"""
Error handling module.
"""
from fastapi import Request
from fastapi.responses import JSONResponse
from .models import ErrorResponse, ErrorDetail
def create_error_response(
message: str,
error_type: str = "invalid_request",
param: str = None,
code: str = None,
status_code: int = 400,
) -> JSONResponse:
"""Create a standard error response."""
error_detail = ErrorDetail(message=message, type=error_type, param=param, code=code)
error_response = ErrorResponse(error=error_detail)
return JSONResponse(status_code=status_code, content=error_response.model_dump())
async def not_found_handler(request: Request, exc):
"""404 error handler."""
return create_error_response(
message=f"Not found: {request.url.path}",
error_type="not_found",
status_code=404,
)
async def internal_error_handler(request: Request, exc):
"""500 error handler."""
return create_error_response(
message="Internal server error",
error_type="server_error",
status_code=500
)
@@ -0,0 +1,190 @@
"""
OpenAI API compatible data models
Data model definitions compatible with the OpenAI API specification.
"""
from typing import List, Optional, Dict, Any, Union, Literal
from pydantic import BaseModel, Field
from datetime import datetime
class ChatMessageContentText(BaseModel):
"""Text content model."""
type: Literal["text"] = Field("text", description="Content type")
text: str = Field(..., description="Text content")
class ChatMessageContentImageUrl(BaseModel):
"""Image URL model."""
url: str = Field(..., description="Image URL or base64 data")
local_path: Optional[str] = Field(None, description="Image path already saved within the workspace")
class ChatMessageContentImage(BaseModel):
"""Image content model."""
type: Literal["image_url"] = Field("image_url", description="Content type")
image_url: ChatMessageContentImageUrl = Field(..., description="Image URL")
# Multimodal content type.
ChatMessageContent = Union[
str, List[Union[ChatMessageContentText, ChatMessageContentImage]]
]
class ChatMessage(BaseModel):
"""Conversation message model."""
role: Literal["system", "user", "assistant", "tool"] = Field(
..., description="Message role"
)
content: Optional[ChatMessageContent] = Field(None, description="Message content")
name: Optional[str] = Field(None, description="Sender name")
tool_calls: Optional[List[Dict[str, Any]]] = Field(None, description="Tool calls")
tool_call_id: Optional[str] = Field(None, description="Tool call ID")
class ChatCompletionRequest(BaseModel):
"""Chat completion request model."""
model: str = Field(..., description="Model name")
messages: List[ChatMessage] = Field(..., description="Conversation message list")
temperature: Optional[float] = Field(1.0, ge=0, le=2, description="Generation temperature")
top_p: Optional[float] = Field(1.0, ge=0, le=1, description="Nucleus sampling parameter")
n: Optional[int] = Field(1, ge=1, le=128, description="Number of generations")
stream: Optional[bool] = Field(False, description="Whether to stream output")
stop: Optional[Union[str, List[str]]] = Field(None, description="Stop words")
max_tokens: Optional[int] = Field(None, ge=1, description="Maximum token count")
presence_penalty: Optional[float] = Field(0, ge=-2, le=2, description="Presence penalty")
frequency_penalty: Optional[float] = Field(0, ge=-2, le=2, description="Frequency penalty")
logit_bias: Optional[Dict[str, float]] = Field(None, description="Logit bias")
user: Optional[str] = Field(None, description="User identifier")
tools: Optional[List[Dict[str, Any]]] = Field(None, description="Available tools")
tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(
None, description="Tool selection strategy"
)
class Usage(BaseModel):
"""Token usage statistics."""
prompt_tokens: int = Field(..., description="Prompt token count")
completion_tokens: int = Field(..., description="Completion token count")
total_tokens: int = Field(..., description="Total token count")
class ChatChoice(BaseModel):
"""Chat choice result."""
index: int = Field(..., description="Choice index")
message: ChatMessage = Field(..., description="Generated message")
finish_reason: Optional[
Literal["stop", "length", "tool_calls", "content_filter"]
] = Field(None, description="Finish reason")
class ChatCompletionResponse(BaseModel):
"""Chat completion response model."""
id: str = Field(..., description="Request ID")
object: Literal["chat.completion"] = Field(
"chat.completion", description="Object type"
)
created: int = Field(..., description="Creation timestamp")
model: str = Field(..., description="Model name")
choices: List[ChatChoice] = Field(..., description="Generated choice list")
usage: Usage = Field(..., description="Token usage statistics")
system_fingerprint: Optional[str] = Field(None, description="System fingerprint")
class DeltaMessage(BaseModel):
"""Incremental message for streaming output."""
role: Optional[Literal["system", "user", "assistant", "tool"]] = Field(
None, description="Message role"
)
content: Optional[str] = Field(None, description="Message content")
tool_calls: Optional[List[Dict[str, Any]]] = Field(None, description="Tool calls")
class ChatCompletionChunkChoice(BaseModel):
"""Choice chunk for streaming output."""
index: int = Field(..., description="Choice index")
delta: DeltaMessage = Field(..., description="Incremental message")
finish_reason: Optional[
Literal["stop", "length", "tool_calls", "content_filter"]
] = Field(None, description="Finish reason")
class ChatCompletionChunk(BaseModel):
"""Response chunk for streaming output."""
id: str = Field(..., description="Request ID")
object: Literal["chat.completion.chunk"] = Field(
"chat.completion.chunk", description="Object type"
)
created: int = Field(..., description="Creation timestamp")
model: str = Field(..., description="Model name")
choices: List[ChatCompletionChunkChoice] = Field(..., description="Choice chunk list")
usage: Optional[Usage] = Field(None, description="Token usage statistics")
system_fingerprint: Optional[str] = Field(None, description="System fingerprint")
class ModelInfo(BaseModel):
"""Model information."""
id: str = Field(..., description="Model ID")
object: Literal["model"] = Field("model", description="Object type")
created: int = Field(..., description="Creation timestamp")
owned_by: str = Field(..., description="Owner")
permission: Optional[List[Dict[str, Any]]] = Field(None, description="Permission information")
root: Optional[str] = Field(None, description="Root model")
parent: Optional[str] = Field(None, description="Parent model")
class ModelListResponse(BaseModel):
"""Model list response."""
object: Literal["list"] = Field("list", description="Object type")
data: List[ModelInfo] = Field(..., description="Model list")
class ErrorDetail(BaseModel):
"""Error details."""
message: str = Field(..., description="Error message")
type: str = Field(..., description="Error type")
param: Optional[str] = Field(None, description="Error parameter")
code: Optional[str] = Field(None, description="Error code")
class ErrorResponse(BaseModel):
"""Error response."""
error: ErrorDetail = Field(..., description="Error details")
# Health check response.
class HealthResponse(BaseModel):
"""Health check response."""
status: Literal["ok"] = Field("ok", description="Service status")
timestamp: str = Field(..., description="Timestamp")
version: str = Field(..., description="Version information")
agent_name: str = Field(..., description="Agent name")
# Server information response.
class ServerInfoResponse(BaseModel):
"""Server information response."""
name: str = Field(..., description="Service name")
version: str = Field(..., description="Version information")
description: str = Field(..., description="Service description")
api_version: str = Field(..., description="API version")
supported_models: List[str] = Field(..., description="Supported model list")
capabilities: List[str] = Field(..., description="Service capability list")
@@ -0,0 +1,16 @@
"""
Web Interface Routers Package
Contains all API router modules.
"""
from .conversation_router import router as conversation_router
from .agent_router import router as agent_router
from .chat_router import router as chat_router
from .health_router import router as health_router
__all__ = [
"conversation_router",
"agent_router",
"chat_router",
"health_router"
]
@@ -0,0 +1,65 @@
"""
Agent-related router module.
"""
import time
from typing import Dict, Any
from fastapi import APIRouter, Depends
from ..models import ModelListResponse, ModelInfo
from ..state import get_server_state, ServerState
from agent import list_available_models
router = APIRouter(prefix="/v1", tags=["agents"])
@router.get(
"/agents",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="List all active Agent instances for debugging and monitoring",
)
async def list_agents(state: ServerState = Depends(get_server_state)):
"""List all active Agent instances for debugging and monitoring."""
if not state.agent_registry:
return {"error": "Agent registry not initialized"}
agents_info = {}
for model_name in state.agent_registry.list_agents():
agent_info = state.agent_registry.get_agent_info(model_name)
if agent_info:
# Add instance ID for singleton verification.
agent = state.agent_registry.get_agent(model_name)
agent_info["instance_id"] = id(agent) if agent else None
agents_info[model_name] = agent_info
return {
"registry_stats": state.agent_registry.get_agent_stats(),
"agents": agents_info,
}
@router.get(
"/models",
response_model=ModelListResponse,
dependencies=[Depends(get_server_state)],
description="List all available models for model selection",
)
async def list_models():
"""List available models."""
available_models = list_available_models()
models = []
for model_name in available_models:
model_info = ModelInfo(
id=model_name,
object="model",
created=int(time.time()),
owned_by="simpleagent",
permission=None,
root=None,
parent=None,
)
models.append(model_info)
return ModelListResponse(object="list", data=models)
@@ -0,0 +1,327 @@
"""
Chat router module.
"""
import time
import json
from typing import AsyncGenerator, Any
from SimpleLLMFunc import push_error
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from ..models import (
ChatCompletionRequest,
)
from context.conversation_manager import Conversation
from ..state import get_server_state, ServerState
from ..utils import (
validate_chat_request,
get_or_create_conversation,
get_agent_for_model,
process_agent_response,
create_chat_response,
persist_request_images,
)
from ..error_handlers import create_error_response
from SimpleLLMFunc.logger import (
app_log,
push_warning,
log_context,
get_location,
)
from agent import BaseAgent
from observability import propagate_conversation_session
from react_stream import (
event_name_for_output,
format_sse,
is_response_yield,
project_response_to_oai_chunk,
serialize_react_output,
)
router = APIRouter(prefix="/v1/chat", tags=["chat"])
async def _persist_conversation(conversation: Conversation) -> None:
try:
await conversation.context.persist()
conversation.sketch_pad.persist()
app_log(
f"✅ Auto-saved conversation {conversation.uuid} after stream completion"
)
except Exception as save_error:
push_warning(
f"⚠️ Warning: Failed to save conversation {conversation.uuid}: {save_error}"
)
async def stream_chat_completion(
request: ChatCompletionRequest,
request_id: str,
conversation: Conversation,
agent: BaseAgent,
) -> AsyncGenerator[str, None]:
"""OpenAI-compatible streaming projection built from ReactOutput."""
query, _, raw_user_content = validate_chat_request(request)
app_log(
f"🔍 Starting stream chat completion for conversation {conversation.uuid}, the query is {query}, agent is {agent.name}"
)
created_time = int(time.time())
try:
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": request.model,
"agent_name": agent.name,
"request_id": request_id,
"transport": "oai_stream",
},
tags=["cadagent", "oai_stream"],
):
with conversation:
sent_role = False
async for output in agent.run(query, raw_user_content=raw_user_content):
if not is_response_yield(output):
continue
chunk_obj = project_response_to_oai_chunk(
output,
request_id=request_id,
model=request.model,
created_time=created_time,
sent_role=sent_role,
)
if chunk_obj is None:
continue
try:
json_str = json.dumps(chunk_obj, ensure_ascii=False)
except Exception as encode_err:
push_warning(f"Failed to encode projected chunk: {encode_err}")
json_str = json.dumps(
{"error": str(encode_err)}, ensure_ascii=False
)
first_delta = chunk_obj.get("choices", [{}])[0].get("delta", {})
if (
isinstance(first_delta, dict)
and first_delta.get("role") == "assistant"
):
sent_role = True
app_log(f"🔍 Forwarding projected chunk: {json_str}")
yield f"data: {json_str}\n\n"
await _persist_conversation(conversation)
except Exception as e:
err_obj: dict[str, Any] = {
"id": request_id,
"object": "chat.completion.chunk",
"created": created_time,
"model": request.model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": f"Error: {str(e)}"},
"finish_reason": "stop",
}
],
}
err_json = json.dumps(err_obj, ensure_ascii=False)
push_error(f"🔍 Sending error chunk: {err_json}", location=get_location())
yield f"data: {err_json}\n\n"
yield "data: [DONE]\n\n"
return
# End signal.
yield "data: [DONE]\n\n"
async def stream_chat_events(
request: ChatCompletionRequest,
conversation: Conversation,
agent: BaseAgent,
) -> AsyncGenerator[str, None]:
"""Native SSE stream that exposes our React event protocol."""
query, request_id, raw_user_content = validate_chat_request(request)
try:
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": request.model,
"agent_name": agent.name,
"request_id": request_id,
"transport": "event_stream",
},
tags=["cadagent", "event_stream"],
):
with conversation:
async for output in agent.run(query, raw_user_content=raw_user_content):
payload = serialize_react_output(output, delta_consumer="web")
yield format_sse(event_name_for_output(output), payload)
await _persist_conversation(conversation)
except Exception as e:
error_payload = {
"type": "error",
"message": str(e),
}
yield format_sse("error", error_payload)
yield format_sse("done", {"ok": False})
return
yield format_sse("done", {"ok": True})
@router.post(
"/completions",
dependencies=[Depends(get_server_state)],
description="Chat completion endpoint compatible with the OpenAI specification",
)
async def chat_completions(
request: ChatCompletionRequest,
http_request: Request,
state: ServerState = Depends(get_server_state),
):
"""Chat completion endpoint compatible with the OpenAI specification."""
if not state.agent_registry:
return create_error_response(
message="Agent registry not initialized",
error_type="server_error",
status_code=500,
)
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
# Get conversation ID from the custom header.
conversation_id = http_request.headers.get("X-Conversation-ID")
with log_context(conversation_id=conversation_id):
try:
# Get or create the conversation.
conversation, conversation_id = get_or_create_conversation(
conversation_id, state.conversation_manager
)
persist_request_images(request, conversation_id)
# Get Agent.
agent = get_agent_for_model(request.model, state.agent_registry)
# Validate the request and retrieve the necessary information.
query, request_id, raw_user_content = validate_chat_request(request)
app_log(
f"🔍 {request_id} request chat completion for conversation {conversation_id}"
)
# Streaming response.
if request.stream:
return StreamingResponse(
stream_chat_completion(request, request_id, conversation, agent),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Conversation-ID": conversation_id,
},
)
# Non-streaming response: return text plus token statistics.
(
full_response,
prompt_tokens,
completion_tokens,
) = await process_agent_response(
query,
conversation,
agent,
raw_user_content=raw_user_content,
)
response = create_chat_response(
request_id,
request.model,
full_response,
prompt_tokens,
completion_tokens,
)
return JSONResponse(
content=response.model_dump(),
headers={"X-Conversation-ID": conversation_id},
)
except HTTPException:
# Re-raise HTTPException so FastAPI can handle it.
raise
except Exception as e:
return create_error_response(
message=f"Internal server error: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.post(
"/events",
dependencies=[Depends(get_server_state)],
description="Chat event stream endpoint using native ReactEvent Stream SSE",
)
async def chat_events(
request: ChatCompletionRequest,
http_request: Request,
state: ServerState = Depends(get_server_state),
):
if not state.agent_registry:
return create_error_response(
message="Agent registry not initialized",
error_type="server_error",
status_code=500,
)
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
conversation_id = http_request.headers.get("X-Conversation-ID")
with log_context(conversation_id=conversation_id):
try:
conversation, conversation_id = get_or_create_conversation(
conversation_id, state.conversation_manager
)
persist_request_images(request, conversation_id)
agent = get_agent_for_model(request.model, state.agent_registry)
validate_chat_request(request)
return StreamingResponse(
stream_chat_events(request, conversation, agent),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Conversation-ID": conversation_id,
},
)
except HTTPException:
raise
except Exception as e:
return create_error_response(
message=f"Internal server error: {str(e)}",
error_type="server_error",
status_code=500,
)
@@ -0,0 +1,484 @@
"""
Conversation-related router module.
"""
from pathlib import Path
from typing import Dict, Any, Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
# Removed unused imports.
from ..state import get_server_state, ServerState
from ..error_handlers import create_error_response
from ..artifacts import (
extract_latest_artifacts,
guess_content_type,
resolve_project_path,
)
from config.config import get_config
router = APIRouter(prefix="/v1/conversations", tags=["conversations"])
def _build_artifact_url(conversation_id: str, path: Path) -> str:
encoded_path = quote(str(path), safe="")
return f"/v1/conversations/{conversation_id}/artifacts/raw?path={encoded_path}"
def _read_text_artifact(path: Path) -> Optional[str]:
try:
return path.read_text(encoding="utf-8")
except Exception:
return None
def _serialize_artifact_file(
conversation_id: str,
path: Path,
include_content: bool = False,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"path": str(path),
"content_type": guess_content_type(path),
"url": _build_artifact_url(conversation_id, path),
}
if include_content:
payload["content"] = _read_text_artifact(path)
return payload
@router.get(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="List all available conversations",
)
async def list_conversations(state: ServerState = Depends(get_server_state)):
"""List all available conversations."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversations = state.conversation_manager.list_conversations()
return {"conversations": conversations, "total_count": len(conversations)}
except Exception as e:
return create_error_response(
message=f"Failed to list conversations: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.post(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Create a new conversation",
)
async def create_conversation(
state: ServerState = Depends(get_server_state),
):
"""Create a new conversation.
Args:
llm_interface: LLM interface name; if None, use the default interface
max_history_length: Maximum Context history length
"""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
llm_obj = get_config().CONTEXT_SUMMARY_INTERFACE
max_history_length = get_config().CONTEXT_MAX_HISTORY_LENGTH
conversation = state.conversation_manager.create_conversation(
llm_interface=llm_obj, max_history_length=max_history_length
)
return {
"conversation_id": conversation.uuid,
"created_at": conversation.created_at.isoformat(),
"last_accessed": conversation.last_accessed.isoformat(),
}
except Exception as e:
return create_error_response(
message=f"Failed to create conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get information for the specified conversation",
)
async def get_conversation(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Get information for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get conversation statistics.
with conversation:
try:
history_count = conversation.context.get_total_message_count()
sketch_stats = conversation.sketch_pad.get_statistics()
# Convert the SketchPadStatistics object to a dictionary.
sketch_stats_dict = sketch_stats.model_dump()
except Exception as e:
history_count = 0
sketch_stats_dict = {}
return {
"conversation_id": conversation.uuid,
"created_at": conversation.created_at.isoformat(),
"last_accessed": conversation.last_accessed.isoformat(),
"message_count": history_count,
"sketch_stats": sketch_stats_dict,
}
except Exception as e:
return create_error_response(
message=f"Failed to get conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.delete(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Delete all conversations",
)
async def delete_all_conversations(state: ServerState = Depends(get_server_state)):
"""Delete all conversations."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
deleted_ids = state.conversation_manager.delete_all_conversations()
return {
"deleted": True,
"deleted_count": len(deleted_ids),
"conversation_ids": deleted_ids,
}
except Exception as e:
return create_error_response(
message=f"Failed to delete all conversations: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.delete(
"/{conversation_id}",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Delete the specified conversation",
)
async def delete_conversation(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Delete the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
success = state.conversation_manager.delete_conversation(conversation_id)
if not success:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
return {"deleted": True, "conversation_id": conversation_id}
except Exception as e:
return create_error_response(
message=f"Failed to delete conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/history",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the conversation history for the specified conversation",
)
async def get_conversation_history(
conversation_id: str,
limit: Optional[int] = None,
state: ServerState = Depends(get_server_state),
):
"""Get the conversation history for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get conversation history.
with conversation:
try:
# Get the complete conversation history.
history = [
message_item.model_dump()
for message_item in conversation.context.retrieve_full_messages()
]
total_messages = len(history)
# If a limit is specified, return only the most recent messages.
if limit and limit > 0:
history = history[-limit:]
return {
"conversation_id": conversation_id,
"messages": history,
"total_messages": total_messages,
"has_more": total_messages > len(history) if limit else False,
}
except Exception as e:
return create_error_response(
message=f"Failed to access conversation history: {str(e)}",
error_type="server_error",
status_code=500,
)
except Exception as e:
return create_error_response(
message=f"Failed to get conversation history: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/sketchpad",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the SketchPad content for the specified conversation",
)
async def get_conversation_sketchpad(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Get the SketchPad content for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get SketchPad content.
with conversation:
try:
# Get sketch items that include concrete content.
sketch_items = conversation.sketch_pad.list_items(include_value=True)
sketch_stats = conversation.sketch_pad.get_statistics()
return {
"conversation_id": conversation_id,
"sketch_items": sketch_items,
"statistics": sketch_stats,
"total_items": len(sketch_items),
}
except Exception as e:
return create_error_response(
message=f"Failed to access SketchPad: {str(e)}",
error_type="server_error",
status_code=500,
)
except Exception as e:
return create_error_response(
message=f"Failed to get SketchPad: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/artifacts/latest",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the latest generated code and model preview information for the specified conversation",
)
async def get_latest_conversation_artifacts(
conversation_id: str,
state: ServerState = Depends(get_server_state),
):
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
with conversation:
history = [
message_item.model_dump()
for message_item in conversation.context.retrieve_full_messages()
]
artifacts = extract_latest_artifacts(history)
code_path = artifacts.get("code_path")
code_paths = artifacts.get("code_paths", [])
model_path = artifacts.get("model_path")
model_paths = artifacts.get("model_paths", [])
output_paths = artifacts.get("output_paths", [])
response: Dict[str, Any] = {
"conversation_id": conversation_id,
"code_file": None,
"code_files": [],
"model_file": None,
"model_files": [],
"output_files": [str(path) for path in output_paths],
}
if isinstance(code_path, Path) and code_path.is_file():
response["code_file"] = _serialize_artifact_file(
conversation_id,
code_path,
include_content=True,
)
response["code_files"] = [
_serialize_artifact_file(
conversation_id,
candidate_path,
include_content=True,
)
for candidate_path in code_paths
if isinstance(candidate_path, Path) and candidate_path.is_file()
]
if isinstance(model_path, Path) and model_path.is_file():
response["model_file"] = _serialize_artifact_file(
conversation_id,
model_path,
)
response["model_files"] = [
_serialize_artifact_file(conversation_id, candidate_path)
for candidate_path in model_paths
if isinstance(candidate_path, Path) and candidate_path.is_file()
]
return response
except Exception as e:
return create_error_response(
message=f"Failed to get latest artifacts: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/artifacts/raw",
dependencies=[Depends(get_server_state)],
description="Get the raw content of an artifact file for the specified conversation",
)
async def get_conversation_artifact_file(
conversation_id: str,
path: str,
state: ServerState = Depends(get_server_state),
):
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
resolved_path = resolve_project_path(path)
if resolved_path is None or not resolved_path.is_file():
return create_error_response(
message="Artifact file not found or outside project root",
error_type="not_found",
status_code=404,
)
return FileResponse(
path=str(resolved_path),
media_type=guess_content_type(resolved_path),
filename=resolved_path.name,
)
except Exception as e:
return create_error_response(
message=f"Failed to get artifact file: {str(e)}",
error_type="server_error",
status_code=500,
)
@@ -0,0 +1,46 @@
"""
Health check router module.
"""
from datetime import datetime
from fastapi import APIRouter, Depends
from typing import Dict, Any
from ..models import HealthResponse, ServerInfoResponse
from ..state import get_server_state, ServerState
router = APIRouter()
@router.get("/", response_model=ServerInfoResponse)
async def root():
"""Server root path information."""
return ServerInfoResponse(
name="CADDesigner API Server",
version="1.0.0",
description="OpenAI-compatible API for CADDesigner",
api_version="v1",
supported_models=["cadagent"],
capabilities=[
"chat.completions",
"streaming",
"tool_calling",
"conversation_history",
"sketch_pad_storage",
],
)
@router.get("/health", response_model=HealthResponse)
async def health_check(state: ServerState = Depends(get_server_state)):
"""Health check endpoint."""
default_agent = (
state.agent_registry.get_agent("cadagent") if state.agent_registry else None
)
return HealthResponse(
status="ok",
timestamp=datetime.now().isoformat(),
version="1.0.0",
agent_name=default_agent.name if default_agent else "Not initialized",
)
@@ -0,0 +1,80 @@
"""
FastAPI Web Server for CADDesigner.
Web server implementation compatible with the OpenAI API specification.
"""
from contextlib import asynccontextmanager
import uvicorn
from bootstrap_env import load_project_env
load_project_env()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .state import server_state
from .routers import (
health_router,
agent_router,
conversation_router,
chat_router,
)
from .error_handlers import not_found_handler, internal_error_handler
from SimpleLLMFunc.logger import push_error, app_log
from SimpleLLMFunc.observability.langfuse_client import flush_all_observations
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle management."""
app_log("🚀 Initializing CADDesigner Web Server...")
try:
server_state.initialize()
app_log("✅ CADDesigner initialized successfully!")
except Exception as e:
push_error(f"❌ Failed to initialize CADDesigner: {e}")
raise
yield
app_log("🔄 Shutting down CADDesigner Web Server...")
try:
flush_all_observations()
app_log("✅ Flushed Langfuse observations")
except Exception as e:
push_error(f"⚠️ Failed to flush Langfuse observations: {e}")
# Create the FastAPI application.
app = FastAPI(
title="CADDesigner API",
description="OpenAI-compatible API for CADDesigner",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
# Add CORS middleware.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Restrict specific domains in production.
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers.
app.include_router(health_router)
app.include_router(agent_router)
app.include_router(conversation_router)
app.include_router(chat_router)
# Register error handlers.
app.add_exception_handler(404, not_found_handler)
app.add_exception_handler(500, internal_error_handler)
if __name__ == "__main__":
uvicorn.run("web_interface.server:app", host="0.0.0.0", port=8000, reload=True)
@@ -0,0 +1,37 @@
"""
Server state management module.
"""
from typing import Optional, Any
from context.conversation_manager import ConversationManager, get_conversation_manager
from agent import get_agent_registry
class ServerState:
"""Server state management class, replacing global variables."""
def __init__(self):
self.agent_registry: Optional[Any] = None
self.conversation_manager: Optional[ConversationManager] = None
def initialize(self) -> None:
"""Initialize server state."""
self.agent_registry = get_agent_registry()
self.conversation_manager = get_conversation_manager()
# Create the default Agent instance.
self.agent_registry.get_or_create_agent(
"cadagent",
name="CADAgent Web Service",
description="Professional CAD modeling assistant with web API",
max_history_length=4, # DEFAULT_MAX_HISTORY_LENGTH
)
# Global server state.
server_state = ServerState()
def get_server_state() -> ServerState:
"""Get the server state dependency."""
return server_state
@@ -0,0 +1,467 @@
"""
Utility function module.
"""
import time
import uuid
import base64
from pathlib import Path
import re
from typing import Tuple, Optional, Any, Union, Dict
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from SimpleLLMFunc.type import ImgPath, ImgUrl, Text
from .models import (
ChatCompletionRequest,
Usage,
ChatCompletionResponse,
ChatMessage,
ChatChoice,
)
from context.conversation_manager import ConversationManager, Conversation
from SimpleLLMFunc.logger import (
app_log,
push_warning,
push_error,
get_current_context_attribute,
)
from react_stream import extract_output_text, is_response_yield
from observability import propagate_conversation_session
DATA_URL_PATTERN = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.*)$", re.DOTALL)
IMAGE_MIME_EXTENSIONS = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/webp": ".webp",
}
def _content_item_type(item: Any) -> Optional[str]:
if isinstance(item, dict):
value = item.get("type")
return value if isinstance(value, str) else None
value = getattr(item, "type", None)
return value if isinstance(value, str) else None
def _content_item_text(item: Any) -> Optional[str]:
if isinstance(item, dict):
value = item.get("text")
return value if isinstance(value, str) else None
value = getattr(item, "text", None)
return value if isinstance(value, str) else None
def _content_item_image_url_payload(item: Any) -> Any:
if isinstance(item, dict):
return item.get("image_url")
return getattr(item, "image_url", None)
def _image_payload_url(image_payload: Any) -> Optional[str]:
if isinstance(image_payload, dict):
value = image_payload.get("url")
return value if isinstance(value, str) else None
value = getattr(image_payload, "url", None)
return value if isinstance(value, str) else None
def _image_payload_detail(image_payload: Any) -> str:
if isinstance(image_payload, dict):
value = image_payload.get("detail")
return value if isinstance(value, str) else "auto"
value = getattr(image_payload, "detail", None)
return value if isinstance(value, str) else "auto"
def _image_payload_local_path(image_payload: Any) -> Optional[str]:
if isinstance(image_payload, dict):
value = image_payload.get("local_path")
return value if isinstance(value, str) else None
value = getattr(image_payload, "local_path", None)
return value if isinstance(value, str) else None
def _set_image_payload_local_path(image_payload: Any, local_path: str) -> None:
if isinstance(image_payload, dict):
image_payload["local_path"] = local_path
return
if hasattr(image_payload, "local_path"):
image_payload.local_path = local_path
def get_agent_for_model(model_name: str, agent_registry) -> Any:
"""
Get an Agent instance by model name.
Args:
model_name: Model name
agent_registry: Agent registry instance
Returns:
Agent instance
Raises:
HTTPException: If the model does not exist or creation fails
"""
if not agent_registry:
raise HTTPException(status_code=500, detail="Agent registry not initialized")
# First try to get an existing Agent instance.
agent = agent_registry.get_agent(model_name)
if agent:
return agent
# If it does not exist, try to create a new Agent instance.
try:
agent = agent_registry.get_or_create_agent(
model_name,
name=f"Agent for {model_name}",
description=f"Agent instance for model {model_name}",
)
return agent
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Error: {e} was caught. Maybe due to: [Unknown model: {model_name}]",
)
# Removed create_error_response because it has been moved to error_handlers.py.
def _has_nonempty_user_content(content: Any) -> bool:
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
for item in content:
item_type = _content_item_type(item)
if item_type == "text":
text_value = _content_item_text(item)
if isinstance(text_value, str) and text_value.strip():
return True
if item_type == "image_url":
image_url = _content_item_image_url_payload(item)
if _image_payload_url(image_url):
return True
return False
return False
def persist_request_images(
request: ChatCompletionRequest,
conversation_id: str,
workspace_root: Optional[str | Path] = None,
) -> None:
"""Persist inline image uploads into a deterministic workspace folder.
Images sent as data URLs are written under `./uploads/<conversation_id>/` so tools that
expect a local image path can reference stable files.
"""
root = Path(workspace_root).resolve() if workspace_root else Path.cwd().resolve()
uploads_dir = root / "uploads" / conversation_id
upload_index = 0
for message in request.messages:
if message.role != "user" or not isinstance(message.content, list):
continue
for item in message.content:
item_type = _content_item_type(item)
if item_type != "image_url":
continue
image_url = _content_item_image_url_payload(item)
if image_url is None:
continue
local_path = _image_payload_local_path(image_url)
if isinstance(local_path, str) and local_path.strip():
continue
url = _image_payload_url(image_url)
if not isinstance(url, str):
continue
match = DATA_URL_PATTERN.match(url)
if not match:
continue
mime_type, encoded_data = match.groups()
extension = IMAGE_MIME_EXTENSIONS.get(mime_type, ".jpg")
uploads_dir.mkdir(parents=True, exist_ok=True)
upload_index += 1
image_path = uploads_dir / f"query_image_{upload_index:03d}{extension}"
image_bytes = base64.b64decode(encoded_data)
image_path.write_bytes(image_bytes)
resolved_path = str(image_path)
_set_image_payload_local_path(image_url, resolved_path)
def _convert_chat_content_to_agent_query(content: Any) -> Any:
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: list[Any] = []
image_path_notes: list[str] = []
for item in content:
item_type = _content_item_type(item)
if item_type == "text":
text_value = _content_item_text(item)
if isinstance(text_value, str) and text_value.strip():
parts.append(Text(text_value))
continue
if item_type == "image_url":
image_url = _content_item_image_url_payload(item)
local_path = _image_payload_local_path(image_url)
if isinstance(local_path, str) and local_path.strip():
cleaned_local_path = local_path.strip()
parts.append(ImgPath(cleaned_local_path, detail="high"))
image_path_notes.append(
"Reference image saved at: "
f"{cleaned_local_path}. You can pass this path to tools like "
"`make_user_query_more_detailed(query_image_path=...)` or "
"`get_visual_feedback(query_image_path=...)` when needed."
)
continue
image_url_value = _image_payload_url(image_url)
if isinstance(image_url_value, str):
parts.append(
ImgUrl(image_url_value, detail=_image_payload_detail(image_url))
)
for note in image_path_notes:
parts.append(Text(note))
if not parts:
return ""
if len(parts) == 1 and isinstance(parts[0], Text):
return str(parts[0])
return parts
def validate_chat_request(request: ChatCompletionRequest) -> Tuple[Any, str, Any]:
"""
Validate the chat request and extract the user message.
Returns:
(query_for_agent, request_id, raw_user_content)
"""
if not request.messages:
raise HTTPException(
status_code=400, detail="Missing required parameter: messages"
)
# Get the user's last message.
user_messages = [msg for msg in request.messages if msg.role == "user"]
if not user_messages:
raise HTTPException(
status_code=400, detail="No user message found in conversation"
)
last_user_message = user_messages[-1]
if not _has_nonempty_user_content(last_user_message.content):
raise HTTPException(
status_code=400, detail="User message content cannot be empty"
)
query = _convert_chat_content_to_agent_query(last_user_message.content)
request_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
return query, request_id, last_user_message.content
def get_or_create_conversation(
conversation_id: Optional[str], conversation_manager: ConversationManager
) -> Tuple[Conversation, str]:
"""
Get or create a conversation.
If conversation_id is None, create a new conversation.
If conversation_id exists but the corresponding conversation does not exist, create a new conversation.
Otherwise, return the existing conversation.
Args:
conversation_id: Optional conversation ID
conversation_manager: ConversationManager instance
Returns:
(conversation, conversation_id)
"""
if not conversation_id:
conversation = conversation_manager.create_conversation()
conversation_id = conversation.uuid
else:
conversation = conversation_manager.get_conversation(conversation_id) # type: ignore
if conversation is None:
# If the conversation does not exist, create a new one.
conversation = conversation_manager.create_conversation(
conversation_id=conversation_id
)
return conversation, conversation_id
def _extract_tokens_from_chunk(chunk: Any) -> Tuple[Optional[int], Optional[int]]:
"""Extract token statistics from multiple possible chunk structures.
Return (prompt_tokens, completion_tokens). Either value is None if absent.
"""
# 1) Pydantic model: chunk.usage.prompt_tokens
try:
usage = getattr(chunk, "usage", None)
if usage is not None:
pt = getattr(usage, "prompt_tokens", None)
ct = getattr(usage, "completion_tokens", None)
if isinstance(pt, int) or isinstance(ct, int):
return (
int(pt) if isinstance(pt, int) else None,
int(ct) if isinstance(ct, int) else None,
)
except Exception:
pass
# 2) Dictionary: {"usage": {"prompt_tokens": x, "completion_tokens": y}}
try:
if isinstance(chunk, dict):
u = chunk.get("usage")
if isinstance(u, dict):
pt = u.get("prompt_tokens")
ct = u.get("completion_tokens")
pt_v = int(pt) if isinstance(pt, (int, float)) else None
ct_v = int(ct) if isinstance(ct, (int, float)) else None
if pt_v is not None or ct_v is not None:
return (pt_v, ct_v)
except Exception:
pass
# 3) Flat dictionary: {"prompt_tokens": x, "completion_tokens": y}
try:
if isinstance(chunk, dict):
pt = chunk.get("prompt_tokens")
ct = chunk.get("completion_tokens")
pt_v = int(pt) if isinstance(pt, (int, float)) else None
ct_v = int(ct) if isinstance(ct, (int, float)) else None
if pt_v is not None or ct_v is not None:
return (pt_v, ct_v)
except Exception:
pass
return (None, None)
async def process_agent_response(
query: Any,
conversation: Conversation,
agent: Any,
raw_user_content: Any = None,
) -> Tuple[str, Optional[int], Optional[int]]:
"""Process the Agent response and return (full_text, prompt_tokens, completion_tokens)."""
full_response: str = ""
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
try:
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": getattr(agent, "model_name", None),
"agent_name": getattr(agent, "name", None),
"transport": "non_stream",
},
tags=["cadagent", "non_stream"],
):
with conversation:
async for output in agent.run(query, raw_user_content=raw_user_content):
delta = extract_output_text(output, "agent_non_stream")
if delta:
full_response += delta
if (
prompt_tokens is None or completion_tokens is None
) and is_response_yield(output):
pt, ct = _extract_tokens_from_chunk(output.response)
if pt is not None:
prompt_tokens = pt
if ct is not None:
completion_tokens = ct
# Persist the conversation immediately after completion.
try:
await conversation.context.persist()
# Directly call the sketch_pad persist method.
conversation.sketch_pad.persist()
app_log(
f"✅ Auto-saved conversation {conversation.uuid} after agent response"
)
except Exception as save_error:
# Save failure should not affect the response, but it should be logged.
push_warning(
f"⚠️ Warning: Failed to save conversation {conversation.uuid}: {save_error}"
)
return full_response.strip(), prompt_tokens, completion_tokens
except Exception as e:
raise HTTPException(status_code=500, detail=f"Agent processing error: {str(e)}")
def create_chat_response(
request_id: str,
model: str,
full_response: str,
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None,
) -> ChatCompletionResponse:
"""Create a chat response."""
created_time = int(time.time())
response_message = ChatMessage(
role="assistant",
content=full_response,
name=None,
tool_calls=None,
tool_call_id=None,
)
choice = ChatChoice(index=0, message=response_message, finish_reason="stop")
# Prefer upstream token statistics; otherwise fall back to context statistics; finally use 0.
if prompt_tokens is None:
_in = get_current_context_attribute("input_tokens")
try:
prompt_tokens = int(_in) if _in is not None else 0
except Exception:
prompt_tokens = 0
if completion_tokens is None:
_out = get_current_context_attribute("output_tokens")
try:
completion_tokens = int(_out) if _out is not None else 0
except Exception:
completion_tokens = 0
usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=(prompt_tokens or 0) + (completion_tokens or 0),
)
return ChatCompletionResponse(
id=request_id,
object="chat.completion",
created=created_time,
model=model,
choices=[choice],
usage=usage,
system_fingerprint=None,
)