diff --git a/README.md b/README.md index bfc2855..d4b8a09 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,13 @@ with `tool_start`/`tool_result` events as each tool runs — so a UI can type th answer out live and show tool activity as it happens. ```bash -# Run the agent API (default model: kimi via NVIDIA build) +# Run the agent API (default provider: nvidia or openrouter) export FLOW_NVIDIA_API_KEY="nvapi-..." +# Or use OpenRouter: +# export OPENROUTER_API_KEY="sk-or-v1-..." +# export FLOW_AGENT_PROVIDER="openrouter" +# export FLOW_AGENT_MODEL="moonshotai/kimi-k2.6" + flow agent # POST /agent/chat (SSE), GET /agent/models, POST /agent/undo # Or expose the same tools to external coding agents over MCP diff --git a/config/config.example.toml b/config/config.example.toml index 4b209ee..79984bd 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -81,10 +81,11 @@ auto_generate_topics = true [agent] # The in-app video agent — an LLM that operates the project through 41 tools. -# Served by the NVIDIA build API (OpenAI-compatible). Run with: `flow agent` +# Supported providers: "nvidia" (NVIDIA Build API) or "openrouter" (OpenRouter.ai API) +# Can also be overridden via FLOW_AGENT_PROVIDER, FLOW_AGENT_MODEL, FLOW_AGENT_BASE_URL, FLOW_AGENT_API_KEY env vars. provider = "nvidia" base_url = "https://integrate.api.nvidia.com/v1" -api_key = "" # or set FLOW_NVIDIA_API_KEY +api_key = "" # or set FLOW_NVIDIA_API_KEY / OPENROUTER_API_KEY model = "kimi" # alias -> moonshotai/kimi-k2.6; see all via GET /agent/models max_iterations = 12 # safety cap on the tool-calling loop diff --git a/src/flow/agent/nvidia.py b/src/flow/agent/nvidia.py index deb40bb..25d43e4 100644 --- a/src/flow/agent/nvidia.py +++ b/src/flow/agent/nvidia.py @@ -11,21 +11,38 @@ import httpx +OPENROUTER_BASE = "https://openrouter.ai/api/v1" NVIDIA_BASE = "https://integrate.api.nvidia.com/v1" MODEL_ALIASES = {"kimi": "moonshotai/kimi-k2.6"} class NvidiaClient: def __init__(self, api_key: str | None = None, base_url: str = NVIDIA_BASE, - model: str = "kimi", timeout: float = 120.0) -> None: - self.api_key = api_key or os.environ.get("FLOW_NVIDIA_API_KEY", "") + model: str = "kimi", timeout: float = 120.0, + extra_headers: dict | None = None) -> None: self.base_url = base_url.rstrip("/") + if not api_key: + is_or = ("openrouter" in self.base_url or os.environ.get("OPENROUTER_API_KEY") + or os.environ.get("FLOW_OPENROUTER_API_KEY")) + if is_or: + api_key = (os.environ.get("OPENROUTER_API_KEY") + or os.environ.get("FLOW_OPENROUTER_API_KEY") + or os.environ.get("FLOW_NVIDIA_API_KEY", "")) + else: + api_key = os.environ.get("FLOW_NVIDIA_API_KEY", "") + self.api_key = api_key self.model = MODEL_ALIASES.get(model, model) self.timeout = timeout + self.extra_headers = extra_headers or {} + if "openrouter" in self.base_url: + self.extra_headers.setdefault("HTTP-Referer", "https://openx-flow.stanl.ink") + self.extra_headers.setdefault("X-Title", "OpenX Flow") def _headers(self) -> dict: - return {"Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json"} + headers = {"Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json"} + headers.update(self.extra_headers) + return headers def list_models(self) -> list[str]: with httpx.Client(timeout=30) as c: diff --git a/src/flow/api/app.py b/src/flow/api/app.py index 50e8c3a..2a98274 100644 --- a/src/flow/api/app.py +++ b/src/flow/api/app.py @@ -12,7 +12,7 @@ from sse_starlette.sse import EventSourceResponse from flow.agent.loop import Agent -from flow.agent.nvidia import NvidiaClient +from flow.agent.nvidia import NVIDIA_BASE, OPENROUTER_BASE, NvidiaClient from flow.config import Config, load_config from flow.store.store import ProjectStore from flow.tools.context import ToolContext, dispatch @@ -39,7 +39,10 @@ def get_config() -> Config: def _client() -> NvidiaClient: cfg = get_config().agent - return NvidiaClient(api_key=cfg.api_key or None, base_url=cfg.base_url, model=cfg.model) + base_url = cfg.base_url + if cfg.provider == "openrouter" and base_url == NVIDIA_BASE: + base_url = OPENROUTER_BASE + return NvidiaClient(api_key=cfg.api_key or None, base_url=base_url, model=cfg.model) def _make_agent(project_id: str) -> Agent: diff --git a/src/flow/config.py b/src/flow/config.py index 27754bd..fbba613 100644 --- a/src/flow/config.py +++ b/src/flow/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import tomllib from pathlib import Path from typing import Any @@ -108,9 +109,19 @@ class Config(BaseModel): def load_config(path: str | Path = "config/config.toml") -> Config: - """Load configuration from TOML file.""" + """Load configuration from TOML file with environment variable overrides.""" config_path = Path(path) - if not config_path.exists(): - return Config() - data: dict[str, Any] = tomllib.loads(config_path.read_text()) - return Config(**data) + data: dict[str, Any] = tomllib.loads(config_path.read_text()) if config_path.exists() else {} + cfg = Config(**data) + + # Environment variable overrides for AgentConfig + if os.environ.get("FLOW_AGENT_PROVIDER"): + cfg.agent.provider = os.environ["FLOW_AGENT_PROVIDER"] + if os.environ.get("FLOW_AGENT_BASE_URL"): + cfg.agent.base_url = os.environ["FLOW_AGENT_BASE_URL"] + if os.environ.get("FLOW_AGENT_API_KEY"): + cfg.agent.api_key = os.environ["FLOW_AGENT_API_KEY"] + if os.environ.get("FLOW_AGENT_MODEL"): + cfg.agent.model = os.environ["FLOW_AGENT_MODEL"] + + return cfg diff --git a/tests/test_agent.py b/tests/test_agent.py index e40003d..dbaf95e 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -112,3 +112,18 @@ def test_run_collects_stream(): assert out["reply"] == "all set" assert len(out["tool_calls"]) == 1 assert out["tool_calls"][0]["tool"] == "create_character" + + +# --- OpenRouter client tests --- + +def test_nvidia_client_openrouter_defaults(monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test") + from flow.agent.nvidia import OPENROUTER_BASE, NvidiaClient + + client = NvidiaClient(base_url=OPENROUTER_BASE) + assert client.api_key == "sk-or-v1-test" + headers = client._headers() + assert headers["Authorization"] == "Bearer sk-or-v1-test" + assert headers["HTTP-Referer"] == "https://openx-flow.stanl.ink" + assert headers["X-Title"] == "OpenX Flow" + diff --git a/tests/test_config.py b/tests/test_config.py index 7e6484a..b040a04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,3 +16,17 @@ def test_load_missing_config(): """Loading a nonexistent config returns defaults.""" config = load_config("/nonexistent/path.toml") assert config.llm.provider == "openai" + + +def test_agent_config_env_overrides(monkeypatch): + monkeypatch.setenv("FLOW_AGENT_PROVIDER", "openrouter") + monkeypatch.setenv("FLOW_AGENT_MODEL", "moonshotai/kimi-k2.6") + monkeypatch.setenv("FLOW_AGENT_BASE_URL", "https://openrouter.ai/api/v1") + monkeypatch.setenv("FLOW_AGENT_API_KEY", "sk-or-v1-test") + + cfg = load_config("/nonexistent/path.toml") + assert cfg.agent.provider == "openrouter" + assert cfg.agent.model == "moonshotai/kimi-k2.6" + assert cfg.agent.base_url == "https://openrouter.ai/api/v1" + assert cfg.agent.api_key == "sk-or-v1-test" + diff --git a/uv.lock b/uv.lock index c76627b..cd9baea 100644 --- a/uv.lock +++ b/uv.lock @@ -538,7 +538,7 @@ wheels = [ [[package]] name = "flow" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "edge-tts" },