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
79 changes: 79 additions & 0 deletions docs/api/orcarouter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# OrcaRouter Cyber Inference — BYOK provider

OrcaRouter Cyber Inference is a named, OpenAI-compatible provider for security
teams. It is a *user-owned* credential path like every other AXGuard provider:
nothing is sent to OrcaRouter unless you select it explicitly and set a key.

- Provider page: [https://www.orcarouter.ai/cyber](https://www.orcarouter.ai/cyber)
- Cyber model catalog: [https://www.orcarouter.ai/models?tier=cyber](https://www.orcarouter.ai/models?tier=cyber)
- API base: `https://api.orcarouter.ai/v1`

It is aimed at teams doing **authorized, scoped** security work: red-team and
adversary-style analysis with cyber-tuned models on the red side, and the same
endpoint's gateway controls on the blue side (pre-ship review, triage, fix
verification). General-purpose gateways often reject or silently downgrade this
kind of request; this provider lets verified security teams route it to the
cyber tier. Requests outside that scope still work through the general layer.

## Configure (local API / CLI)

```bash
export AXGUARD_AI_MODE=user_key
export AXGUARD_AI_PROVIDER=orcarouter
export AXGUARD_AI_API_KEY=sk-orca-… # never logged; masked in /v1/health
# optional:
# export AXGUARD_AI_MODEL=orcarouter/auto # default
# export AXGUARD_AI_BASE_URL=https://api.orcarouter.ai/v1 # explicit override
```

Or in `.axguard.yml` for the GitHub bot (key still comes from the environment):

```yaml
ai:
provider: orcarouter
mode: user_key
model: orcarouter/auto
```

## Models

| Role | Model id | Notes |
|---|---|---|
| Default | `orcarouter/auto` | Auto-routed; works with any OrcaRouter key |
| Recommended for security teams | `orcacyber-ultra-1.0` (OrcaCyber Ultra) | Security code scanning, large-codebase multi-step reasoning; needs business verification |
| Recommended for security teams | `orcacyber-zero-1.5` / `orcacyber-zero-1.0` (OrcaCyber Zero) | OrcaCyber Zero harness models; needs business verification |
| General fallback | `orcarouter/fusion` | Non-cyber workloads |

The provider's model list is live: `GET {base_url}/models` with your key, then
filtered per capability (see `engines/api/providers/catalog.py`). Ids keep their
`vendor/model` namespace. If the catalog fetch fails, `/v1/models` reports
`"degraded": true` with `"catalog_source": "seed"` and only the small verified
cold-start set (`engines/api/providers/orcarouter.py`); seed entries are never
mixed into a successful live result.

```bash
axguard api models --capability chat # text dropdown
axguard api models --capability chat --modality image # multimodal understanding
axguard api models --check # non-zero if degraded
curl 'http://127.0.0.1:8787/v1/models?capability=chat'
```

Capability filters (metadata only, never model names):

| `capability` | Rule |
|---|---|
| `chat` (default) | `supported_endpoint_types` includes `openai`, `anthropic`, `gemini` or `openai-response` |
| `chat&modality=image\|audio\|video` | chat model whose `architecture.input_modalities` declares the modality (undeclared = excluded) |
| `embedding` | `embeddings` endpoint |
| `image` | `image-generation` endpoint |
| `video` | `openai-video` endpoint |
| `rerank` | `jina-rerank` endpoint |

The local API keeps the key server-side; `/v1/models` and `/v1/health` return
metadata only.

## Errors

401/403 from the catalog → `/v1/models` degrades to the verified seed and reports
the error text (key redacted). A rejected completion raises
`LLM provider HTTP 401/403: …` with any `sk-orca-…`/`Bearer …` token scrubbed.
2 changes: 1 addition & 1 deletion docs/api/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ axguard api start
# → http://127.0.0.1:8787
```

See [quickstart](quickstart.md), [authentication](authentication.md), [providers](providers.md), [security](security.md), [mcp-contracts](mcp-contracts.md).
See [quickstart](quickstart.md), [authentication](authentication.md), [providers](providers.md), [OrcaRouter Cyber Inference](orcarouter.md), [security](security.md), [mcp-contracts](mcp-contracts.md).
11 changes: 11 additions & 0 deletions docs/api/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ AXGuard has **no hosted model**.
| `AXGUARD_AI_MODEL` | Model id |

`enrichment=llm` returns **422** unless a real local/BYOK provider is configured. `NoneProvider` is valid for no-llm (`available=True`) but still rejected for enrichment.

## Named provider: OrcaRouter Cyber Inference

`AXGUARD_AI_PROVIDER=orcarouter` selects the named OrcaRouter Cyber Inference
provider ([OrcaRouter](https://www.orcarouter.ai), base
`https://api.orcarouter.ai/v1`, key prefix `sk-orca-`). Default model
`orcarouter/auto`; cyber-tier models (`orcacyber-ultra-1.0`,
`orcacyber-zero-1.5`) for verified security teams; `orcarouter/fusion` as the
general fallback. The model list is discovered live per capability
(`axguard api models`, `GET /v1/models`). Full guide:
[orcarouter.md](orcarouter.md).
24 changes: 23 additions & 1 deletion docs/github/ai-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Related: [privacy.md](privacy.md) · [config.md](config.md).
| `user_key` | Customer-provided cloud key via `AXGUARD_AI_API_KEY` |

`ai.provider` selects the adapter (`none`, `local`, `ollama`, `openai`,
`anthropic`, `openrouter`, …). Keys are never read from the analyzed repo.
`anthropic`, `openrouter`, `orcarouter`, …). Keys are never read from the analyzed repo.

