Skip to content
Open
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
9 changes: 9 additions & 0 deletions changelog.d/orcarouter-provider.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **OrcaRouter is now a first-class LLM provider.** The System Settings LLM picker
offers an `orcarouter:` provider (`opencontractserver/pipeline/llm_providers/orcarouter_provider.py`)
for [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible model routing
gateway. Set `ORCAROUTER_API_KEY` (or configure it live in System Settings →
Pipeline Components) and use specs like `orcarouter:orcarouter/auto`. Because
pydantic-ai has no native `orcarouter:` prefix, `build_agent_model()`
(`opencontractserver/llms/model_factory.py`) always constructs a concrete
OpenAI-compatible model for this provider instead of returning a bare spec
string — so a picked `orcarouter:` model can never surface an unresolvable spec.
3 changes: 3 additions & 0 deletions docs/architecture/llms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2966,6 +2966,7 @@ Specs follow [`pydantic-ai`](https://ai.pydantic.dev)'s `"{provider_key}:{model_
| `"anthropic:claude-opus-4-6"` | Anthropic | claude-opus-4-6 |
| `"google-gla:gemini-2.0-flash"` | Google (AI Studio) | gemini-2.0-flash |
| `"ollama:llama3.3"` | Ollama (local) | llama3.3 |
| `"orcarouter:orcarouter/auto"` | OrcaRouter | orcarouter/auto |

Bare strings (no colon — e.g. `"gpt-4o"`) are treated as `openai` models so legacy `OPENAI_MODEL` values keep working.

Expand All @@ -2989,6 +2990,8 @@ Resolution is **DB-wins / env-fallback**, applied by [`opencontractserver/llms/m
- When a provider has a DB-configured `api_key`/`base_url`, `build_agent_model()` returns a concrete pydantic-ai model whose `Provider` carries those credentials — overriding the environment. A custom `base_url` lets you point OpenAI/Ollama at an OpenAI-compatible gateway or self-hosted server.
- When nothing is configured (the default), it returns the bare `"{provider}:{model}"` spec string and pydantic-ai resolves the credential from the provider-native environment variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, …) exactly as before.

**OrcaRouter** is the one exception to the env-fallback string: pydantic-ai has no native `orcarouter:` provider prefix, so a bare `"orcarouter:..."` spec would raise "Unknown model". Instead `build_agent_model()` always builds a concrete OpenAI-compatible model for OrcaRouter — using DB-configured credentials when present, otherwise `ORCAROUTER_API_KEY` (or a blank-key fallback) and the default endpoint `https://api.orcarouter.ai/v1`. This keeps the System Settings LLM picker safe: choosing an `orcarouter:` model can never produce an unresolvable spec.

Any failure to build a credentialed model degrades to the env-fallback string, so a misconfiguration can never take the chat path down. The factory is invoked at every `make_pydantic_ai_agent` call site (document, corpus, and structured-output agents, plus the memory-curation tasks); it performs ORM access, so async call sites use the `abuild_agent_model()` wrapper.

Because the factory runs on every agent build, the resolved per-provider credentials are memoized in-process keyed on `(class_path, PipelineSettings.modified)` — the same cache key the reranker/embedder instance caches use. This skips the Fernet/PBKDF2 secret decryption on repeat builds while keeping rotation live: a superuser key change calls `PipelineSettings.save()`, which bumps `modified` and clears the singleton cache, so the next build misses the memo and re-decrypts (no redeploy, no staleness beyond the existing 5-minute `PipelineSettings` cache TTL). An out-of-band write that bypasses `save()` (e.g. `QuerySet.update`) should call `invalidate_credential_cache()`.
Expand Down
1 change: 1 addition & 0 deletions docs/sample_env_files/backend/production/django.env
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ ALLOW_API_KEYS=false

# Additional LLM Providers (optional)
# ANTHROPIC_API_KEY=your-anthropic-api-key
# ORCAROUTER_API_KEY=sk-orca-your-key # OrcaRouter OpenAI-compatible gateway
# HF_TOKEN=your-huggingface-token
# HF_EMBEDDINGS_ENDPOINT=your-hf-endpoint

Expand Down
39 changes: 38 additions & 1 deletion opencontractserver/llms/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
from django.db import Error as DatabaseError

from opencontractserver.llms.llm_registry import parse_model_spec
from opencontractserver.pipeline.llm_providers.orcarouter_provider import (
ORCAROUTER_DEFAULT_BASE_URL,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -202,6 +205,40 @@ def _construct_model(
Returns ``None`` for providers we have no construction recipe for, so
the caller can fall back to the bare spec string (env credentials).
"""
if provider_key == "orcarouter":
# OrcaRouter is an OpenAI-compatible model routing gateway. pydantic-ai
# has no native ``orcarouter:`` provider, so a bare spec string would
# raise "Unknown model" at agent construction — this branch ALWAYS
# builds a concrete OpenAI-compatible model. DB-configured credentials
# win; otherwise the ``ORCAROUTER_API_KEY`` env var and the OrcaRouter
# default endpoint are used. An invalid DB-configured endpoint falls
# back to the default rather than returning ``None`` (which the caller
# would turn back into the unresolvable bare spec).
import os

from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

api_key = creds.get("api_key") or os.environ.get("ORCAROUTER_API_KEY")
base_url = creds.get("base_url")
if not base_url:
base_url = ORCAROUTER_DEFAULT_BASE_URL
else:
from urllib.parse import urlparse

if urlparse(base_url).scheme not in ("http", "https"):
logger.warning(
"DB-configured base_url for provider %r is not a valid "
"http(s) URL (%r); using the OrcaRouter default endpoint.",
provider_key,
base_url,
)
base_url = ORCAROUTER_DEFAULT_BASE_URL
return OpenAIChatModel(
model_name,
provider=OpenAIProvider(api_key=api_key, base_url=base_url),
)

api_key = creds.get("api_key")
base_url = creds.get("base_url")

Expand Down Expand Up @@ -361,7 +398,7 @@ def build_agent_model(spec: str) -> Any:
env_spec = f"openai-responses:{model_name}" if responses_api else spec

creds = _get_db_credentials(provider_key)
if not creds:
if not creds and provider_key != "orcarouter":
return env_spec

try:
Expand Down
58 changes: 58 additions & 0 deletions opencontractserver/pipeline/llm_providers/orcarouter_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""OrcaRouter provider for pydantic-ai model routing."""

from __future__ import annotations

from dataclasses import dataclass
from typing import ClassVar

from opencontractserver.pipeline.base.llm_provider import (
BaseLLMProvider,
llm_api_key_field,
llm_base_url_field,
)

#: OrcaRouter's OpenAI-compatible endpoint. The gateway routes each request
#: to the best model for the job (``orcarouter/auto``) or to a specific model
#: (e.g. ``deepseek/deepseek-v4-pro``).
ORCAROUTER_DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1"


class OrcaRouterProvider(BaseLLMProvider):
"""OrcaRouter — an OpenAI-compatible model routing gateway.

OrcaRouter (https://www.orcarouter.ai) fronts dozens of hosted models
behind one OpenAI-compatible endpoint, so ``orcarouter:`` model specs
reuse the exact same pydantic-ai / OpenAI client path as the built-in
OpenAI provider. API credentials and endpoint are configurable live in
System Settings; when unset they fall back to ``ORCAROUTER_API_KEY`` in
the process environment and the OrcaRouter default endpoint.
"""

title: str = "OrcaRouter"
description: str = (
"OrcaRouter is an OpenAI-compatible model routing gateway "
"(https://www.orcarouter.ai). It routes every request to the best "
"model for the job — pick a router alias like orcarouter/auto or a "
"specific hosted model. API credentials and endpoint are configurable "
"live in System Settings; when unset they fall back to "
"ORCAROUTER_API_KEY and the OrcaRouter default endpoint."
)
author: str = "OrcaRouter"

@dataclass
class Settings:
api_key: str = llm_api_key_field("ORCAROUTER_API_KEY")
base_url: str = llm_base_url_field(default=ORCAROUTER_DEFAULT_BASE_URL)

provider_key: ClassVar[str] = "orcarouter"
supported_models: ClassVar[tuple[str, ...]] = (
"orcarouter/auto",
"openai/gpt-5.5",
"google/gemini-3.5-flash",
"anthropic/claude-opus-4.8",
"grok/grok-4.3",
"deepseek/deepseek-v4-pro",
"minimax/minimax-m2.7",
"qwen/qwen3.7-max",
)
requires_api_key: ClassVar[bool] = True
67 changes: 67 additions & 0 deletions opencontractserver/tests/test_llm_model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import os
from unittest import mock

from asgiref.sync import async_to_sync
Expand Down Expand Up @@ -121,6 +122,72 @@ def test_unknown_provider_returns_string(self):
)


class TestOrcaRouterProvider(TestCase):
"""OrcaRouter is an OpenAI-compatible gateway pydantic-ai has no native
prefix for, so the model factory must ALWAYS build a concrete model (never
the bare ``orcarouter:`` string, which would raise "Unknown model")."""

def setUp(self):
reset_registry()
self.addCleanup(reset_registry)
PipelineSettings.clear_cache()
self.addCleanup(PipelineSettings.clear_cache)
# Isolate from the environment: the env fallback reads ORCAROUTER_API_KEY.
self._env = mock.patch.dict(
os.environ, {"ORCAROUTER_API_KEY": "sk-orca-test"}, clear=False
)
self._env.start()
self.addCleanup(self._env.stop)

def test_no_db_creds_still_builds_concrete_model(self):
"""With no DB creds, build_agent_model must NOT return the bare spec."""
result = build_agent_model("orcarouter:orcarouter/auto")
self.assertIsInstance(result, Model)
# It is an OpenAI-compatible chat model pointed at the OrcaRouter base.
from pydantic_ai.models.openai import OpenAIChatModel

self.assertIsInstance(result, OpenAIChatModel)
self.assertEqual(result.provider.base_url, "https://api.orcarouter.ai/v1/")
self.assertEqual(result.provider.client.api_key, "sk-orca-test")

def test_db_base_url_wins_over_default(self):
"""A DB-configured base_url overrides the OrcaRouter default."""
orcarouter_defn = get_llm_provider_by_key_cached("orcarouter")
assert orcarouter_defn is not None
instance = PipelineSettings.get_instance()
instance.component_settings = {
orcarouter_defn.class_name: {"base_url": "http://gateway.local/v1"}
}
instance.save()
result = build_agent_model("orcarouter:orcarouter/auto")
self.assertIsInstance(result, Model)
self.assertEqual(result.provider.base_url, "http://gateway.local/v1/")

def test_db_api_key_wins_over_env(self):
"""A DB-configured api_key overrides the ORCAROUTER_API_KEY env var."""
orcarouter_defn = get_llm_provider_by_key_cached("orcarouter")
assert orcarouter_defn is not None
instance = PipelineSettings.get_instance()
instance.set_secrets({orcarouter_defn.class_name: {"api_key": "sk-orca-db"}})
instance.save()
result = build_agent_model("orcarouter:orcarouter/auto")
self.assertIsInstance(result, Model)
self.assertEqual(result.provider.client.api_key, "sk-orca-db")

def test_invalid_db_base_url_falls_back_to_default(self):
"""A malformed DB base_url degrades to the OrcaRouter default endpoint."""
orcarouter_defn = get_llm_provider_by_key_cached("orcarouter")
assert orcarouter_defn is not None
instance = PipelineSettings.get_instance()
instance.component_settings = {
orcarouter_defn.class_name: {"base_url": "not-a-url"}
}
instance.save()
result = build_agent_model("orcarouter:orcarouter/auto")
self.assertIsInstance(result, Model)
self.assertEqual(result.provider.base_url, "https://api.orcarouter.ai/v1/")


class TestBuildAgentModelDbWins(TestCase):
"""DB-configured credentials are threaded into model construction."""

Expand Down
15 changes: 15 additions & 0 deletions opencontractserver/tests/test_llm_runtime_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ def test_registry_discovers_shipped_providers(self):
self.assertIn("anthropic", provider_keys)
self.assertIn("google-gla", provider_keys)
self.assertIn("ollama", provider_keys)
self.assertIn("orcarouter", provider_keys)

def test_orcarouter_provider_metadata(self):
orcarouter = get_llm_provider_by_key_cached("orcarouter")
assert orcarouter is not None
self.assertEqual(orcarouter.component_type, ComponentType.LLM_PROVIDER)
self.assertEqual(orcarouter.title, "OrcaRouter")
self.assertTrue(orcarouter.requires_api_key)
self.assertIn("orcarouter/auto", orcarouter.supported_models)
# The default endpoint is surfaced in the settings schema for the UI.
schema = {entry["name"]: entry for entry in orcarouter.settings_schema}
self.assertIn("api_key", schema)
self.assertEqual(schema["api_key"]["env_var"], "ORCAROUTER_API_KEY")
self.assertIn("base_url", schema)
self.assertEqual(schema["base_url"]["default"], "https://api.orcarouter.ai/v1")

def test_provider_metadata_round_trips(self):
anthropic = get_llm_provider_by_key_cached("anthropic")
Expand Down
Loading