diff --git a/changelog.d/orcarouter-provider.added.md b/changelog.d/orcarouter-provider.added.md new file mode 100644 index 000000000..a54422d96 --- /dev/null +++ b/changelog.d/orcarouter-provider.added.md @@ -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. diff --git a/docs/architecture/llms/README.md b/docs/architecture/llms/README.md index 09305c511..1ae2939f1 100644 --- a/docs/architecture/llms/README.md +++ b/docs/architecture/llms/README.md @@ -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. @@ -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()`. diff --git a/docs/sample_env_files/backend/production/django.env b/docs/sample_env_files/backend/production/django.env index dfdada3fd..bf84aa74c 100644 --- a/docs/sample_env_files/backend/production/django.env +++ b/docs/sample_env_files/backend/production/django.env @@ -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 diff --git a/opencontractserver/llms/model_factory.py b/opencontractserver/llms/model_factory.py index 63f76b883..8259bd4fd 100644 --- a/opencontractserver/llms/model_factory.py +++ b/opencontractserver/llms/model_factory.py @@ -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__) @@ -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") @@ -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: diff --git a/opencontractserver/pipeline/llm_providers/orcarouter_provider.py b/opencontractserver/pipeline/llm_providers/orcarouter_provider.py new file mode 100644 index 000000000..6553d4be0 --- /dev/null +++ b/opencontractserver/pipeline/llm_providers/orcarouter_provider.py @@ -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 diff --git a/opencontractserver/tests/test_llm_model_factory.py b/opencontractserver/tests/test_llm_model_factory.py index d244d2f80..ef7dcad6a 100644 --- a/opencontractserver/tests/test_llm_model_factory.py +++ b/opencontractserver/tests/test_llm_model_factory.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os from unittest import mock from asgiref.sync import async_to_sync @@ -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.""" diff --git a/opencontractserver/tests/test_llm_runtime_config.py b/opencontractserver/tests/test_llm_runtime_config.py index a4438cbc4..b21e09a9f 100644 --- a/opencontractserver/tests/test_llm_runtime_config.py +++ b/opencontractserver/tests/test_llm_runtime_config.py @@ -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")