---

Expand Down Expand Up @@ -46,6 +46,28 @@ export AXGUARD_AI_BASE_URL=http://127.0.0.1:11434

---

## Example — OrcaRouter Cyber Inference

```yaml
ai:
provider: orcarouter
mode: user_key
# model: orcarouter/auto (default)
```

```bash
export AXGUARD_AI_API_KEY=sk-orca-…
```

Named provider for security teams ([OrcaRouter](https://www.orcarouter.ai/cyber),
base `https://api.orcarouter.ai/v1`). Default model `orcarouter/auto`; cyber-tier
models `orcacyber-ultra-1.0` / `orcacyber-zero-1.5` for verified security teams;
`orcarouter/fusion` as general fallback. Catalog:
[cyber tier](https://www.orcarouter.ai/models?tier=cyber) ·
[local API guide](../api/orcarouter.md).

---

## Example — user-owned cloud key

```yaml
Expand Down
47 changes: 47 additions & 0 deletions engines/api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ def add_api_parser(sub: argparse._SubParsersAction) -> None:
start.add_argument("--reload", action="store_true", help="Dev reload (uvicorn)")

api_sub.add_parser("status", help="Show API settings and provider status")
models = api_sub.add_parser(
"models", help="List the capability-filtered model catalog for the configured provider"
)
models.add_argument(
"--capability",
default="chat",
choices=["chat", "multimodal", "embedding", "image", "video", "rerank"],
)
models.add_argument(
"--modality",
default=None,
choices=["image", "audio", "video"],
help="Narrow chat/multimodal models to declared input modalities",
)
models.add_argument(
"--check",
action="store_true",
help="Exit non-zero unless a live (non-degraded) catalog was returned",
)
api_sub.add_parser("projects", help="List local projects in the API DB")

keys = api_sub.add_parser("keys", help="Manage local API keys")
Expand Down Expand Up @@ -95,6 +114,34 @@ def run_api_command(args: argparse.Namespace) -> int:
)
return 0

if cmd == "models":
provider = get_provider(settings=settings)
catalog_fn = getattr(provider, "catalog", None)
if not callable(catalog_fn):
print(
json.dumps(
{
"error": "catalog_unavailable",
"provider": getattr(provider, "provider_name", "none"),
"hint": "Set AXGUARD_AI_PROVIDER=orcarouter and AXGUARD_AI_API_KEY=sk-orca-…",
},
indent=2,
)
)
return 1
try:
result = catalog_fn(
capability=getattr(args, "capability", "chat"),
modality=getattr(args, "modality", None),
)
except ValueError as exc:
print(json.dumps({"error": "validation_error", "message": str(exc)}), file=sys.stderr)
return 2
print(json.dumps(result, indent=2, default=str))
if getattr(args, "check", False) and result.get("degraded"):
return 1
return 0

if cmd == "projects":
store = Store(settings.db_path)
try:
Expand Down
186 changes: 186 additions & 0 deletions engines/api/providers/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Model catalog and capability filters for OpenAI-compatible providers.

The catalog is the provider's own ``GET {base_url}/models`` listing. Model ids
are kept verbatim (``vendor/model`` namespaces included) and capabilities are
read from directory metadata only — never guessed from model names.

Endpoints are filtered per capability:

- ``chat``: models whose ``supported_endpoint_types`` include one of
``openai`` / ``anthropic`` / ``gemini`` / ``openai-response``
(or, when the listing carries no endpoint metadata, models without a
non-text generation endpoint — fail closed for image/video/rerank-only).
- ``embedding``: ``embeddings`` endpoint type.
- ``image``: ``image-generation`` endpoint type.
- ``video``: ``openai-video`` endpoint type.
- ``rerank``: ``jina-rerank`` endpoint type.

Multimodal understanding is not an endpoint type: it is a *chat* model whose
``architecture.input_modalities`` explicitly lists the attachment modality.
Models that do not declare a modality are excluded (fail closed).
"""

from __future__ import annotations

import json
import urllib.error
import urllib.request
from typing import Any, Iterable

CATALOG_TIMEOUT = 60.0

CHAT_ENDPOINT_TYPES: frozenset[str] = frozenset(
{"openai", "anthropic", "gemini", "openai-response"}
)
NON_TEXT_ONLY_ENDPOINT_TYPES: frozenset[str] = frozenset(
{"image-generation", "openai-video", "jina-rerank", "embeddings", "moderations", "audio"}
)
CAPABILITY_ENDPOINT_TYPES: dict[str, frozenset[str]] = {
"embedding": frozenset({"embeddings"}),
"image": frozenset({"image-generation"}),
"video": frozenset({"openai-video"}),
"rerank": frozenset({"jina-rerank"}),
}
KNOWN_CAPABILITIES: tuple[str, ...] = ("chat", "multimodal", "embedding", "image", "video", "rerank")
INPUT_MODALITIES: frozenset[str] = frozenset({"image", "audio", "video"})


class CatalogError(RuntimeError):
"""Catalog fetch failed (auth, network or malformed payload)."""

def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status


def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
return [v.strip().lower() for v in value.split(",") if v.strip()]
if isinstance(value, (list, tuple, set)):
out: list[str] = []
for item in value:
if isinstance(item, dict):
# Directory lists may carry structured entries; the endpoint
# itself is named by ``type``.
item = item.get("type") or item.get("name") or ""
text = str(item).strip().lower()
if text:
out.append(text)
return out
return []


def normalize_model(entry: dict[str, Any]) -> dict[str, Any]:
"""Reduce a catalog entry to the metadata the filters need."""
model_id = str(entry.get("id") or "").strip()
endpoint_types = _string_list(entry.get("supported_endpoint_types"))
architecture = entry.get("architecture")
if not isinstance(architecture, dict):
architecture = {}
modalities = _string_list(architecture.get("input_modalities"))
return {
"id": model_id,
"name": str(entry.get("name") or model_id),
"supported_endpoint_types": endpoint_types,
"input_modalities": modalities,
"context_length": entry.get("context_length") or entry.get("context_window"),
"reasoning": isinstance(entry.get("supported_parameters"), list)
and "reasoning" in [str(p).lower() for p in entry["supported_parameters"]],
}


def is_chat_model(model: dict[str, Any]) -> bool:
endpoints = set(model.get("supported_endpoint_types") or [])
if endpoints & CHAT_ENDPOINT_TYPES:
return True
if endpoints & NON_TEXT_ONLY_ENDPOINT_TYPES:
return False
# No text endpoint declared: fail closed unless the entry has no endpoint
# metadata at all (older/proxied catalogs omit it).
return not endpoints


def _endpoint_matches(model: dict[str, Any], endpoint_types: frozenset[str], *, fallback: bool) -> bool:
endpoints = set(model.get("supported_endpoint_types") or [])
if endpoints:
return bool(endpoints & endpoint_types)
# No endpoint metadata: only fall back for chat, and only when nothing
# marks the model as a non-text generator.
return fallback and not (endpoints & NON_TEXT_ONLY_ENDPOINT_TYPES)


def supports_modality(model: dict[str, Any], modality: str) -> bool:
modality = modality.strip().lower()
if modality not in INPUT_MODALITIES:
raise ValueError(f"unsupported modality: {modality}")
return modality in (model.get("input_modalities") or []) and is_chat_model(model)


def filter_models(
models: Iterable[dict[str, Any]],
*,
capability: str | None = None,
modality: str | None = None,
) -> list[dict[str, Any]]:
"""Return models compatible with the requested capability.

``capability`` must be one of ``KNOWN_CAPABILITIES``. ``modality`` narrows a
``chat``/``multimodal`` request to models that declare that input modality.
"""
cap = (capability or "chat").strip().lower()
if cap not in KNOWN_CAPABILITIES:
raise ValueError(f"unknown capability: {cap}")
if modality is not None and cap not in {"chat", "multimodal"}:
raise ValueError("modality only applies to chat/multimodal")

out: list[dict[str, Any]] = []
for model in models:
if not model.get("id"):
continue
if cap in {"chat", "multimodal"}:
if not is_chat_model(model):
continue
if modality is not None and not supports_modality(model, modality):
continue
elif not _endpoint_matches(model, CAPABILITY_ENDPOINT_TYPES[cap], fallback=False):
continue
out.append(model)
return out


def parse_catalog(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, dict):
data = payload.get("data")
if data is None:
data = payload.get("models")
else:
data = payload
if not isinstance(data, list):
raise CatalogError("model catalog payload is not a list")
return [normalize_model(e) for e in data if isinstance(e, dict)]


def fetch_catalog(base_url: str, api_key: str | None, *, timeout: float = CATALOG_TIMEOUT) -> list[dict[str, Any]]:
"""Fetch ``GET {base_url}/models`` with a Bearer key. Key never leaves this process."""
url = f"{base_url.rstrip('/')}/models"
headers = {"Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
if exc.code in {401, 403}:
raise CatalogError(
"model catalog rejected the credential (401/403) — check the OrcaRouter API key",
status=exc.code,
) from None
raise CatalogError(f"model catalog HTTP {exc.code}", status=exc.code) from None
except urllib.error.URLError as exc:
raise CatalogError(f"model catalog unreachable: {exc.reason}") from None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
raise CatalogError("model catalog returned non-JSON") from None
return parse_catalog(payload)
Loading