Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ REEL_AF_MODEL=openrouter/deepseek/deepseek-v4-pro
# REEL_AF_MODEL=openrouter/qwen/qwen3-235b-a22b-instruct
# REEL_AF_MODEL=openrouter/google/gemini-2.5-pro

# --- Advanced: bring your own reasoning endpoint (optional) ---
# Leave both unset to use OpenRouter (the default). To run reasoning against
# any OpenAI-compatible endpoint — a local vLLM/Ollama server, a self-hosted
# gateway, or another aggregator — set the model to that server's id and point
# these at it. Media (TTS/image/video) still uses OpenRouter, so keep
# OPENROUTER_API_KEY set.
# REEL_AF_MODEL=openai/your-local-model
# REEL_AF_API_BASE=http://localhost:8000/v1
# REEL_AF_API_KEY=sk-local-or-anything
# REEL_AF_API_BASE=
# REEL_AF_API_KEY=

# --- Media models ---
# TTS: Gemini Flash supports 200+ inline tone tags.
REEL_AF_TTS_MODEL=google/gemini-3.1-flash-tts-preview
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# ffmpeg/ffprobe are a hard runtime requirement (karaoke render path);
# the render tests exercise the real binaries.
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Pin Python (matches the Dockerfile)
run: uv python install 3.11

# Lint the test suite. NOTE: src/ carries some pre-existing ruff debt
# tracked separately; scope here is the code this CI guards.
- name: Lint tests
run: uv run --extra dev ruff check tests/

