D-2 수정 작업: OpenAI API 우선 없으면 Ollama 적용
This commit is contained in:
1 parent
5eedbdd039
commit
2fe0dc8de1
16 files changed
+466
-218
No files matched your search
@@ -5,7 +5,7 @@
|
||||
```mermaid
|
||||
flowchart LR
|
||||
J["flows/*.json<br/>노드와 연결 정의"] --> E["miniflow 엔진<br/>순서 계산 · 실행"]
|
||||
E --> L{{"LLM 노드<br/>ollama · mock · openai · anthropic · gemini"}}
|
||||
E --> L{{"LLM 노드<br/>openai · ollama · mock · anthropic · gemini"}}
|
||||
E --> T["Tool 노드<br/>get_weather · search_faq"]
|
||||
E --> M[("Memory 노드<br/>.miniflow-memory.json")]
|
||||
E --> R["runs/*.json<br/>실행 기록(Trace)"]
|
||||
@@ -57,19 +57,18 @@ uv run pytest -q
|
||||
|
||||
## 5. LLM 선택
|
||||
|
||||
`MINIFLOW_LLM` 환경 변수로 LLM을 고릅니다. 수업 기본값은 노트북에서 실행하는 **Ollama**입니다. 준비 방법은 [SETUP-WSL-OLLAMA.md](../SETUP-WSL-OLLAMA.md)를 참고하세요.
|
||||
`MINIFLOW_LLM`으로 LLM을 고릅니다. 보통은 `Last3Mon/.env`에 적어 두고(실행한 폴더에서 위로 올라가며 가장 가까운 `.env`를 읽음), 셸이나 명령 앞에 적은 값이 `.env`보다 우선합니다. 수업 기본값은 **OpenAI API**이고, `MINIFLOW_LLM_FALLBACK=ollama`이면 OpenAI 호출이 실패할 때 노트북의 Ollama로 한 번 더 시도합니다. 준비 방법은 [SETUP-WSL-LLM.md](../SETUP-WSL-LLM.md)를 참고하세요.
|
||||
|
||||
| `MINIFLOW_LLM` | LLM | 필요한 것 |
|
||||
| --- | --- | --- |
|
||||
| `ollama` (수업 기본) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
|
||||
| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, Ollama가 안 될 때) |
|
||||
| `openai` | OpenAI (`OPENAI_MODEL`) | `uv sync --extra openai`, `OPENAI_API_KEY` |
|
||||
| `openai` (수업 기본) | OpenAI (`OPENAI_MODEL`, 기본 `gpt-4.1-mini`) | `.env`의 `OPENAI_API_KEY` (패키지는 `uv sync`에 포함) |
|
||||
| `ollama` (대체) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
|
||||
| `mock` (`.env`도 설정도 없을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, 둘 다 안 될 때) |
|
||||
| `anthropic` | Anthropic Claude (`ANTHROPIC_MODEL`, 기본 `claude-opus-5-5`) | `uv sync --extra anthropic`, `ANTHROPIC_API_KEY` |
|
||||
| `gemini` | Google Gemini (`GEMINI_MODEL`) | `uv sync --extra gemini`, `GEMINI_API_KEY` |
|
||||
|
||||
```bash
|
||||
export MINIFLOW_LLM=ollama
|
||||
export OLLAMA_MODEL=gemma3:4b # 8GB 노트북은 gemma3:1b
|
||||
export OLLAMA_HOST=http://localhost:11434 # 기본값이라 생략 가능
|
||||
uv run miniflow check # 연결 확인
|
||||
uv run miniflow check # .env 설정으로 연결 확인 → Reply (openai, ...)
|
||||
OPENAI_API_KEY=sk-wrong uv run miniflow check # 대체 경로 확인 → Reply (ollama, ...)
|
||||
MINIFLOW_LLM=ollama OLLAMA_MODEL=gemma3:1b uv run miniflow check # 대체 모델만 따로
|
||||
```
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
description = "A tiny JSON-defined AI workflow runner for learning LLM, Tool and Memory nodes"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
dependencies = ["openai>=1.40"] # course default LLM (key in .env)
|
||||
|
||||
[project.optional-dependencies]
|
||||
gemini = ["google-genai>=1.0"]
|
||||
|
||||
@@ -67,9 +67,15 @@ def cmd_check(_: argparse.Namespace) -> int:
|
||||
|
||||
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
|
||||
print(f"MINIFLOW_LLM : {provider}")
|
||||
if provider == "ollama":
|
||||
print(f".env : {llm.envfile.LOADED[0] if llm.envfile.LOADED else '(none)'}")
|
||||
if provider == "openai":
|
||||
print(f"OPENAI_MODEL : {os.getenv('OPENAI_MODEL', 'gpt-4.1-mini')}")
|
||||
print(f"API key : {'set' if os.getenv('OPENAI_API_KEY') else 'MISSING (put OPENAI_API_KEY in .env)'}")
|
||||
if provider == "ollama" or os.getenv("MINIFLOW_LLM_FALLBACK", "").lower() == "ollama":
|
||||
print(f"OLLAMA_HOST : {os.getenv('OLLAMA_HOST', 'http://localhost:11434')}")
|
||||
print(f"OLLAMA_MODEL : {os.getenv('OLLAMA_MODEL', 'gemma3:4b')}")
|
||||
if os.getenv("MINIFLOW_LLM_FALLBACK"):
|
||||
print(f"FALLBACK : {os.getenv('MINIFLOW_LLM_FALLBACK')}")
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
reply, used = llm.complete({"temperature": 0}, "Reply with exactly one word: ready", "", {})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Load settings from the nearest .env file (KEY=VALUE lines, no extra package needed).
|
||||
|
||||
Search order: the current folder and its parents, then the folders above this package
|
||||
(so one Last3Mon/.env serves every day). Only the nearest file is read, and variables
|
||||
already set in the shell win. Set MINIFLOW_DOTENV=0 to skip (the tests do this).
|
||||
The .env file holds API keys: it is listed in .gitignore and must never be committed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
LOADED: list[str] = [] # the .env file that was read, if any
|
||||
|
||||
|
||||
def parse(text: str) -> dict[str, str]:
|
||||
values = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.removeprefix("export ").strip()
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
elif " #" in value:
|
||||
value = value.split(" #", 1)[0].rstrip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def candidates() -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for start in (Path.cwd(), Path(__file__).resolve().parent):
|
||||
for folder in (start, *start.parents):
|
||||
if folder / ".env" not in paths:
|
||||
paths.append(folder / ".env")
|
||||
return paths
|
||||
|
||||
|
||||
def load() -> Path | None:
|
||||
if os.getenv("MINIFLOW_DOTENV", "1") == "0":
|
||||
return None
|
||||
for path in candidates():
|
||||
if path.is_file():
|
||||
for key, value in parse(path.read_text(encoding="utf-8")).items():
|
||||
os.environ.setdefault(key, value)
|
||||
LOADED.append(str(path))
|
||||
return path
|
||||
return None
|
||||
@@ -1,16 +1,21 @@
|
||||
"""LLM providers: mock (default, no API key), ollama (local), openai, anthropic, gemini.
|
||||
"""LLM providers: mock (default, no API key), openai (course default, key in .env), ollama (local), anthropic, gemini.
|
||||
|
||||
Select with the MINIFLOW_LLM environment variable.
|
||||
Select with MINIFLOW_LLM (usually set in .env). MINIFLOW_LLM_FALLBACK=ollama retries a failed call locally.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from miniflow import envfile
|
||||
from miniflow.template import render
|
||||
|
||||
envfile.load() # OPENAI_API_KEY and other settings from .env
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
pass
|
||||
@@ -111,26 +116,68 @@ def call_gemini(system: str, prompt: str, temperature: float) -> str:
|
||||
return response.text or ""
|
||||
|
||||
|
||||
def call_openai(system: str, prompt: str, temperature: float) -> str:
|
||||
def call_openai(system: str, prompt: str, temperature: float, schema: dict | None = None,
|
||||
max_tokens: int | None = None) -> str:
|
||||
"""Call the OpenAI Responses API (OPENAI_MODEL, default gpt-4.1-mini).
|
||||
|
||||
When `schema` is given, the answer is JSON that follows it (structured output).
|
||||
A model that does not accept temperature is called again without it.
|
||||
"""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
import openai
|
||||
except ImportError as error:
|
||||
raise LLMError("Install the extra first: uv sync --extra openai") from error
|
||||
raise LLMError("The openai package is missing. Run: uv sync") 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
|
||||
raise LLMError("OPENAI_API_KEY is not set (put it in the .env file)")
|
||||
client = openai.OpenAI(timeout=float(os.getenv("OPENAI_TIMEOUT", "60")),
|
||||
max_retries=int(os.getenv("OPENAI_MAX_RETRIES", "2")))
|
||||
model = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
|
||||
request: dict[str, Any] = {"model": model, "input": prompt, "temperature": temperature}
|
||||
if system:
|
||||
request["instructions"] = system
|
||||
if max_tokens:
|
||||
request["max_output_tokens"] = max(16, int(max_tokens)) # the API minimum is 16
|
||||
if schema:
|
||||
request["text"] = {"format": {"type": "json_schema", "name": "reply", "schema": schema, "strict": False}}
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
try:
|
||||
response = client.responses.create(**request)
|
||||
except openai.BadRequestError as error:
|
||||
if "temperature" not in str(error):
|
||||
raise
|
||||
request.pop("temperature")
|
||||
response = client.responses.create(**request)
|
||||
except openai.AuthenticationError as error:
|
||||
raise LLMError("OpenAI rejected the API key (check OPENAI_API_KEY in .env)") from error
|
||||
except openai.APIConnectionError as error:
|
||||
raise LLMError(f"Cannot reach the OpenAI API ({error})") from error
|
||||
except openai.APIStatusError as error:
|
||||
raise LLMError(f"OpenAI API returned HTTP {error.status_code}: {error.message}") from error
|
||||
return (response.output_text or "").strip()
|
||||
|
||||
|
||||
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()
|
||||
"""Return (reply, provider_name).
|
||||
|
||||
With MINIFLOW_LLM_FALLBACK set (for example ollama), a call that fails on the main provider
|
||||
is tried once more there, and the reply says which provider answered.
|
||||
"""
|
||||
primary = os.getenv("MINIFLOW_LLM", "mock").lower()
|
||||
try:
|
||||
return _complete_once(node, prompt, system, context, primary)
|
||||
except LLMError as error:
|
||||
fallback = os.getenv("MINIFLOW_LLM_FALLBACK", "").strip().lower()
|
||||
if not fallback or fallback == primary or type(error).__name__ == "BudgetError":
|
||||
raise
|
||||
print(f"[miniflow] {primary} failed ({error}); trying {fallback}", file=sys.stderr)
|
||||
try:
|
||||
return _complete_once(node, prompt, system, context, fallback)
|
||||
except LLMError as second:
|
||||
raise LLMError(f"{primary} failed: {error} / fallback {fallback} failed: {second}") from second
|
||||
|
||||
|
||||
def _complete_once(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any], provider: str) -> tuple[str, str]:
|
||||
temperature = float(node.get("temperature", 0.2))
|
||||
if provider == "mock":
|
||||
return mock_reply(prompt, node.get("mock"), context), "mock"
|
||||
@@ -141,5 +188,5 @@ def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str,
|
||||
if provider == "gemini":
|
||||
return call_gemini(system, prompt, temperature), "gemini"
|
||||
if provider == "openai":
|
||||
return call_openai(system, prompt, temperature), "openai"
|
||||
return call_openai(system, prompt, temperature, node.get("json_schema")), "openai"
|
||||
raise LLMError(f"Unknown MINIFLOW_LLM value: {provider} (use mock, ollama, openai, anthropic or gemini)")
|
||||
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""OpenAI provider, .env loading and the Ollama fallback, without network access."""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from miniflow import envfile, llm
|
||||
|
||||
|
||||
def test_parse_env_lines():
|
||||
values = envfile.parse('# comment\nexport A=1\nB="two words"\nC=3 # note\n\nbroken line\n')
|
||||
assert values == {"A": "1", "B": "two words", "C": "3"}
|
||||
|
||||
|
||||
def test_load_keeps_shell_values(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env").write_text("MINIFLOW_TEST_A=from-file\nMINIFLOW_TEST_B=from-file\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("MINIFLOW_DOTENV", "1")
|
||||
monkeypatch.setenv("MINIFLOW_TEST_A", "from-shell")
|
||||
monkeypatch.delenv("MINIFLOW_TEST_B", raising=False)
|
||||
assert envfile.load() == tmp_path / ".env"
|
||||
assert llm.os.environ["MINIFLOW_TEST_A"] == "from-shell"
|
||||
assert llm.os.environ["MINIFLOW_TEST_B"] == "from-file"
|
||||
monkeypatch.delenv("MINIFLOW_TEST_B")
|
||||
|
||||
|
||||
def test_openai_without_key(monkeypatch):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MINIFLOW_LLM_FALLBACK", raising=False)
|
||||
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY"):
|
||||
llm.complete({}, "hello", "", {})
|
||||
|
||||
|
||||
def test_openai_request_shape(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
class FakeResponses:
|
||||
def create(self, **kwargs):
|
||||
sent.update(kwargs)
|
||||
return types.SimpleNamespace(output_text='{"ok": true}', usage=None)
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_):
|
||||
self.responses = FakeResponses()
|
||||
|
||||
import openai
|
||||
|
||||
monkeypatch.setattr(openai, "OpenAI", FakeClient)
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "test-model")
|
||||
schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
|
||||
reply, used = llm.complete({"temperature": 0, "json_schema": schema}, "hello", "be brief", {})
|
||||
assert (reply, used) == ('{"ok": true}', "openai")
|
||||
assert sent["model"] == "test-model" and sent["instructions"] == "be brief" and sent["temperature"] == 0
|
||||
assert sent["text"]["format"]["schema"] == schema
|
||||
|
||||
|
||||
def test_fallback_to_second_provider(monkeypatch, capsys):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
|
||||
monkeypatch.setattr(llm, "call_ollama", lambda *args, **kwargs: "from ollama")
|
||||
reply, used = llm.complete({}, "hello", "", {})
|
||||
assert (reply, used) == ("from ollama", "ollama")
|
||||
assert "openai failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_both_providers_fail(monkeypatch):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise llm.LLMError("Cannot reach Ollama")
|
||||
|
||||
monkeypatch.setattr(llm, "call_ollama", broken)
|
||||
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY.*fallback ollama failed"):
|
||||
llm.complete({}, "hello", "", {})
|
||||
Generated
+4
@@ -647,6 +647,9 @@ wheels = [
|
||||
name = "miniflow"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openai" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
anthropic = [
|
||||
@@ -668,6 +671,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=1.13" },
|
||||
{ name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.0" },
|
||||
{ name = "openai", specifier = ">=1.40" },
|
||||
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.40" },
|
||||
]
|
||||
provides-extras = ["gemini", "openai", "anthropic"]
|
||||
|
||||
Reference in new issue
Block a user