From 1e8250483969702264fd3e470cce1fb931fd1d1c Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Wed, 12 Aug 2026 04:29:11 +0800 Subject: [PATCH] feat(providers): add OrcaRouter provider Add a named orcarouter provider for the OrcaRouter model-routing gateway (https://api.orcarouter.ai/v1, auth via ORCAROUTER_API_KEY), mirroring the existing openai provider shape. Registers it in the SKILLSPECTOR_PROVIDER selector, ships bundled token-budget metadata for the orcarouter/auto default, and documents it in README.md and .env.example. Signed-off-by: XiaoHuo888 --- .env.example | 8 +- README.md | 10 ++- src/skillspector/providers/__init__.py | 7 +- .../providers/orcarouter/__init__.py | 20 +++++ .../providers/orcarouter/model_registry.yaml | 18 +++++ .../providers/orcarouter/provider.py | 79 +++++++++++++++++++ tests/unit/test_providers.py | 45 +++++++++++ 7 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/skillspector/providers/orcarouter/__init__.py create mode 100644 src/skillspector/providers/orcarouter/model_registry.yaml create mode 100644 src/skillspector/providers/orcarouter/provider.py diff --git a/.env.example b/.env.example index db03085a0..91b550b18 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ ENV=dev # options: dev|s # Active LLM provider. Selects which provider answers credentials, # metadata, and default-model lookups. Leave unset to default to nv_build. -# Options: openai | anthropic | anthropic_proxy | nv_build +# Options: openai | anthropic | anthropic_proxy | orcarouter | nv_build SKILLSPECTOR_PROVIDER= # Provider credentials — set the one matching SKILLSPECTOR_PROVIDER (or @@ -24,6 +24,12 @@ SKILLSPECTOR_REASONING_EFFORT= # For SKILLSPECTOR_PROVIDER=anthropic. ANTHROPIC_API_KEY= +# For SKILLSPECTOR_PROVIDER=orcarouter (OrcaRouter model-routing gateway, +# OpenAI-compatible). Leave ORCAROUTER_BASE_URL unset to use the default +# api.orcarouter.ai endpoint. +ORCAROUTER_API_KEY= +# ORCAROUTER_BASE_URL=https://api.orcarouter.ai/v1 # optional override + # For SKILLSPECTOR_PROVIDER=anthropic_proxy (Vertex-style raw-predict proxy). # Supports corporate API gateways, GCP Vertex AI, and self-hosted proxies. ANTHROPIC_PROXY_ENDPOINT_URL= diff --git a/README.md b/README.md index 6de01bb08..6ce3363cf 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ inference gateways. | `anthropic_proxy` | `ANTHROPIC_PROXY_API_KEY` + `ANTHROPIC_PROXY_ENDPOINT_URL` | Any Vertex-style raw-predict proxy | `claude-sonnet-4-6` | | `bedrock` | `AWS_PROFILE` (optional) + `AWS_REGION` — SigV4 via boto3 | AWS Bedrock Runtime | `us.anthropic.claude-sonnet-4-6-20250915-v1:0` | | `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `deepseek-ai/deepseek-v4-flash` | +| `orcarouter` | `ORCAROUTER_API_KEY` | api.orcarouter.ai (OpenAI-compatible model-routing gateway) | `orcarouter/auto` | | `claude_cli` | _(none — uses local CLI auth)_ | local `claude` binary | local Claude runtime fallback, or `SKILLSPECTOR_MODEL` | | `codex_cli` | _(none — uses local CLI auth)_ | local `codex` binary | local Codex runtime fallback, or `SKILLSPECTOR_MODEL` | @@ -272,6 +273,11 @@ export SKILLSPECTOR_PROVIDER=nv_build export NVIDIA_INFERENCE_KEY=nvapi-... skillspector scan ./my-skill/ +# OrcaRouter — model-routing gateway (OpenAI-compatible) +export SKILLSPECTOR_PROVIDER=orcarouter +export ORCAROUTER_API_KEY=sk-orca-... +skillspector scan ./my-skill/ + # Local Claude CLI — no API key; uses your existing `claude auth login` session # Requires: claude CLI installed and authenticated (claude auth login) export SKILLSPECTOR_PROVIDER=claude_cli @@ -574,13 +580,15 @@ Issues (2) | Variable | Description | Required | |----------|-------------|----------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `claude_cli`, `codex_cli`, or `gemini_cli`. Hosted providers use bundled `model_registry.yaml` defaults; `claude_cli` and `codex_cli` fall back to the local CLI runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | +| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `orcarouter`, `claude_cli`, `codex_cli`, or `gemini_cli`. Hosted providers use bundled `model_registry.yaml` defaults; `claude_cli` and `codex_cli` fall back to the local CLI runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | | `NVIDIA_INFERENCE_KEY` | Credential for the `nv_build` provider (build.nvidia.com). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=nv_build` | | `OPENAI_API_KEY` | Credential for the OpenAI provider (`SKILLSPECTOR_PROVIDER=openai`). Also serves as the tier-2 fallback in the credential waterfall when the active provider returns no credentials. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=openai` | | `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional | | `SKILLSPECTOR_REASONING_EFFORT` | Optional provider- and model-dependent reasoning-effort setting. Non-empty values are trimmed and passed through unchanged; unset or blank preserves provider-default behavior. | Optional | | `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` | | `ANTHROPIC_BASE_URL` | Override the native Anthropic endpoint (default: `https://api.anthropic.com`). | Optional | +| `ORCAROUTER_API_KEY` | Credential for the OrcaRouter provider (`SKILLSPECTOR_PROVIDER=orcarouter`). | Required when `SKILLSPECTOR_PROVIDER=orcarouter` | +| `ORCAROUTER_BASE_URL` | Override the OrcaRouter endpoint (default: `https://api.orcarouter.ai/v1`). | Optional | | `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | | `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | | `ANTHROPIC_PROXY_API_VERSION` | `anthropic_version` value sent in the request body (default: `vertex-2023-10-16`). | Optional | diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index f380fda17..8d11abf39 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -27,6 +27,7 @@ anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy) bedrock → BedrockProvider (AWS Bedrock Runtime, SigV4) nv_build → NvBuildProvider (build.nvidia.com) + orcarouter → OrcaRouterProvider (api.orcarouter.ai) claude_cli → ClaudeCLIProvider (local ``claude`` binary, no API key) codex_cli → CodexCLIProvider (local ``codex`` binary, no API key) gemini_cli → GeminiCLIProvider (local ``gemini`` binary, no API key) @@ -120,6 +121,10 @@ def _select_active_provider() -> LLMProvider: return BedrockProvider() if name == "nv_build": return NvBuildProvider() + if name == "orcarouter": + from .orcarouter import OrcaRouterProvider + + return OrcaRouterProvider() if name == "claude_cli": from .claude_cli import ClaudeCLIProvider @@ -149,7 +154,7 @@ def _select_active_provider() -> LLMProvider: raise ValueError( f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " "Expected one of: openai, anthropic, anthropic_proxy, bedrock, nv_build, " - "claude_cli, codex_cli, gemini_cli, antigravity_cli (or unset)." + "orcarouter, claude_cli, codex_cli, gemini_cli, antigravity_cli (or unset)." ) diff --git a/src/skillspector/providers/orcarouter/__init__.py b/src/skillspector/providers/orcarouter/__init__.py new file mode 100644 index 000000000..d80dc0ca7 --- /dev/null +++ b/src/skillspector/providers/orcarouter/__init__.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OrcaRouter provider package (api.orcarouter.ai OpenAI-compatible gateway).""" + +from .provider import ORCAROUTER_BASE_URL, REGISTRY_PATH, OrcaRouterProvider + +__all__ = ["ORCAROUTER_BASE_URL", "REGISTRY_PATH", "OrcaRouterProvider"] diff --git a/src/skillspector/providers/orcarouter/model_registry.yaml b/src/skillspector/providers/orcarouter/model_registry.yaml new file mode 100644 index 000000000..d0c0c9afc --- /dev/null +++ b/src/skillspector/providers/orcarouter/model_registry.yaml @@ -0,0 +1,18 @@ +# Token-budget metadata for the OrcaRouter provider (api.orcarouter.ai). +# +# ``orcarouter/auto`` routes each request to the best available frontier +# model for the task, so the exact backend context window varies. The +# budgets below mirror the conservative Claude-class defaults already +# shipped in this package; tune with SKILLSPECTOR_MODEL_REGISTRY if your +# routing target needs more headroom. +# +# Format: +# models: +# "": +# context_length: # total context window in tokens (required) +# max_output_tokens: # model's max output cap (optional) + +models: + "orcarouter/auto": + context_length: 200000 + max_output_tokens: 64000 diff --git a/src/skillspector/providers/orcarouter/provider.py b/src/skillspector/providers/orcarouter/provider.py new file mode 100644 index 000000000..23e4d2266 --- /dev/null +++ b/src/skillspector/providers/orcarouter/provider.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OrcaRouter provider — model-routing gateway via api.orcarouter.ai. + +Reads ``ORCAROUTER_API_KEY`` for credentials and serves the OpenAI-compatible +``https://api.orcarouter.ai/v1`` endpoint (optionally overridden with +``ORCAROUTER_BASE_URL``). The ``orcarouter/auto`` model routes each request +to the best available frontier model for the task, so no per-model API key +or model id is required. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from langchain_core.language_models.chat_models import BaseChatModel + +from skillspector.providers import registry +from skillspector.providers.chat_models import create_openai_compatible_chat_model + +# Default endpoint; overridden by ``ORCAROUTER_BASE_URL`` when set. +ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1" + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +class OrcaRouterProvider: + """OrcaRouter credentials + bundled-YAML metadata provider.""" + + DEFAULT_MODEL = "orcarouter/auto" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return ``(api_key, base_url)`` from ``ORCAROUTER_API_KEY`` / ``ORCAROUTER_BASE_URL``.""" + api_key = os.environ.get("ORCAROUTER_API_KEY", "").strip() + if not api_key: + return None + base_url = os.environ.get("ORCAROUTER_BASE_URL", "").strip() or ORCAROUTER_BASE_URL + return api_key, base_url + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatOpenAI`` for the OrcaRouter OpenAI-compatible endpoint.""" + return create_openai_compatible_chat_model( + model=model, + credentials=self.resolve_credentials(), + max_tokens=max_tokens, + timeout=timeout, + ) + + def get_context_length(self, model: str) -> int | None: + return registry.lookup_context_length(REGISTRY_PATH, model) + + def get_max_output_tokens(self, model: str) -> int | None: + return registry.lookup_max_output_tokens(REGISTRY_PATH, model) + + def resolve_model(self, slot: str = "default") -> str: + """Resolve model: ``SKILLSPECTOR_MODEL`` env > slot default > ``DEFAULT_MODEL``.""" + user_input = os.environ.get("SKILLSPECTOR_MODEL", "").strip() + return user_input or self.SLOT_DEFAULTS.get(slot, "") or self.DEFAULT_MODEL diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 0db796ada..379180aff 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -53,6 +53,7 @@ from skillspector.providers.gemini_cli import GeminiCLIProvider from skillspector.providers.nv_build import BUILD_BASE_URL, NvBuildProvider from skillspector.providers.openai import OpenAIProvider +from skillspector.providers.orcarouter import ORCAROUTER_BASE_URL, OrcaRouterProvider try: from skillspector.providers.nv_inference import ( @@ -116,6 +117,8 @@ def _clean_provider_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_PROJECT_ID", raising=False) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + monkeypatch.delenv("ORCAROUTER_BASE_URL", raising=False) monkeypatch.delenv("SKILLSPECTOR_REASONING_EFFORT", raising=False) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) @@ -309,6 +312,41 @@ def test_metadata_known_model(self) -> None: assert provider.get_max_output_tokens("gpt-5.4") == 128_000 +class TestOrcaRouterProvider: + """OrcaRouter provider — model-routing gateway credentials + YAML metadata.""" + + def test_returns_none_without_env_var(self) -> None: + assert OrcaRouterProvider().resolve_credentials() is None + + def test_resolves_to_orcarouter_default_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + creds = OrcaRouterProvider().resolve_credentials() + assert creds == ("sk-orca-test", ORCAROUTER_BASE_URL) + + def test_honors_orcarouter_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + monkeypatch.setenv("ORCAROUTER_BASE_URL", "https://router.example.com/v1") + creds = OrcaRouterProvider().resolve_credentials() + assert creds == ("sk-orca-test", "https://router.example.com/v1") + + def test_creates_chat_openai(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + llm = OrcaRouterProvider().create_chat_model("orcarouter/auto", max_tokens=123) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "orcarouter/auto" + assert llm.max_tokens == 123 + + def test_default_model(self) -> None: + assert OrcaRouterProvider().resolve_model() == "orcarouter/auto" + # All slots inherit DEFAULT_MODEL — orcarouter/auto everywhere. + assert OrcaRouterProvider().resolve_model("meta_analyzer") == "orcarouter/auto" + + def test_metadata_known_model(self) -> None: + provider = OrcaRouterProvider() + assert provider.get_context_length("orcarouter/auto") == 200_000 + assert provider.get_max_output_tokens("orcarouter/auto") == 64_000 + + class TestAnthropicProvider: """Anthropic provider — Claude credentials + bundled YAML metadata.""" @@ -570,6 +608,13 @@ def test_select_anthropic(self, monkeypatch: pytest.MonkeyPatch) -> None: assert creds == ("sk-ant-x", None) assert isinstance(get_metadata_provider(), AnthropicProvider) + def test_select_orcarouter(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "orcarouter") + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-x") + creds = resolve_provider_credentials() + assert creds == ("sk-orca-x", ORCAROUTER_BASE_URL) + assert isinstance(get_metadata_provider(), OrcaRouterProvider) + def test_create_chat_model_uses_native_anthropic_when_configured( self, monkeypatch: pytest.MonkeyPatch ) -> None: