Initial commit
This commit is contained in:
commit
5eedbdd039
30 files changed
+2827
No files matched your search
@@ -0,0 +1,145 @@
|
||||
"""LLM providers: mock (default, no API key), ollama (local), openai, anthropic, gemini.
|
||||
|
||||
Select with the MINIFLOW_LLM environment variable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from miniflow.template import render
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def mock_reply(prompt: str, mock: dict[str, Any] | None, context: dict[str, Any]) -> str:
|
||||
"""Deterministic reply so the workflow runs without any API key.
|
||||
|
||||
mock.rules: first rule whose keywords appear in the prompt wins.
|
||||
mock.template: rendered with the workflow context.
|
||||
"""
|
||||
mock = mock or {}
|
||||
lowered = prompt.lower()
|
||||
for rule in mock.get("rules", []):
|
||||
if any(word.lower() in lowered for word in rule.get("if_contains", [])):
|
||||
return str(render(rule["then"], context))
|
||||
if "template" in mock:
|
||||
return str(render(mock["template"], context))
|
||||
if "default" in mock:
|
||||
return str(render(mock["default"], context))
|
||||
first_line = prompt.strip().splitlines()[0] if prompt.strip() else ""
|
||||
return f"[mock-llm] received {len(prompt)} chars. first line: {first_line[:60]}"
|
||||
|
||||
|
||||
def call_ollama(system: str, prompt: str, temperature: float) -> str:
|
||||
"""Call a local Ollama server through its /api/chat endpoint (no extra package needed)."""
|
||||
host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
||||
if not host.startswith("http"):
|
||||
host = f"http://{host}"
|
||||
model = os.getenv("OLLAMA_MODEL", "gemma3:4b")
|
||||
messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
|
||||
body = json.dumps({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature},
|
||||
}).encode("utf-8")
|
||||
request = urllib.request.Request(f"{host}/api/chat", data=body, headers={"Content-Type": "application/json"})
|
||||
timeout = float(os.getenv("OLLAMA_TIMEOUT", "180"))
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode("utf-8", errors="replace")
|
||||
if error.code == 404:
|
||||
raise LLMError(f"Model '{model}' not found on Ollama. Run: ollama pull {model}") from error
|
||||
raise LLMError(f"Ollama returned HTTP {error.code}: {detail[:200]}") from error
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
|
||||
raise LLMError(f"Cannot reach Ollama at {host}. Is 'ollama serve' running? ({error})") from error
|
||||
return data.get("message", {}).get("content", "").strip()
|
||||
|
||||
|
||||
def call_anthropic(system: str, prompt: str) -> str:
|
||||
"""Call Claude through the Anthropic SDK.
|
||||
|
||||
Current Claude models reject sampling parameters, so temperature is not sent.
|
||||
A server-side fallback model answers if the first model declines the request.
|
||||
"""
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError as error:
|
||||
raise LLMError("Install the extra first: uv sync --extra anthropic") from error
|
||||
client = anthropic.Anthropic()
|
||||
try:
|
||||
response = client.beta.messages.create(
|
||||
model=os.getenv("ANTHROPIC_MODEL", "claude-opus-5-5"),
|
||||
max_tokens=16000,
|
||||
system=system or anthropic.omit,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
betas=["server-side-fallback-2026-07-01"],
|
||||
fallbacks="default",
|
||||
)
|
||||
except anthropic.AuthenticationError as error:
|
||||
raise LLMError("Anthropic credentials are missing or invalid (set ANTHROPIC_API_KEY)") from error
|
||||
except anthropic.APIConnectionError as error:
|
||||
raise LLMError(f"Cannot reach the Anthropic API ({error})") from error
|
||||
except anthropic.APIStatusError as error:
|
||||
raise LLMError(f"Anthropic API returned HTTP {error.status_code}: {error.message}") from error
|
||||
if response.stop_reason == "refusal":
|
||||
raise LLMError("Claude declined this request (stop_reason=refusal)")
|
||||
return "".join(block.text for block in response.content if block.type == "text").strip()
|
||||
|
||||
|
||||
def call_gemini(system: str, prompt: str, temperature: float) -> str:
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
except ImportError as error:
|
||||
raise LLMError("Install the extra first: uv sync --extra gemini") from error
|
||||
if not os.getenv("GEMINI_API_KEY"):
|
||||
raise LLMError("GEMINI_API_KEY is not set")
|
||||
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
|
||||
response = client.models.generate_content(
|
||||
model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(system_instruction=system or None, temperature=temperature),
|
||||
)
|
||||
return response.text or ""
|
||||
|
||||
|
||||
def call_openai(system: str, prompt: str, temperature: float) -> str:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as error:
|
||||
raise LLMError("Install the extra first: uv sync --extra openai") from error
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
raise LLMError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI()
|
||||
response = client.responses.create(
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
|
||||
instructions=system or None,
|
||||
input=prompt,
|
||||
temperature=temperature,
|
||||
)
|
||||
return response.output_text
|
||||
|
||||
|
||||
def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return (reply, provider_name)."""
|
||||
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
|
||||
temperature = float(node.get("temperature", 0.2))
|
||||
if provider == "mock":
|
||||
return mock_reply(prompt, node.get("mock"), context), "mock"
|
||||
if provider == "ollama":
|
||||
return call_ollama(system, prompt, temperature), "ollama"
|
||||
if provider == "anthropic":
|
||||
return call_anthropic(system, prompt), "anthropic"
|
||||
if provider == "gemini":
|
||||
return call_gemini(system, prompt, temperature), "gemini"
|
||||
if provider == "openai":
|
||||
return call_openai(system, prompt, temperature), "openai"
|
||||
raise LLMError(f"Unknown MINIFLOW_LLM value: {provider} (use mock, ollama, openai, anthropic or gemini)")
|
||||
Reference in new issue
Block a user