Initial commit: add .gitignore and README
This commit is contained in:
14
fusionagi/api/routes/__init__.py
Normal file
14
fusionagi/api/routes/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""API routes for Dvādaśa sessions and prompts."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fusionagi.api.routes.sessions import router as sessions_router
|
||||
from fusionagi.api.routes.tts import router as tts_router
|
||||
from fusionagi.api.routes.admin import router as admin_router
|
||||
from fusionagi.api.routes.openai_compat import router as openai_compat_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(sessions_router, prefix="/sessions", tags=["sessions"])
|
||||
router.include_router(tts_router, prefix="/sessions", tags=["tts"])
|
||||
router.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||
router.include_router(openai_compat_router)
|
||||
17
fusionagi/api/routes/admin.py
Normal file
17
fusionagi/api/routes/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Admin routes: telemetry, etc."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from fusionagi.api.dependencies import get_telemetry_tracer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/telemetry")
|
||||
def get_telemetry(task_id: str | None = None, limit: int = 100) -> dict:
|
||||
"""Return telemetry traces (admin). Filter by task_id if provided."""
|
||||
tracer = get_telemetry_tracer()
|
||||
if not tracer:
|
||||
return {"traces": []}
|
||||
traces = tracer.get_traces(task_id=task_id, limit=limit)
|
||||
return {"traces": traces}
|
||||
265
fusionagi/api/routes/openai_compat.py
Normal file
265
fusionagi/api/routes/openai_compat.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""OpenAI-compatible API routes for Cursor Composer and other consumers."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from fusionagi.api.dependencies import (
|
||||
ensure_initialized,
|
||||
get_event_bus,
|
||||
get_orchestrator,
|
||||
get_safety_pipeline,
|
||||
get_openai_bridge_config,
|
||||
verify_openai_bridge_auth,
|
||||
)
|
||||
from fusionagi.api.openai_compat.translators import (
|
||||
messages_to_prompt,
|
||||
final_response_to_openai,
|
||||
estimate_usage,
|
||||
)
|
||||
from fusionagi.core import run_dvadasa
|
||||
from fusionagi.schemas.commands import parse_user_input
|
||||
|
||||
router = APIRouter(tags=["openai-compat"])
|
||||
|
||||
# Chunk size for streaming (chars per SSE delta)
|
||||
_STREAM_CHUNK_SIZE = 50
|
||||
|
||||
|
||||
def _openai_error(status_code: int, message: str, error_type: str) -> HTTPException:
|
||||
"""Raise HTTPException with OpenAI-style error body."""
|
||||
return HTTPException(
|
||||
status_code=status_code,
|
||||
detail={"error": {"message": message, "type": error_type}},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_openai_init() -> None:
|
||||
"""Ensure orchestrator and dependencies are initialized."""
|
||||
ensure_initialized()
|
||||
|
||||
|
||||
async def _verify_auth_dep(authorization: str | None = Header(default=None)) -> None:
|
||||
"""Dependency: verify auth for OpenAI bridge routes."""
|
||||
verify_openai_bridge_auth(authorization)
|
||||
|
||||
|
||||
@router.get("/models", dependencies=[Depends(_verify_auth_dep)])
|
||||
async def list_models() -> dict[str, Any]:
|
||||
"""
|
||||
List available models (OpenAI-compatible).
|
||||
Returns fusionagi-dvadasa as the single model.
|
||||
"""
|
||||
cfg = get_openai_bridge_config()
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": cfg.model_id,
|
||||
"object": "model",
|
||||
"created": 1704067200,
|
||||
"owned_by": "fusionagi",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat/completions",
|
||||
dependencies=[Depends(_verify_auth_dep)],
|
||||
response_model=None,
|
||||
)
|
||||
async def create_chat_completion(request: Request):
|
||||
"""
|
||||
Create chat completion (OpenAI-compatible).
|
||||
Supports both sync (stream=false) and streaming (stream=true).
|
||||
"""
|
||||
_ensure_openai_init()
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as e:
|
||||
raise _openai_error(400, f"Invalid JSON body: {e}", "invalid_request_error")
|
||||
|
||||
messages = body.get("messages")
|
||||
if not messages or not isinstance(messages, list):
|
||||
raise _openai_error(
|
||||
400,
|
||||
"messages is required and must be a non-empty array",
|
||||
"invalid_request_error",
|
||||
)
|
||||
|
||||
from fusionagi.api.openai_compat.translators import _extract_content
|
||||
|
||||
has_content = any(_extract_content(m).strip() for m in messages)
|
||||
if not has_content:
|
||||
raise _openai_error(
|
||||
400,
|
||||
"messages must contain at least one user or assistant message with content",
|
||||
"invalid_request_error",
|
||||
)
|
||||
|
||||
prompt = messages_to_prompt(messages)
|
||||
if not prompt.strip():
|
||||
raise _openai_error(
|
||||
400,
|
||||
"messages must contain at least one user or assistant message with content",
|
||||
"invalid_request_error",
|
||||
)
|
||||
|
||||
pipeline = get_safety_pipeline()
|
||||
if pipeline:
|
||||
pre_result = pipeline.pre_check(prompt)
|
||||
if not pre_result.allowed:
|
||||
raise _openai_error(
|
||||
400,
|
||||
pre_result.reason or "Input moderation failed",
|
||||
"invalid_request_error",
|
||||
)
|
||||
|
||||
orch = get_orchestrator()
|
||||
bus = get_event_bus()
|
||||
if not orch:
|
||||
raise _openai_error(503, "Service not initialized", "internal_error")
|
||||
|
||||
cfg = get_openai_bridge_config()
|
||||
request_model = body.get("model") or cfg.model_id
|
||||
stream = body.get("stream", False) is True
|
||||
|
||||
task_id = orch.submit_task(goal=prompt[:200])
|
||||
parsed = parse_user_input(prompt)
|
||||
|
||||
if stream:
|
||||
return StreamingResponse(
|
||||
_stream_chat_completion(
|
||||
orch=orch,
|
||||
bus=bus,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
parsed=parsed,
|
||||
request_model=request_model,
|
||||
messages=messages,
|
||||
pipeline=pipeline,
|
||||
cfg=cfg,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
# Sync path
|
||||
final = run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=prompt,
|
||||
parsed=parsed,
|
||||
event_bus=bus,
|
||||
timeout_per_head=cfg.timeout_per_head,
|
||||
)
|
||||
|
||||
if not final:
|
||||
raise _openai_error(500, "Dvādaśa failed to produce response", "internal_error")
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
raise _openai_error(
|
||||
400,
|
||||
f"Output scan failed: {', '.join(post_result.flags)}",
|
||||
"invalid_request_error",
|
||||
)
|
||||
|
||||
result = final_response_to_openai(
|
||||
final=final,
|
||||
task_id=task_id,
|
||||
request_model=request_model,
|
||||
messages=messages,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _stream_chat_completion(
|
||||
orch: Any,
|
||||
bus: Any,
|
||||
task_id: str,
|
||||
prompt: str,
|
||||
parsed: Any,
|
||||
request_model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
pipeline: Any,
|
||||
cfg: Any,
|
||||
):
|
||||
"""
|
||||
Async generator that runs Dvādaśa and streams the final_answer as SSE chunks.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
def run() -> Any:
|
||||
return run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=prompt,
|
||||
parsed=parsed,
|
||||
event_bus=bus,
|
||||
timeout_per_head=cfg.timeout_per_head,
|
||||
)
|
||||
|
||||
try:
|
||||
final = await loop.run_in_executor(executor, run)
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'error': {'message': str(e), 'type': 'internal_error'}})}\n\n"
|
||||
return
|
||||
|
||||
if not final:
|
||||
yield f"data: {json.dumps({'error': {'message': 'Dvādaśa failed', 'type': 'internal_error'}})}\n\n"
|
||||
return
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
yield f"data: {json.dumps({'error': {'message': 'Output scan failed', 'type': 'invalid_request_error'}})}\n\n"
|
||||
return
|
||||
|
||||
chat_id = f"chatcmpl-{task_id[:24]}" if len(task_id) >= 24 else f"chatcmpl-{task_id}"
|
||||
|
||||
# Stream final_answer in chunks
|
||||
text = final.final_answer
|
||||
for i in range(0, len(text), _STREAM_CHUNK_SIZE):
|
||||
chunk = text[i : i + _STREAM_CHUNK_SIZE]
|
||||
chunk_json = {
|
||||
"id": chat_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": request_model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"content": chunk},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
yield f"data: {json.dumps(chunk_json)}\n\n"
|
||||
|
||||
# Final chunk with finish_reason
|
||||
usage = estimate_usage(messages, text)
|
||||
final_chunk = {
|
||||
"id": chat_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": request_model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": usage,
|
||||
}
|
||||
yield f"data: {json.dumps(final_chunk)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
147
fusionagi/api/routes/sessions.py
Normal file
147
fusionagi/api/routes/sessions.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Session and prompt routes."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from fusionagi.api.dependencies import get_orchestrator, get_session_store, get_event_bus, get_safety_pipeline
|
||||
from fusionagi.api.websocket import handle_stream
|
||||
from fusionagi.core import run_dvadasa, select_heads_for_complexity, extract_sources_from_head_outputs
|
||||
from fusionagi.schemas.commands import parse_user_input, UserIntent
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ensure_init():
|
||||
from fusionagi.api.dependencies import ensure_initialized
|
||||
ensure_initialized()
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_session(user_id: str | None = None) -> dict[str, Any]:
|
||||
"""Create a new session."""
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
if not store:
|
||||
raise HTTPException(status_code=503, detail="Session store not initialized")
|
||||
session_id = str(uuid.uuid4())
|
||||
store.create(session_id, user_id)
|
||||
return {"session_id": session_id, "user_id": user_id}
|
||||
|
||||
|
||||
@router.post("/{session_id}/prompt")
|
||||
def submit_prompt(session_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Submit a prompt and receive FinalResponse (sync)."""
|
||||
_ensure_init()
|
||||
store = get_session_store()
|
||||
orch = get_orchestrator()
|
||||
bus = get_event_bus()
|
||||
if not store or not orch:
|
||||
raise HTTPException(status_code=503, detail="Service not initialized")
|
||||
|
||||
sess = store.get(session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
prompt = body.get("prompt", "")
|
||||
parsed = parse_user_input(prompt)
|
||||
|
||||
if not prompt or not parsed.cleaned_prompt.strip():
|
||||
if parsed.intent in (UserIntent.SHOW_DISSENT, UserIntent.RERUN_RISK, UserIntent.EXPLAIN_REASONING, UserIntent.SOURCES):
|
||||
hist = sess.get("history", [])
|
||||
if hist:
|
||||
prompt = hist[-1].get("prompt", "")
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail="No previous prompt; provide a prompt for this command")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
effective_prompt = parsed.cleaned_prompt.strip() or prompt
|
||||
pipeline = get_safety_pipeline()
|
||||
if pipeline:
|
||||
pre_result = pipeline.pre_check(effective_prompt)
|
||||
if not pre_result.allowed:
|
||||
raise HTTPException(status_code=400, detail=pre_result.reason or "Input moderation failed")
|
||||
|
||||
task_id = orch.submit_task(goal=effective_prompt[:200])
|
||||
|
||||
# Dynamic head selection
|
||||
head_ids = select_heads_for_complexity(effective_prompt)
|
||||
if parsed.intent.value == "head_strategy" and parsed.head_id:
|
||||
head_ids = [parsed.head_id]
|
||||
|
||||
force_second = parsed.intent == UserIntent.RERUN_RISK
|
||||
return_heads = parsed.intent == UserIntent.SOURCES
|
||||
|
||||
result = run_dvadasa(
|
||||
orchestrator=orch,
|
||||
task_id=task_id,
|
||||
user_prompt=effective_prompt,
|
||||
parsed=parsed,
|
||||
head_ids=head_ids if parsed.intent.value != "normal" or body.get("use_all_heads") else None,
|
||||
event_bus=bus,
|
||||
force_second_pass=force_second,
|
||||
return_head_outputs=return_heads,
|
||||
)
|
||||
|
||||
if return_heads and isinstance(result, tuple):
|
||||
final, head_outputs = result
|
||||
else:
|
||||
final = result
|
||||
head_outputs = []
|
||||
|
||||
if not final:
|
||||
raise HTTPException(status_code=500, detail="Failed to produce response")
|
||||
|
||||
if pipeline:
|
||||
post_result = pipeline.post_check(final.final_answer)
|
||||
if not post_result.passed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Output scan failed: {', '.join(post_result.flags)}",
|
||||
)
|
||||
|
||||
entry = {
|
||||
"prompt": effective_prompt,
|
||||
"final_answer": final.final_answer,
|
||||
"confidence_score": final.confidence_score,
|
||||
"head_contributions": final.head_contributions,
|
||||
}
|
||||
store.append_history(session_id, entry)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"task_id": task_id,
|
||||
"final_answer": final.final_answer,
|
||||
"transparency_report": final.transparency_report.model_dump(),
|
||||
"head_contributions": final.head_contributions,
|
||||
"confidence_score": final.confidence_score,
|
||||
}
|
||||
if parsed.intent == UserIntent.SHOW_DISSENT:
|
||||
response["response_mode"] = "show_dissent"
|
||||
response["disputed_claims"] = final.transparency_report.agreement_map.disputed_claims
|
||||
elif parsed.intent == UserIntent.EXPLAIN_REASONING:
|
||||
response["response_mode"] = "explain"
|
||||
elif parsed.intent == UserIntent.SOURCES and head_outputs:
|
||||
response["sources"] = extract_sources_from_head_outputs(head_outputs)
|
||||
return response
|
||||
|
||||
|
||||
@router.websocket("/{session_id}/stream")
|
||||
async def stream_websocket(websocket: WebSocket, session_id: str) -> None:
|
||||
"""WebSocket for streaming Dvādaśa response. Send {\"prompt\": \"...\"} to start."""
|
||||
await websocket.accept()
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
prompt = data.get("prompt", "")
|
||||
async def send_evt(evt: dict) -> None:
|
||||
await websocket.send_json(evt)
|
||||
await handle_stream(session_id, prompt, send_evt)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e)})
|
||||
except Exception:
|
||||
pass
|
||||
49
fusionagi/api/routes/tts.py
Normal file
49
fusionagi/api/routes/tts.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""TTS synthesis routes for per-head voice output."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from fusionagi.api.dependencies import get_session_store
|
||||
from fusionagi.config.head_voices import get_voice_id_for_head
|
||||
from fusionagi.schemas.head import HeadId
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{session_id}/synthesize")
|
||||
async def synthesize(
|
||||
session_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Synthesize text to audio for a head.
|
||||
Body: { "text": "...", "head_id": "logic" }
|
||||
Returns: { "audio_base64": "..." } or { "audio_base64": null } if TTS not configured.
|
||||
"""
|
||||
store = get_session_store()
|
||||
if not store:
|
||||
raise HTTPException(status_code=503, detail="Service not initialized")
|
||||
sess = store.get(session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
text = body.get("text", "")
|
||||
head_id_str = body.get("head_id", "")
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="text is required")
|
||||
|
||||
try:
|
||||
head_id = HeadId(head_id_str)
|
||||
except ValueError:
|
||||
head_id = HeadId.LOGIC
|
||||
|
||||
voice_id = get_voice_id_for_head(head_id)
|
||||
audio_base64 = None
|
||||
# TODO: Wire TTSAdapter (ElevenLabs, Azure, etc.) and synthesize
|
||||
# if tts_adapter:
|
||||
# audio_bytes = await tts_adapter.synthesize(text, voice_id=voice_id)
|
||||
# if audio_bytes:
|
||||
# import base64
|
||||
# audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
return {"audio_base64": audio_base64, "voice_id": voice_id}
|
||||
Reference in New Issue
Block a user