first commit
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user