- name: Run tests
run: uv run --extra dev python -m pytest tests/ -q
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,32 @@ Voice, pacing, and tone are picked in code (`render/tts.py:_VOICE_BY_TONE` and t

---

## Bring your own model (advanced)

OpenRouter is the recommended path — one key, every model, zero config. But if you'd rather run **reasoning** against your own OpenAI-compatible endpoint — a local [vLLM](https://github.com/vllm-project/vllm) / [Ollama](https://ollama.com/) server, a self-hosted gateway, or a different aggregator — you can, without touching code:

```bash
REEL_AF_MODEL=openai/your-model-id # how your endpoint names the model
REEL_AF_API_BASE=http://localhost:8000/v1 # your OpenAI-compatible base URL
REEL_AF_API_KEY=sk-local-or-anything # your endpoint's key
```

Leave `REEL_AF_API_BASE` / `REEL_AF_API_KEY` unset and everything points at OpenRouter exactly as before — this is strictly additive, the default flow is unchanged.

| Env var | Default | What it controls |
|---|---|---|
| `REEL_AF_API_BASE` | OpenRouter (`https://openrouter.ai/api/v1`) | Base URL for reasoning `.ai()` calls. Empty = OpenRouter. |
| `REEL_AF_API_KEY` | falls back to `OPENROUTER_API_KEY` | Key sent to the reasoning endpoint. |

Two caveats:

- **Media still uses OpenRouter.** TTS, image, and Veo generation route through OpenRouter today, so keep `OPENROUTER_API_KEY` set even when reasoning is self-hosted. Configurable per-provider media endpoints are tracked in [issue #2](https://github.com/Agent-Field/reels-af/issues/2).
- **Use the `openai/` prefix** for OpenAI-compatible servers so the request is shaped correctly; `REEL_AF_API_BASE` then redirects it to your host.

This intentionally isn't the headline workflow — it adds moving parts. For most users, the one-key OpenRouter default is the simpler path.

---

## Troubleshooting

**"OPENROUTER_API_KEY not set in env."** — paste your key into `.env`. The Docker container reads it via `docker-compose.yml`; the CLI reads it via `python-dotenv`.
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ services:
AGENT_NODE_ID: ${AGENT_NODE_ID:-reel-af}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
REEL_AF_MODEL: ${REEL_AF_MODEL:-openrouter/deepseek/deepseek-v4-pro}
# Advanced: point reasoning at any OpenAI-compatible endpoint. Empty
# values are treated as unset and fall back to OpenRouter.
REEL_AF_API_BASE: ${REEL_AF_API_BASE:-}
REEL_AF_API_KEY: ${REEL_AF_API_KEY:-}
REEL_AF_TTS_MODEL: ${REEL_AF_TTS_MODEL:-google/gemini-3.1-flash-tts-preview}
REEL_AF_IMAGE_MODEL: ${REEL_AF_IMAGE_MODEL:-openrouter/google/gemini-2.5-flash-image}
REEL_AF_VIDEO_MODEL: ${REEL_AF_VIDEO_MODEL:-openrouter/google/veo-3.1-lite}
Expand Down
11 changes: 9 additions & 2 deletions src/reel_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,15 @@
description="URL or topic → vertical viral reel via a multi-reasoner DAG.",
ai_config=AIConfig(
model=os.getenv("REEL_AF_MODEL", "openrouter/deepseek/deepseek-v4-pro"),
api_key=os.environ.get("OPENROUTER_API_KEY", ""),
api_base="https://openrouter.ai/api/v1",
# Reasoning endpoint defaults to OpenRouter. Advanced users can point
# `.ai()` calls at any OpenAI-compatible endpoint (local vLLM/Ollama,
# a self-hosted gateway, another aggregator) without code changes:
# REEL_AF_API_KEY overrides the key (falls back to OPENROUTER_API_KEY)
# REEL_AF_API_BASE overrides the base URL (empty/unset → OpenRouter)
# Media (TTS/image/video) still routes through OpenRouter, so
# OPENROUTER_API_KEY remains required. See README "Bring your own model".
api_key=os.getenv("REEL_AF_API_KEY") or os.environ.get("OPENROUTER_API_KEY", ""),
api_base=os.getenv("REEL_AF_API_BASE") or "https://openrouter.ai/api/v1",
),
dev_mode=True,
)
Expand Down
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Test bootstrap.

Adds the ``src`` layout and the ``tests`` directory to ``sys.path`` so the
``reel_af`` package and the local ``util`` helper module import cleanly
whether or not the project has been ``pip install``-ed.
"""

from __future__ import annotations

import sys
from pathlib import Path

_ROOT = Path(__file__).resolve().parent.parent
_SRC = _ROOT / "src"
_TESTS = Path(__file__).resolve().parent

for _p in (str(_SRC), str(_TESTS)):
if _p not in sys.path:
sys.path.insert(0, _p)
44 changes: 44 additions & 0 deletions tests/test_byo_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tier A — bring-your-own reasoning endpoint.

Locks the additive override path introduced by ``REEL_AF_API_BASE`` /
``REEL_AF_API_KEY``. These coexist with the OpenRouter defaults pinned in
``test_provider_config.py``: with both vars unset, config resolves to
OpenRouter exactly as before. Each test maps to a validation-contract item.
"""

from __future__ import annotations

from util import run_config_probe

DEFAULT_API_BASE = "https://openrouter.ai/api/v1"

# Clear the BYO vars by default so the host environment can't leak in; each
# test sets only what it exercises.
_CLEAR = {"REEL_AF_API_BASE": None, "REEL_AF_API_KEY": None}


def test_api_base_override():
# CA2 — a custom endpoint is honoured verbatim.
cfg = run_config_probe({**_CLEAR, "REEL_AF_API_BASE": "http://localhost:8000/v1"})
assert cfg["api_base"] == "http://localhost:8000/v1"


def test_api_key_override_takes_precedence_over_openrouter_key():
# CA3 — REEL_AF_API_KEY wins over OPENROUTER_API_KEY when both are set.
cfg = run_config_probe(
{**_CLEAR, "OPENROUTER_API_KEY": "sk-openrouter", "REEL_AF_API_KEY": "sk-byo-endpoint"}
)
assert cfg["api_key"] == "sk-byo-endpoint"


def test_api_key_falls_back_to_openrouter_when_byo_unset():
# CA4 — without REEL_AF_API_KEY, OPENROUTER_API_KEY still supplies the key.
cfg = run_config_probe({**_CLEAR, "OPENROUTER_API_KEY": "sk-openrouter"})
assert cfg["api_key"] == "sk-openrouter"


def test_empty_api_base_falls_back_to_openrouter():
# CA5 — Docker passes `${REEL_AF_API_BASE:-}` as "" when unset; an empty
# string must NOT clobber the default endpoint.
cfg = run_config_probe({**_CLEAR, "REEL_AF_API_BASE": ""})
assert cfg["api_base"] == DEFAULT_API_BASE
99 changes: 99 additions & 0 deletions tests/test_gates_and_patches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""API-key gates and the SDK runtime patches.

The provider refactor will relax the hard ``OPENROUTER_API_KEY`` requirement,
so these pin the *current* gate behaviour to make any change deliberate and
visible. The patch tests guard the end-to-end-callability fixes the README
depends on (data-URL images, video-download auth, the /audio/speech route).
"""

from __future__ import annotations

import base64
import io

import pytest
from PIL import Image

# ───── missing-key gates ─────────────────────────────────────────────


async def test_article_to_reel_errors_without_key(monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
import reel_af.app as app

result = await app.article_to_reel(url="https://example.com/x")
assert result == {"error": "OPENROUTER_API_KEY not set in env."}


async def test_topic_to_reel_errors_without_key(monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
import reel_af.app as app

result = await app.topic_to_reel(topic="the placebo effect")
assert result == {"error": "OPENROUTER_API_KEY not set in env."}


def test_cli_require_key_raises_when_unset(monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
import reel_af.cli as cli

with pytest.raises(SystemExit):
cli._require_key()


def test_cli_require_key_passes_when_set(monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
import reel_af.cli as cli

cli._require_key() # must not raise


# ───── sdk patches ───────────────────────────────────────────────────


def test_generate_speech_method_is_added():
from agentfield.media_providers import OpenRouterProvider

import reel_af.sdk_patches # noqa: F401

assert hasattr(OpenRouterProvider, "generate_speech")


def test_generate_video_is_patched():
from agentfield.media_providers import OpenRouterProvider

import reel_af.sdk_patches # noqa: F401

assert getattr(OpenRouterProvider.generate_video, "__reel_af_patched__", False) is True


def test_image_output_save_decodes_data_urls(tmp_path):
from agentfield.multimodal_response import ImageOutput

import reel_af.sdk_patches # noqa: F401

# A 4x4 red PNG encoded as a data: URL — Gemini returns these.
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, format="PNG")
raw = buf.getvalue()
data_url = "data:image/png;base64," + base64.b64encode(raw).decode()

out = tmp_path / "decoded.png"
ImageOutput(url=data_url).save(out)

assert out.read_bytes() == raw # decoded locally, no network fetch


def test_apply_all_is_idempotent():
from agentfield.media_providers import OpenRouterProvider

import reel_af.sdk_patches as patches

before = OpenRouterProvider.generate_video
patches.apply_all() # second application
patches.apply_all() # third
after = OpenRouterProvider.generate_video

assert after is before # no re-wrapping
assert getattr(after, "__reel_af_patched__", False) is True
assert hasattr(OpenRouterProvider, "generate_speech")
77 changes: 77 additions & 0 deletions tests/test_provider_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Lock the documented provider/config contract (README "Customize" table).

These characterise the env-var → config resolution that the upcoming
"bring your own provider" change will refactor. They must keep passing
afterwards: the new behaviour is purely additive, so the OpenRouter
defaults and the already-documented overrides cannot regress.
"""

from __future__ import annotations

from util import run_config_probe

# README-documented defaults (Customize table + .env.example).
DEFAULT_MODEL = "openrouter/deepseek/deepseek-v4-pro"
DEFAULT_API_BASE = "https://openrouter.ai/api/v1"
DEFAULT_TTS = "google/gemini-3.1-flash-tts-preview"
DEFAULT_IMAGE = "openrouter/google/gemini-2.5-flash-image"
DEFAULT_VIDEO = "openrouter/google/veo-3.1-lite"

# Every model-selecting env var, cleared so we observe true defaults.
_CLEAR_MODELS = {
"REEL_AF_MODEL": None,
"REEL_AF_TTS_MODEL": None,
"REEL_AF_IMAGE_MODEL": None,
"REEL_AF_VIDEO_MODEL": None,
"REEL_AF_USE_VEO": None,
}


def test_clean_env_resolves_documented_defaults():
cfg = run_config_probe(_CLEAR_MODELS)
assert cfg["model"] == DEFAULT_MODEL
assert cfg["api_base"] == DEFAULT_API_BASE
assert cfg["tts_default"] == DEFAULT_TTS
assert cfg["image_model"] == DEFAULT_IMAGE
assert cfg["video_model"] == DEFAULT_VIDEO
assert cfg["use_veo"] is False


def test_api_key_sourced_from_openrouter_key():
cfg = run_config_probe({**_CLEAR_MODELS, "OPENROUTER_API_KEY": "sk-probe-123"})
assert cfg["api_key"] == "sk-probe-123"


def test_reel_af_model_override():
# Exact example from the README quick-start.
override = "openrouter/anthropic/claude-sonnet-4"
cfg = run_config_probe({**_CLEAR_MODELS, "REEL_AF_MODEL": override})
assert cfg["model"] == override
# Overriding the reasoning model leaves the endpoint untouched.
assert cfg["api_base"] == DEFAULT_API_BASE


def test_media_model_overrides():
cfg = run_config_probe(
{
**_CLEAR_MODELS,
"REEL_AF_TTS_MODEL": "google/some-tts",
"REEL_AF_IMAGE_MODEL": "openrouter/black-forest-labs/flux-1.1-pro",
"REEL_AF_VIDEO_MODEL": "openrouter/google/veo-3.1",
}
)
assert cfg["tts_default"] == DEFAULT_TTS # constant is the *fallback*, not the override
assert cfg["image_model"] == "openrouter/black-forest-labs/flux-1.1-pro"
assert cfg["video_model"] == "openrouter/google/veo-3.1"


def test_use_veo_truthy_values_enable_veo():
for truthy in ("true", "TRUE", "True", "1", "yes"):
cfg = run_config_probe({**_CLEAR_MODELS, "REEL_AF_USE_VEO": truthy})
assert cfg["use_veo"] is True, truthy


def test_use_veo_falsey_values_keep_kenburns():
for falsey in ("false", "0", "no", "", "off"):
cfg = run_config_probe({**_CLEAR_MODELS, "REEL_AF_USE_VEO": falsey})
assert cfg["use_veo"] is False, falsey
Loading
Loading