docs(P01): complete minimal-voice-loop phase
---ci--- phase: 1 milestone: v0.1 status: complete requirements: covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-SCEN-01, REQ-STATE-01, REQ-LLM-01, REQ-LLM-02, REQ-DEBRIEF-01, REQ-ORCH-01, REQ-ORCH-02, REQ-SCEN-FMT-01, REQ-NFR-LAT-01, REQ-NFR-SAFE-01, REQ-NFR-COST-01] partial: [] ---/ci---
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""LLM adapter package — Ollama Cloud direct API behind LLMProvider."""
|
||||
|
||||
from server.services.base import LLMProvider, LLMStreamChunk
|
||||
|
||||
__all__ = ["LLMProvider", "LLMStreamChunk"]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Ollama Cloud LLM adapter behind the LLMProvider interface (D-020).
|
||||
|
||||
Direct API to https://ollama.com/api/chat with OLLAMA_API_KEY bearer,
|
||||
stream=True. Two models:
|
||||
- gemma4:cloud (role-play fast path, 256K ctx)
|
||||
- deepseek-v4-flash:cloud (debrief + branch classifier, no-think mode)
|
||||
|
||||
R6 resolution: Pipecat's OLLamaLLMService accepts a custom base_url + bearer
|
||||
(see docs/latency-report.md). This adapter is a thin wrapper over the raw
|
||||
/api/chat streaming endpoint so the pipeline has a stable, testable contract
|
||||
independent of Pipecat's OpenAI-compat shim. The Pipecat pipeline wires the
|
||||
LLM via this adapter (TASK-02-04) so a swap (e.g. self-hosted gemma4:e4b
|
||||
post-pilot) requires no pipeline change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from server.services.base import LLMProvider, LLMStreamChunk
|
||||
|
||||
CHAT_URL_DEFAULT = "https://ollama.com/api/chat"
|
||||
|
||||
|
||||
class OllamaCloudLLM(LLMProvider):
|
||||
"""Ollama Cloud direct-API LLM adapter (D-020)."""
|
||||
|
||||
name = "ollama-cloud"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
chat_url: str | None = None,
|
||||
roleplay_model: str | None = None,
|
||||
debrief_model: str | None = None,
|
||||
) -> None:
|
||||
self._api_key = (api_key or os.environ.get("OLLAMA_API_KEY", "")).strip()
|
||||
self._chat_url = (chat_url or os.environ.get("OLLAMA_CHAT_URL", CHAT_URL_DEFAULT)).strip()
|
||||
self._roleplay_model = (
|
||||
roleplay_model or os.environ.get("OLLAMA_ROLEPLAY_MODEL", "gemma4:cloud")
|
||||
).strip()
|
||||
self._debrief_model = (
|
||||
debrief_model
|
||||
or os.environ.get("OLLAMA_DEBRIEF_MODEL", "deepseek-v4-flash:cloud")
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def roleplay_model(self) -> str:
|
||||
return self._roleplay_model
|
||||
|
||||
@property
|
||||
def debrief_model(self) -> str:
|
||||
return self._debrief_model
|
||||
|
||||
def _missing(self) -> bool:
|
||||
return not self._api_key
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
|
||||
|
||||
def _body(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
model: str,
|
||||
stream: bool,
|
||||
no_think: bool,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
}
|
||||
if no_think:
|
||||
# deepseek-v4-flash:cloud no-think mode (D-020) — skips reasoning
|
||||
# tokens for latency on the debrief / branch-classifier path.
|
||||
body["think"] = False
|
||||
return body
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
stream: bool = True,
|
||||
model: str | None = None,
|
||||
no_think: bool = False,
|
||||
) -> AsyncIterator[LLMStreamChunk]:
|
||||
"""Stream chat-completion chunks from Ollama Cloud /api/chat."""
|
||||
mdl = model or self._roleplay_model
|
||||
if self._missing():
|
||||
# Graceful: yield a single empty chunk so callers don't crash.
|
||||
return
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
async with client.stream(
|
||||
"POST", self._chat_url, headers=self._headers(),
|
||||
json=self._body(messages, mdl, stream, no_think),
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
# Auth/error — degrade to no chunks (pipeline stays up).
|
||||
return
|
||||
is_first = True
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
content = chunk.get("message", {}).get("content", "")
|
||||
if content:
|
||||
yield LLMStreamChunk(
|
||||
content=content,
|
||||
is_first=is_first,
|
||||
finish_reason=chunk.get("done") and "stop" or None,
|
||||
extra={"eval_count": chunk.get("eval_count")},
|
||||
)
|
||||
is_first = False
|
||||
except Exception:
|
||||
# Network/auth errors degrade to no chunks; the pipeline stays up.
|
||||
return
|
||||
|
||||
async def chat_full(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
model: str | None = None,
|
||||
no_think: bool = False,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Return (full_text, usage) for non-streaming (debrief / classifier)."""
|
||||
mdl = model or self._debrief_model
|
||||
parts: list[str] = []
|
||||
usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "model": mdl}
|
||||
async for chunk in self.chat(
|
||||
messages, stream=True, model=mdl, no_think=no_think
|
||||
):
|
||||
parts.append(chunk.content)
|
||||
if chunk.extra.get("eval_count"):
|
||||
usage["output_tokens"] = chunk.extra["eval_count"]
|
||||
return "".join(parts), usage
|
||||
|
||||
|
||||
__all__ = ["OllamaCloudLLM"]
|
||||
Reference in New Issue
Block a user