feat: LLM router with local (Ollama) and cloud (OpenAI) backends

- lyra.config.load() reads env into a frozen Config dataclass
- lyra.llm.complete(messages, backend) routes to Ollama /api/chat or
  OpenAI chat completions
- lyra.llm.embed(texts) calls OpenAI embeddings
- .env.example switched from Anthropic to OpenAI to match available key
This commit is contained in:
Claude
2026-05-16 06:10:48 +00:00
parent b2523c2561
commit 6a1255dfdb
5 changed files with 457 additions and 4 deletions
+31
View File
@@ -0,0 +1,31 @@
"""Environment-driven configuration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Config:
local_base_url: str
local_model: str
openai_api_key: str
cloud_model: str
embed_model: str
db_path: Path
def load() -> Config:
return Config(
local_base_url=os.getenv("LOCAL_BASE_URL", "http://localhost:11434"),
local_model=os.getenv("LOCAL_MODEL", "qwen2.5:7b-instruct"),
openai_api_key=os.getenv("OPENAI_API_KEY", ""),
cloud_model=os.getenv("CLOUD_MODEL", "gpt-4o-mini"),
embed_model=os.getenv("EMBED_MODEL", "text-embedding-3-small"),
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
)
+44
View File
@@ -0,0 +1,44 @@
"""LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings."""
from __future__ import annotations
from typing import Literal, TypedDict
import httpx
from openai import OpenAI
from lyra.config import load
class Message(TypedDict):
role: Literal["system", "user", "assistant"]
content: str
Backend = Literal["local", "cloud"]
def complete(messages: list[Message], backend: Backend = "local") -> str:
cfg = load()
if backend == "cloud":
if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key)
resp = client.chat.completions.create(model=cfg.cloud_model, messages=messages)
return resp.choices[0].message.content or ""
resp = httpx.post(
f"{cfg.local_base_url}/api/chat",
json={"model": cfg.local_model, "messages": messages, "stream": False},
timeout=120,
)
resp.raise_for_status()
return resp.json()["message"]["content"]
def embed(texts: list[str]) -> list[list[float]]:
cfg = load()
if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key)
resp = client.embeddings.create(model=cfg.embed_model, input=texts)
return [d.embedding for d in resp.data]