feat: OpenRouter as a second provider with admin provider management #39
@@ -1,2 +1,3 @@
|
|||||||
# Copy to .env and fill in. Never commit the real .env file.
|
# Copy to .env and fill in. Never commit the real .env file.
|
||||||
NEURALWATT_API_KEY=
|
NEURALWATT_API_KEY=
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|||||||
23
.omo/evidence/task-3-multi-provider-openrouter.json
Normal file
23
.omo/evidence/task-3-multi-provider-openrouter.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"verification": {
|
||||||
|
"pytest": "tests/test_admin_frontend.py -x: 12 passed",
|
||||||
|
"full_suite": "1270 passed, 2 failed, 1 warning (failures in test_chat_completions.py::llm-router prefix stripping, unrelated to this task)",
|
||||||
|
"lsp_diagnostics": "skipped (basedpyright not installed)",
|
||||||
|
"ruff": "skipped (ruff not installed)",
|
||||||
|
"files_changed": [
|
||||||
|
"admin/frontend/providers.html",
|
||||||
|
"admin/frontend/index.html",
|
||||||
|
"admin/frontend/models.html",
|
||||||
|
"admin/frontend/profiles.html",
|
||||||
|
"admin/frontend/decisions.html",
|
||||||
|
"admin/frontend/controls.html"
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
"Created admin/frontend/providers.html with GET list, add form, inline edit toggle, and delete UI backed by /admin/api/providers endpoints.",
|
||||||
|
"Booleans rendered as Bootstrap form-switch toggles (not bare checkboxes), matching controls.html switch pattern.",
|
||||||
|
"All interpolated DOM strings pass through escapeHtml(); saves/deletes call POST/DELETE /admin/api/providers/{name} and show restart-required banner on success.",
|
||||||
|
"Added 'Providers' nav link to all existing admin/frontend/*.html pages; current page marked active.",
|
||||||
|
"Backend /admin/providers route was already wired in src/admin.py (task 2)."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.omo/evidence/task-7-multi-provider-openrouter.json
Normal file
22
.omo/evidence/task-7-multi-provider-openrouter.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"task": 7,
|
||||||
|
"title": "Make telemetry optional per-provider and replace global account-refusal tracking with per-provider tracking",
|
||||||
|
"file": "src/dispatcher.py",
|
||||||
|
"changes": [
|
||||||
|
"extract_telemetry({}) now defends against non-dict payloads and returns Telemetry() with all None fields",
|
||||||
|
"_sniff_telemetry_line(\": OPENROUTER PROCESSING\") returns None by rejecting any SSE comment whose first word is not 'energy' or 'cost'",
|
||||||
|
"Replaced module-level _last_account_refusal with _provider_refusal_since: dict[str, float]",
|
||||||
|
"Renamed _record_account_refusal() to _record_provider_refusal(provider: str) and keyed by provider",
|
||||||
|
"Classifier cascade refusal check now consults _provider_refusal_since.get(cfg.dispatch_settings.default_provider) with cfg.classifier.cooldown_seconds",
|
||||||
|
"Non-streaming and streaming dispatch refusal paths call _record_provider_refusal(provider)",
|
||||||
|
"SSE :energy / :cost sniffing skipped when cfg.dispatch_providers[provider].has_energy_telemetry is false",
|
||||||
|
"Added module _self_check() verifying extract_telemetry({}) and _sniff_telemetry_line behavior at import time"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"py_compile": "passed",
|
||||||
|
"extract_telemetry_empty": "passed",
|
||||||
|
"sniff_openrouter_keepalive": "passed",
|
||||||
|
"targeted_tests": "expected failure: tests/test_classifier_cascade.py and tests/test_local_dispatch_fallback.py reference removed _last_account_refusal/_record_account_refusal symbols; task 10 will migrate fixtures"
|
||||||
|
},
|
||||||
|
"notes": "Refusal status-code predicate (401/402/403/429) unchanged. Cross-provider runner-up failover not implemented. SSE response contract to clients unchanged."
|
||||||
|
}
|
||||||
39
.omo/evidence/task-9-multi-provider-openrouter.json
Normal file
39
.omo/evidence/task-9-multi-provider-openrouter.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"task": "checkbox 9 from .omo/plans/multi-provider-openrouter.md",
|
||||||
|
"description": "Verify poller and multi-provider backend test coverage is complete per plan checkbox 9; add missing in-process tiering test.",
|
||||||
|
"files_read": [
|
||||||
|
".omo/plans/multi-provider-openrouter.md",
|
||||||
|
"tests/test_openrouter_poller.py",
|
||||||
|
"tests/test_multi_provider_poller.py",
|
||||||
|
"src/poller.py"
|
||||||
|
],
|
||||||
|
"files_modified": ["tests/test_multi_provider_poller.py"],
|
||||||
|
"verification": {
|
||||||
|
"pytest": "PYTHONPATH=src python -m pytest tests/test_openrouter_poller.py tests/test_multi_provider_poller.py tests/test_poller_freshness.py -x --tb=short",
|
||||||
|
"results": "30 passed in 0.35s",
|
||||||
|
"coverage": "src/poller.py 199 stmts, 12 miss, 94% cover"
|
||||||
|
},
|
||||||
|
"coverage_checklist": {
|
||||||
|
"tests/test_openrouter_poller.py": {
|
||||||
|
"fetch_openrouter row construction": "covered by test_fetch_openrouter_returns_model_rows",
|
||||||
|
"virtual-router exclusion": "covered by test_parse_openrouter_model_excludes_virtual_routers and test_fetch_openrouter_excludes_virtual_routers",
|
||||||
|
":batch -> latency_class='flex'": "covered by test_parse_openrouter_model_batch_variant_maps_to_flex and test_fetch_openrouter_returns_model_rows",
|
||||||
|
":free/base -> 'standard'": "covered by test_parse_openrouter_model_free_variant_maps_to_standard and test_parse_openrouter_model_populates_required_fields",
|
||||||
|
"malformed pricing handling": "covered by test_parse_openrouter_model_gracefully_handles_malformed_prices and test_parse_openrouter_model_marks_missing_prompt_price_as_tbd",
|
||||||
|
"request exception propagation": "covered by test_fetch_openrouter_propagates_request_exception"
|
||||||
|
},
|
||||||
|
"tests/test_multi_provider_poller.py": {
|
||||||
|
"two mocked providers both upsert": "covered by test_two_providers_both_upsert",
|
||||||
|
"one provider RequestException doesn't abort the other": "covered by test_one_provider_fails_other_succeeds",
|
||||||
|
"disabled provider skip": "covered by test_disabled_provider_skipped",
|
||||||
|
"zero-row provider skip": "covered by test_zero_row_provider_logged_as_skip",
|
||||||
|
"per-provider mark_stale scoping": "covered by test_per_provider_mark_stale_scoped and test_per_provider_mark_stale_scopes_to_provider",
|
||||||
|
"unknown provider key skip": "covered by test_unknown_provider_key_skipped",
|
||||||
|
"in-process tiering": "covered by test_in_process_tiering_runs_after_upsert (added)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"findings": [
|
||||||
|
"All required scenarios for test_openrouter_poller.py were already present.",
|
||||||
|
"All required scenarios for test_multi_provider_poller.py were present except for an explicit assertion that in-process tiering assigns a non-null tier after a successful poll; added test_in_process_tiering_runs_after_upsert to close the gap."
|
||||||
|
]
|
||||||
|
}
|
||||||
100
.omo/notepads/multi-provider-openrouter/learnings.md
Normal file
100
.omo/notepads/multi-provider-openrouter/learnings.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# multi-provider-openrouter learnings
|
||||||
|
|
||||||
|
## Task 1: Extend DispatchProvider config model for OpenRouter
|
||||||
|
|
||||||
|
- `DispatchProvider` in `src/config.py` now carries `has_energy_telemetry: bool = False` and `enabled: bool = True`.
|
||||||
|
- Config is strict (`extra="forbid"`) — adding new keys to `dispatch_providers` entries without matching model fields would fail at load.
|
||||||
|
- `config/config.yaml` keeps `neuralwatt` enabled with energy telemetry on, and adds an `openrouter` provider entry without telemetry.
|
||||||
|
- `.env.example` now lists both `NEURALWATT_API_KEY=` and `OPENROUTER_API_KEY=`.
|
||||||
|
- Full suite passes (1251 tests). Verification script exits 0.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 5: Multi-provider main loop in poller
|
||||||
|
|
||||||
|
- Refactored `poller.main()` to iterate over `cfg.dispatch_providers.items()` instead of always polling `dispatch_settings.default_provider`.
|
||||||
|
- `main()` now skips disabled providers, dispatches `neuralwatt` rows via `fetch_neuralwatt()` and `openrouter` rows via `fetch_openrouter()`, and logs/skips unknown provider keys.
|
||||||
|
- Per-provider fetch failures (including `CatalogTooSmall` and zero-row catalogs) are caught and logged; other enabled providers continue unaffected.
|
||||||
|
- `mark_stale()` gained an optional `provider=` kwarg that scopes the staleness update with `AND provider = ?`; calling without `provider` retains the historical global behavior, preserving any external callers/tests.
|
||||||
|
- After all providers are processed, `main()` calls `tier.apply_tiering(conn, cfg)` so freshly polled rows are immediately routable without a separate `tier` run.
|
||||||
|
- Existing freshness tests in `tests/test_poller_freshness.py` were updated to isolate the `neuralwatt` provider under test and to expect provider-skip semantics rather than process abort on fetch error/zero rows.
|
||||||
|
- Added `tests/test_multi_provider_poller.py` with tests for: two providers both upserting, failure isolation (one fails, other succeeds), disabled provider skip, zero-row provider skip log, per-provider `mark_stale` scoping, unknown provider key skip, and backward-compatible global `mark_stale`.
|
||||||
|
- Verification: `python -m py_compile src/poller.py tests/test_multi_provider_poller.py` passes; targeted pytest suite passes (64 tests).
|
||||||
|
|
||||||
|
|
||||||
|
## Task 6: Fix passthrough pin dispatch to resolve owning provider from catalog
|
||||||
|
|
||||||
|
- Added `_resolve_pinned_provider(model_id)` to `src/dispatcher.py` that queries `models` for active rows across configured providers, preferring `cfg.dispatch_settings.default_provider` when the same `model_id` exists on multiple providers.
|
||||||
|
- Updated alias stripping to handle opencode's `llm-router/<vendor>/<model>` prefix before falling back to `rsplit("/", 1)[-1]`; this preserves full OpenRouter ids like `openai/gpt-6-astra` instead of mangling them to `gpt-6-astra`.
|
||||||
|
- Passthrough branch now uses `_resolve_pinned_provider(requested) or cfg.dispatch_settings.default_provider` and passes the resolved provider to `_check_pinned_capabilities(...)`.
|
||||||
|
- Added three tests in `tests/test_chat_completions.py` covering OpenRouter-only pin dispatch, `llm-router/openai/gpt-6-astra` prefix handling, and Neuralwatt-only pins still using the default provider.
|
||||||
|
- Full suite remains green; changed path verified by `tests/test_chat_completions.py`.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 8: Admin provider CRUD tests
|
||||||
|
|
||||||
|
- Expanded `tests/test_admin_providers.py` from 3 to 8 tests using isolated temporary config directories.
|
||||||
|
- DELETE `/admin/api/providers/neuralwatt` (the current `dispatch_settings.default_provider`) returns 422 and leaves both `config.yaml` and `config.local.yaml` untouched.
|
||||||
|
- DELETE `/admin/api/providers/openrouter` (base-configured, non-default provider) returns 403 with a detail naming `config/config.yaml` and read-only provenance.
|
||||||
|
- DELETE `/admin/api/providers/nonexistent` returns 404 and does not modify `config.yaml`.
|
||||||
|
- POST `/admin/api/providers/testprov` writes only to `config.local.yaml`, leaves `config.yaml` unchanged, and creates a `config.local.yaml.bak.<ts>` backup.
|
||||||
|
- GET `/admin/api/providers` returns the merged view, including both base providers (`neuralwatt`, `openrouter`) and an overlay provider (`overlayprov`).
|
||||||
|
- Verification: `PYTHONPATH=src python -m pytest tests/test_admin_providers.py -x --tb=short` passes (8 tests, ~2 s). No real provider APIs are called because the tests target the admin router's in-memory config handling only.
|
||||||
|
|
||||||
|
|
||||||
|
- Added `fetch_openrouter(provider)` and `parse_openrouter_model(raw_model)` to `src/poller.py`.
|
||||||
|
- Virtual routers (`openrouter/auto`, `openrouter/auto-beta`, `openrouter/free`, `openrouter/fusion`, `openrouter/pareto-code`, `openrouter/bodybuilder`) are dropped before becoming rows.
|
||||||
|
- Variant suffixes are preserved; `:batch` maps to `latency_class='flex'`, other variants and base rows to `'standard'`.
|
||||||
|
- Catalog `id` is used as `model_id`; `canonical_slug` is used as `base_model_id`.
|
||||||
|
- Per-token USD strings from `pricing.prompt` and `pricing.completion` are converted to cost per 1M tokens.
|
||||||
|
- `pricing.web_search` and `pricing.cache_read` differentials are intentionally omitted from catalog cost estimates.
|
||||||
|
- Added `tests/test_openrouter_poller.py` with 15 mocked tests covering row construction, variant handling, virtual-router exclusion, malformed prices, and request-exception propagation.
|
||||||
|
- Full suite passes (1266 tests).
|
||||||
|
|
||||||
|
|
||||||
|
## Task 9: Verify poller and multi-provider backend test coverage
|
||||||
|
|
||||||
|
- Confirmed `tests/test_openrouter_poller.py` already covers: `fetch_openrouter()` row construction, virtual-router exclusion, `:batch` -> `latency_class='flex'`, `:free`/base -> `'standard'`, malformed pricing handling, and request exception propagation.
|
||||||
|
- Confirmed `tests/test_multi_provider_poller.py` already covers: two mocked providers both upsert, failure isolation, disabled provider skip, zero-row provider skip, per-provider `mark_stale()` scoping, unknown provider key skip, and backward-compatible global `mark_stale()`.
|
||||||
|
- Added `test_in_process_tiering_runs_after_upsert` to explicitly verify that `main()` calls in-process tiering so OpenRouter rows receive a non-null `tier` immediately after the poll.
|
||||||
|
- Targeted verification passes: `PYTHONPATH=src python -m pytest tests/test_openrouter_poller.py tests/test_multi_provider_poller.py tests/test_poller_freshness.py -x --tb=short` -> 30 passed.
|
||||||
|
- poller.py coverage on these files: 199 stmts, 12 miss, 94%.
|
||||||
|
- Evidence recorded in `.omo/evidence/task-9-multi-provider-openrouter.json`.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 7: Per-provider telemetry and refusal isolation
|
||||||
|
|
||||||
|
- `extract_telemetry({})` now safely returns a `Telemetry()` instance with all `None` fields; added an explicit non-dict guard so a malformed payload does not crash downstream logging.
|
||||||
|
- `_sniff_telemetry_line(": OPENROUTER PROCESSING")` returns `None` because only comments whose first word is exactly `"energy"` or `"cost"` are treated as telemetry.
|
||||||
|
- Replaced module-level `_last_account_refusal: float` with `_provider_refusal_since: dict[str, float]`.
|
||||||
|
- Renamed `_record_account_refusal()` to `_record_provider_refusal(provider)` and keyed the timestamp by provider.
|
||||||
|
- Updated the classifier cascade refusal check (around line 654) to consult `_provider_refusal_since.get(cfg.dispatch_settings.default_provider)` using `cfg.classifier.cooldown_seconds`.
|
||||||
|
- Updated non-streaming and streaming dispatch refusal triggers to call `_record_provider_refusal(provider)`.
|
||||||
|
- SSE `: energy` / `: cost` sniffing is now gated by `cfg.dispatch_providers[provider].has_energy_telemetry`; when false, telemetry comments are dropped rather than proxied.
|
||||||
|
- Added a module `_self_check()` that runs at import time to lock `extract_telemetry({})` and `_sniff_telemetry_line` behavior.
|
||||||
|
- Targeted tests `tests/test_classifier_cascade.py` and `tests/test_local_dispatch_fallback.py` fail as expected because they still reference the removed `_last_account_refusal` / `_record_account_refusal` symbols; task 10 will migrate those fixtures.
|
||||||
|
- Verification: `python -m py_compile src/dispatcher.py` passes; `PYTHONPATH=src python -c "from dispatcher import extract_telemetry; t = extract_telemetry({}); assert t.energy_kwh is None"` passes.
|
||||||
|
- Evidence recorded in `.omo/evidence/task-7-multi-provider-openrouter.json`.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 10: migrate classifier cascade tests to per-provider refusal symbols
|
||||||
|
|
||||||
|
- Updated `tests/test_classifier_cascade.py` `_clean_state` fixture: replaced the removed `_last_account_refusal` monkeypatch with `dispatcher._provider_refusal_since.clear()` (reset both before and after each test).
|
||||||
|
- Renamed `test_account_refusal_skips_the_cloud_step` to `test_provider_refusal_skips_the_cloud_step` and replaced `_record_account_refusal()` with `_record_provider_refusal("neuralwatt")`.
|
||||||
|
- Verified `tests/test_local_dispatch_fallback.py` contains no references to `_last_account_refusal` / `_record_account_refusal`; the existing tests continue to pass because `_provider_refusal_since` is scoped per provider and the helper unit tests exercise `_account_level_refusal()` semantics unchanged.
|
||||||
|
- Added `tests/test_multi_provider_dispatch.py` with two focused tests:
|
||||||
|
- `test_openrouter_streaming_ignores_keepalive_comments_and_logs_null_telemetry`: a streamed OpenRouter response containing `: OPENROUTER PROCESSING` keeps the comment in the proxied body, does not crash, and writes an `energy_observations` row with `energy_kwh` and `cost_usd` NULL.
|
||||||
|
- `test_provider_refusal_is_isolated_between_providers`: recording a refusal on `neuralwatt` skips the cloud classifier when `default_provider=neuralwatt`, but does not skip it when `default_provider=openrouter`.
|
||||||
|
- Targeted run: `PYTHONPATH=src python -m pytest tests/test_classifier_cascade.py tests/test_local_dispatch_fallback.py tests/test_multi_provider_dispatch.py -x --tb=short` → 39 passed.
|
||||||
|
- Full suite: `PYTHONPATH=src python -m pytest --tb=short` → 1288 passed, zero failures.
|
||||||
|
- Evidence recorded in `.omo/evidence/task-10-multi-provider-openrouter.json`.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 11: Final verification — Playwright smoke and full test suite
|
||||||
|
|
||||||
|
- Threw away uvicorn on port 8081 (never touched port 8080 / systemd). Verified `/admin/providers` and `/admin/api/providers` return 200 before driving the UI.
|
||||||
|
- Playwright (Chromium, headless) smoke executed at 1400px and 800px.
|
||||||
|
- Passed: page renders, Providers navbar link visible, base providers `neuralwatt` and `openrouter` displayed, create provider `smoketest` appears in the list, XSS payload `<script>alert('xss')</script>` is escaped in the DOM, and 30s idle produced zero console errors.
|
||||||
|
- **Failed / defect found:** newly-created overlay providers render with source badge `unknown` and no edit/delete buttons because `GET /admin/api/providers` omits `in_base`/`in_overlay` provenance fields. The UI (`providers.html`) keys editability off `in_overlay`, so the toggle and delete flow cannot be exercised via the real page. Root cause: `src/admin.py:admin_providers_list()` returns only `name`, `base_url`, `api_key_env`, `has_energy_telemetry`, `enabled`; it does not include the provenance flags returned for profiles.
|
||||||
|
- Full pytest suite passes: 1288 passed, 0 failed. Command: `PYTHONPATH=src python -m pytest --tb=short`.
|
||||||
|
- Overlay cleanup: restored `config/config.local.yaml` to its pre-test state (`local_energy` block only); backup files created by `_persist_to` remain in `config/` but are gitignored; `git status` is clean.
|
||||||
|
- Evidence, screenshots (1400/800), and console log captured in `.omo/evidence/task-11-multi-provider-openrouter.json`.
|
||||||
@@ -59,6 +59,14 @@ body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|||||||
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
||||||
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
||||||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body>. Drop the generated-at timestamp — the widest, least
|
||||||
|
critical element — below 900px; nav labels are never truncated. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
.navbar-brand a{text-decoration:none!important;color:inherit}
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
||||||
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
||||||
@@ -192,6 +200,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|||||||
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
<li class="nav-item active"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
<li class="nav-item active"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|||||||
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
||||||
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
||||||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body>. Drop the generated-at timestamp — the widest, least
|
||||||
|
critical element — below 900px; nav labels are never truncated. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
.navbar-brand a{text-decoration:none!important;color:inherit}
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
||||||
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
||||||
@@ -175,6 +183,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|||||||
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
||||||
<li class="nav-item active"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
<li class="nav-item active"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|||||||
Bootstrap's --bs-gutter-x default, so the brand's left edge is exactly
|
Bootstrap's --bs-gutter-x default, so the brand's left edge is exactly
|
||||||
20px from the viewport edge regardless of container/breakpoint math. ── */
|
20px from the viewport edge regardless of container/breakpoint math. ── */
|
||||||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body> (F3's 868px finding on /admin/). Drop the generated-at
|
||||||
|
timestamp — the widest, least critical element — below 900px; nav labels
|
||||||
|
are never truncated. The timestamp still shows at wider widths. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
/* ── Rounder hero type: brand wordmark + page titles get the friendlier
|
/* ── Rounder hero type: brand wordmark + page titles get the friendlier
|
||||||
geometric font; body copy stays on Tabler's default for readability. ── */
|
geometric font; body copy stays on Tabler's default for readability. ── */
|
||||||
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
@@ -281,6 +290,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|||||||
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|||||||
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
||||||
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
||||||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body>. Drop the generated-at timestamp — the widest, least
|
||||||
|
critical element — below 900px; nav labels are never truncated. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
.navbar-brand a{text-decoration:none!important;color:inherit}
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
||||||
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
||||||
@@ -160,6 +168,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|||||||
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|||||||
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
|
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
|
||||||
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
||||||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body>. Drop the generated-at timestamp — the widest, least
|
||||||
|
critical element — below 900px; nav labels are never truncated. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
.navbar-brand a{text-decoration:none!important;color:inherit}
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
||||||
/* ── Brand mascot sizing and filter guard. ── */
|
/* ── Brand mascot sizing and filter guard. ── */
|
||||||
@@ -148,6 +156,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|||||||
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
680
admin/frontend/providers.html
Normal file
680
admin/frontend/providers.html
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Providers · LLM Router Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2219%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2211%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22119%22%20cy%3D%22226%22%20r%3D%2211%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ── Liquid glass: soft gradient wash behind everything so the blur has
|
||||||
|
something to catch, then translucent+blurred navbar/cards on top. Kept
|
||||||
|
subtle — this is a working dashboard, not a marketing page. ── */
|
||||||
|
body{
|
||||||
|
background:
|
||||||
|
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
|
||||||
|
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
|
||||||
|
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
|
||||||
|
#111827;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
header.navbar{
|
||||||
|
background: rgba(31,41,55,.6)!important;
|
||||||
|
backdrop-filter: blur(16px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1030;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
background: rgba(31,41,55,.55);
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
border: 1px solid rgba(255,255,255,.07);
|
||||||
|
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
|
||||||
|
}
|
||||||
|
.card-header{border-bottom-color: rgba(255,255,255,.06)}
|
||||||
|
/* ── Scroll context: body scrolls, html does not. ── */
|
||||||
|
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
|
||||||
|
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
||||||
|
/* ── Buttons: glass pills instead of flat saturated fills. ── */
|
||||||
|
.btn-success{background:rgba(34,197,94,.16)!important;border-color:rgba(74,222,128,.45)!important;color:#4ade80!important}
|
||||||
|
.btn-success:hover{background:rgba(34,197,94,.28)!important;border-color:rgba(74,222,128,.7)!important;color:#86efac!important}
|
||||||
|
.btn-danger{background:rgba(239,68,68,.16)!important;border-color:rgba(248,113,113,.45)!important;color:#f87171!important}
|
||||||
|
.btn-danger:hover{background:rgba(239,68,68,.28)!important;border-color:rgba(248,113,113,.7)!important;color:#fca5a5!important}
|
||||||
|
.btn-primary{background:rgba(59,130,246,.16)!important;border-color:rgba(96,165,250,.45)!important;color:#93c5fd!important}
|
||||||
|
.btn-primary:hover{background:rgba(59,130,246,.28)!important;border-color:rgba(96,165,250,.7)!important;color:#bfdbfe!important}
|
||||||
|
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
|
||||||
|
/* ── Badges: same glass treatment. ── */
|
||||||
|
.badge.bg-success{background:rgba(34,197,94,.20)!important;color:#4ade80!important}
|
||||||
|
.badge.bg-warning{background:rgba(245,158,11,.20)!important;color:#fbbf24!important}
|
||||||
|
.badge.bg-danger{background:rgba(239,68,68,.20)!important;color:#f87171!important}
|
||||||
|
.badge.bg-info{background:rgba(59,130,246,.20)!important;color:#93c5fd!important}
|
||||||
|
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
|
||||||
|
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
||||||
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||||||
|
/* ── Topbar vs narrow viewports: brand + six nav links + right meta (live
|
||||||
|
badge, warnings bell, generated-at) roughly exceed one nowrap flex row
|
||||||
|
around 800px, so the fixed-width right cluster slides past the right edge
|
||||||
|
and scrolls <body>. Drop the generated-at timestamp — the widest, least
|
||||||
|
critical element — below 900px; nav labels are never truncated. ── */
|
||||||
|
@media (max-width: 899.98px){
|
||||||
|
#generated-at{display:none}
|
||||||
|
}
|
||||||
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
||||||
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
||||||
|
/* ── Brand mascot sizing and filter guard. ── */
|
||||||
|
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
|
||||||
|
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
|
||||||
|
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
||||||
|
/* ── Speed lines. ── */
|
||||||
|
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
|
||||||
|
.brand-six{color:#f59e0b;font-size:1.14em;line-height:1}
|
||||||
|
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
|
||||||
|
.brand-speedlines svg{filter:none!important}
|
||||||
|
@keyframes brand-dash{
|
||||||
|
0%{transform:translateX(-5px);opacity:.2}
|
||||||
|
55%{opacity:1}
|
||||||
|
100%{transform:translateX(4px);opacity:0}
|
||||||
|
}
|
||||||
|
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
|
||||||
|
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
|
||||||
|
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
.navbar-brand a:hover .brand-speedlines line{animation:none}
|
||||||
|
}
|
||||||
|
/* ── Provider card specifics. ── */
|
||||||
|
.provider-empty{color:var(--tblr-secondary);text-align:center;padding:2rem 1rem}
|
||||||
|
.provider-readonly-hint{color:var(--tblr-secondary);font-size:.72rem;display:inline-flex;align-items:center;gap:4px}
|
||||||
|
.provider-field-label{color:var(--tblr-secondary);font-size:.72rem}
|
||||||
|
.provider-field-val{font-size:.78rem;word-break:break-word}
|
||||||
|
.provider-row-actions{display:flex;align-items:center;gap:6px;justify-content:flex-end}
|
||||||
|
.provider-card .card-title{font-size:1rem}
|
||||||
|
.provider-source{font-size:.72rem}
|
||||||
|
/* ── Settings rows for add/edit form: key | meta | control. ── */
|
||||||
|
.provider-form-list{margin:0 -.5rem}
|
||||||
|
.provider-form-row{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(0,1fr) auto 200px;
|
||||||
|
align-items:center;
|
||||||
|
gap:.75rem;
|
||||||
|
min-height:38px;
|
||||||
|
padding:.25rem .5rem;
|
||||||
|
border-left:2px solid transparent;
|
||||||
|
border-radius:6px;
|
||||||
|
}
|
||||||
|
.provider-form-row+.provider-form-row{border-top:1px solid rgba(255,255,255,.05)}
|
||||||
|
.provider-form-key{font-size:.82rem;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.provider-form-meta{font-size:.72rem;white-space:nowrap;color:var(--tblr-secondary);font-variant-numeric:tabular-nums}
|
||||||
|
.provider-form-control{display:flex;justify-content:flex-end;align-items:center;min-width:0}
|
||||||
|
.provider-form-control .form-control,
|
||||||
|
.provider-form-control .form-select{width:100%;font-variant-numeric:tabular-nums}
|
||||||
|
.provider-form-control .form-check{margin:0;padding:0;min-height:0;display:flex;justify-content:flex-end}
|
||||||
|
.provider-form-control .form-check-input{margin:0;float:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ═══ Navbar ═══ -->
|
||||||
|
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
|
||||||
|
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||||
|
<a href="/admin/" style="display:inline-flex;align-items:center;gap:8px">
|
||||||
|
<span class="brand-speedlines d-none d-md-flex" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 40 64" width="40" height="64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="speedfade" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="40" y2="0">
|
||||||
|
<stop offset="0" stop-color="#f59e0b" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#f59e0b" stop-opacity=".85"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g stroke="url(#speedfade)" stroke-linecap="round" fill="none">
|
||||||
|
<line x1="14" y1="20" x2="38" y2="20" stroke-width="5"/>
|
||||||
|
<line x1="2" y1="33" x2="36" y2="33" stroke-width="6"/>
|
||||||
|
<line x1="18" y1="46" x2="34" y2="46" stroke-width="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<img class="navbar-brand-image" src="6krrt-logo.webp" width="64" height="64" style="width:64px;height:64px" alt="6krrt logo" aria-hidden="true">
|
||||||
|
<span class="brand-word"><span class="brand-six">6</span>krrt LLM Router</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<div class="navbar-nav flex-row order-md-last">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span id="sse-status" class="badge bg-warning">connecting…</span>
|
||||||
|
<span id="sse-text" class="visually-hidden"></span>
|
||||||
|
<span id="generated-at" class="text-muted small">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||||
|
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
||||||
|
<li class="nav-item active"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══ Page shell ═══ -->
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">Providers</h2>
|
||||||
|
<div class="text-muted mt-1">Dispatch providers and their API endpoints</div>
|
||||||
|
<div class="text-muted mt-1" style="font-size:.75rem"><span data-icon="alert" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>Creating, editing, or deleting a provider writes to config.local.yaml. A service reload is required before dispatch sees the change.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<!-- Global restart-required banner (shown after edits/deletes) -->
|
||||||
|
<div class="row row-cards" id="restart-banner-row" style="display:none">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<span data-icon="alert" style="display:inline-flex;vertical-align:middle;margin-right:6px"></span>
|
||||||
|
A restart is required for provider changes to take effect.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cards align-items-start">
|
||||||
|
<!-- Add provider card -->
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><span class="me-2" data-icon="plus"></span>Add provider</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="provider-form-list" id="add-provider-form">
|
||||||
|
<div class="provider-form-row">
|
||||||
|
<span class="provider-form-key" title="name">name</span>
|
||||||
|
<span class="provider-form-meta">unique id</span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<input class="form-control form-control-sm" type="text" id="add-name" placeholder="e.g. openrouter" autocomplete="off">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row">
|
||||||
|
<span class="provider-form-key" title="base_url">base_url</span>
|
||||||
|
<span class="provider-form-meta">http(s) URL</span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<input class="form-control form-control-sm" type="text" id="add-base-url" placeholder="https://api.example.com/v1" autocomplete="off">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row">
|
||||||
|
<span class="provider-form-key" title="api_key_env">api_key_env</span>
|
||||||
|
<span class="provider-form-meta">env var name</span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<input class="form-control form-control-sm" type="text" id="add-api-key-env" placeholder="e.g. OPENROUTER_API_KEY" autocomplete="off">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row">
|
||||||
|
<span class="provider-form-key" title="has_energy_telemetry">has_energy_telemetry</span>
|
||||||
|
<span class="provider-form-meta">SSE kWh comments</span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input toggle-input" type="checkbox" id="add-has-energy-telemetry">
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row">
|
||||||
|
<span class="provider-form-key" title="enabled">enabled</span>
|
||||||
|
<span class="provider-form-meta">available to dispatch</span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input toggle-input" type="checkbox" id="add-enabled" checked>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-success btn-sm" id="add-provider-btn" onclick="createProvider()">Create provider</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider list -->
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><span class="me-2" data-icon="server"></span>Configured providers</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="provider-list">
|
||||||
|
<div class="provider-empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /row row-cards -->
|
||||||
|
</div><!-- /container-xl -->
|
||||||
|
</div><!-- /page-body -->
|
||||||
|
<footer class="footer footer-transparent d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
© 2026 adLee · 6krrt, local LLM model router · admin
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Delete confirm modal ═══ -->
|
||||||
|
<div class="modal modal-blur fade" id="delete-modal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Delete provider</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Delete provider <strong id="delete-modal-name"></strong>?</p>
|
||||||
|
<p class="text-muted small">This writes to config.local.yaml; a service reload is required before dispatch sees the change.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" id="delete-confirm-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast (Bootstrap native) -->
|
||||||
|
<div id="toast" class="toast align-items-center text-bg-info border-0 position-fixed bottom-0 end-0 p-3" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
admin/frontend/providers.html — provider management
|
||||||
|
API base: relative (works under /admin/)
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
const API = ''; // relative to /admin/
|
||||||
|
const SSE_URL = '/events/decisions'; // root-level endpoint
|
||||||
|
|
||||||
|
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
|
||||||
|
function icon(name, size) {
|
||||||
|
const s = size || 18;
|
||||||
|
const svgs = {
|
||||||
|
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
||||||
|
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||||||
|
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||||
|
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
|
||||||
|
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
||||||
|
server: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
|
||||||
|
default: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`,
|
||||||
|
};
|
||||||
|
return svgs[name] || svgs.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStaticIcons() {
|
||||||
|
document.querySelectorAll('[data-icon]').forEach(el => {
|
||||||
|
el.innerHTML = icon(el.dataset.icon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast (Bootstrap native via tabler wrapper) ── */
|
||||||
|
let _toastEl = null;
|
||||||
|
function toast(msg, type = 'info') {
|
||||||
|
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', warning: 'text-bg-warning', info: 'text-bg-info' }[type] || 'text-bg-info';
|
||||||
|
if (!_toastEl) {
|
||||||
|
_toastEl = document.getElementById('toast');
|
||||||
|
_toastEl.innerHTML = `<div class="d-flex">
|
||||||
|
<div class="toast-body"></div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>`;
|
||||||
|
_toastEl._bt = new tabler.Toast(_toastEl, { delay: 4000 });
|
||||||
|
}
|
||||||
|
_toastEl.querySelector('.toast-body').textContent = msg;
|
||||||
|
_toastEl.className = `toast align-items-center ${bgClass} border-0 position-fixed bottom-0 end-0 p-3`;
|
||||||
|
_toastEl._bt.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fetch wrappers ── */
|
||||||
|
async function apiFetch(url, opts = {}) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, opts);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
return await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('API call failed:', url, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetchDetail(url, opts = {}) {
|
||||||
|
const resp = await fetch(url, opts);
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
const detail = body.detail || `${resp.status} ${resp.statusText}`;
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
SSE LIVE STREAM
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
let sseConn = null;
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
try {
|
||||||
|
if (sseConn) { sseConn.close(); }
|
||||||
|
sseConn = new EventSource(SSE_URL);
|
||||||
|
|
||||||
|
const statusDot = document.getElementById('sse-status');
|
||||||
|
const statusText = document.getElementById('sse-text');
|
||||||
|
statusDot.className = 'badge bg-warning';
|
||||||
|
statusDot.textContent = 'connecting…';
|
||||||
|
statusText.textContent = 'connecting…';
|
||||||
|
|
||||||
|
sseConn.onopen = () => {
|
||||||
|
statusDot.className = 'badge bg-success';
|
||||||
|
statusDot.textContent = 'live';
|
||||||
|
statusText.textContent = 'live';
|
||||||
|
};
|
||||||
|
|
||||||
|
sseConn.onerror = () => {
|
||||||
|
statusDot.className = 'badge bg-danger';
|
||||||
|
statusDot.textContent = 'reconnecting…';
|
||||||
|
statusText.textContent = 'reconnecting…';
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('sse-status').className = 'badge bg-danger';
|
||||||
|
document.getElementById('sse-status').textContent = 'offline';
|
||||||
|
document.getElementById('sse-text').textContent = 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
PROVIDERS
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBadge(inBase, inOverlay) {
|
||||||
|
if (inBase && inOverlay) return '<span class="badge bg-purple">overlay</span>';
|
||||||
|
if (inBase) return '<span class="badge bg-secondary">base config</span>';
|
||||||
|
if (inOverlay) return '<span class="badge bg-purple">overlay</span>';
|
||||||
|
return '<span class="badge bg-secondary">unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolSwitch(id, checked, onchange, disabled) {
|
||||||
|
return `<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input toggle-input" type="checkbox" id="${id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''} onchange="${onchange}">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvider(p) {
|
||||||
|
const name = escapeHtml(String(p.name || ''));
|
||||||
|
const baseUrl = escapeHtml(String(p.base_url || ''));
|
||||||
|
const apiKeyEnv = escapeHtml(String(p.api_key_env || ''));
|
||||||
|
const hasEnergy = !!p.has_energy_telemetry;
|
||||||
|
const enabled = !!p.enabled;
|
||||||
|
const inBase = !!p.in_base;
|
||||||
|
const inOverlay = !!p.in_overlay;
|
||||||
|
const isEditable = inOverlay;
|
||||||
|
|
||||||
|
const readonlyHint = !isEditable
|
||||||
|
? `<span class="provider-readonly-hint" title="Base-config providers cannot be edited or deleted"><span data-icon="lock"></span> read-only</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const actions = !isEditable
|
||||||
|
? ''
|
||||||
|
: `<div class="provider-row-actions">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" title="Edit provider" data-action="edit" data-provider-name="${name}"><span data-icon="edit"></span></button>
|
||||||
|
<button class="btn btn-danger btn-sm" title="Delete provider" data-action="delete" data-provider-name="${name}"><span data-icon="trash"></span></button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// View mode row.
|
||||||
|
let html = `<div class="provider-form-row provider-view" data-provider="${name}">
|
||||||
|
<span class="provider-form-key" title="${name}">
|
||||||
|
${name}
|
||||||
|
<span class="provider-source ms-2">${sourceBadge(inBase, inOverlay)} ${readonlyHint}</span>
|
||||||
|
</span>
|
||||||
|
<span class="provider-form-meta">${baseUrl || '—'} · ${apiKeyEnv || '—'}</span>
|
||||||
|
<span class="provider-form-control">${actions}</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row provider-view" data-provider="${name}">
|
||||||
|
<span class="provider-form-key text-muted">
|
||||||
|
<span class="provider-field-label me-3">energy telemetry ${hasEnergy ? 'on' : 'off'}</span>
|
||||||
|
<span class="provider-field-label">enabled ${enabled ? 'yes' : 'no'}</span>
|
||||||
|
</span>
|
||||||
|
<span class="provider-form-meta"></span>
|
||||||
|
<span class="provider-form-control"></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Edit mode form (hidden by default).
|
||||||
|
html += `<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
|
||||||
|
<span class="provider-form-key"><input class="form-control form-control-sm" type="text" value="${baseUrl}" data-field="base_url" placeholder="https://api.example.com/v1"></span>
|
||||||
|
<span class="provider-form-meta">base_url</span>
|
||||||
|
<span class="provider-form-control"></span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
|
||||||
|
<span class="provider-form-key"><input class="form-control form-control-sm" type="text" value="${apiKeyEnv}" data-field="api_key_env" placeholder="env var name"></span>
|
||||||
|
<span class="provider-form-meta">api_key_env</span>
|
||||||
|
<span class="provider-form-control"></span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
|
||||||
|
<span class="provider-form-key">has_energy_telemetry</span>
|
||||||
|
<span class="provider-form-meta">SSE kWh comments</span>
|
||||||
|
<span class="provider-form-control">${boolSwitch(`edit-energy-${name}`, hasEnergy, `toggleProviderField('${name}', 'has_energy_telemetry', this.checked)`, false)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
|
||||||
|
<span class="provider-form-key">enabled</span>
|
||||||
|
<span class="provider-form-meta">available to dispatch</span>
|
||||||
|
<span class="provider-form-control">${boolSwitch(`edit-enabled-${name}`, enabled, `toggleProviderField('${name}', 'enabled', this.checked)`, false)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
|
||||||
|
<span class="provider-form-key"></span>
|
||||||
|
<span class="provider-form-meta"></span>
|
||||||
|
<span class="provider-form-control">
|
||||||
|
<button class="btn btn-success btn-sm" onclick="saveProviderEdit('${name}')">Save</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm ms-2" onclick="cancelProviderEdit('${name}')">Cancel</button>
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProviders(providers) {
|
||||||
|
const el = document.getElementById('provider-list');
|
||||||
|
if (!providers) {
|
||||||
|
el.innerHTML = '<div class="provider-empty">Could not load providers. Check the API.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!providers.length) {
|
||||||
|
el.innerHTML = '<div class="provider-empty">No providers configured.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = providers.map(renderProvider).join('');
|
||||||
|
renderStaticIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
const providers = await apiFetch(`${API}api/providers`);
|
||||||
|
renderProviders(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
CRUD ACTIONS
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
|
||||||
|
function showRestartBanner() {
|
||||||
|
const row = document.getElementById('restart-banner-row');
|
||||||
|
if (row) row.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProviderField(name, field, value) {
|
||||||
|
// Switch change is local-only until Save is pressed.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProviderEdit(name) {
|
||||||
|
const rows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
|
||||||
|
const body = { name: name };
|
||||||
|
rows.forEach(row => {
|
||||||
|
const input = row.querySelector('[data-field]');
|
||||||
|
if (input) {
|
||||||
|
const field = input.dataset.field;
|
||||||
|
body[field] = input.value;
|
||||||
|
}
|
||||||
|
const sw = row.querySelector('.form-check-input[type="checkbox"]');
|
||||||
|
if (sw && sw.id && sw.id.startsWith('edit-')) {
|
||||||
|
const field = sw.id.replace(`edit-`, '').replace(new RegExp(`-${name}$`), '');
|
||||||
|
// map switch field names to provider schema keys
|
||||||
|
const keyMap = {
|
||||||
|
energy: 'has_energy_telemetry',
|
||||||
|
enabled: 'enabled',
|
||||||
|
};
|
||||||
|
const key = keyMap[field];
|
||||||
|
if (key) body[key] = sw.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
toast(result && result.message ? 'Provider updated' : 'Provider saved', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
if (result && result.message) {
|
||||||
|
toast(result.message, 'warning');
|
||||||
|
}
|
||||||
|
await loadProviders();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Save failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelProviderEdit(name) {
|
||||||
|
const viewRows = document.querySelectorAll(`.provider-view[data-provider="${CSS.escape(name)}"]`);
|
||||||
|
const editRows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
|
||||||
|
viewRows.forEach(r => r.style.display = '');
|
||||||
|
editRows.forEach(r => r.style.display = 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProviderEdit(name) {
|
||||||
|
const viewRows = document.querySelectorAll(`.provider-view[data-provider="${CSS.escape(name)}"]`);
|
||||||
|
const editRows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
|
||||||
|
viewRows.forEach(r => r.style.display = 'none');
|
||||||
|
editRows.forEach(r => r.style.display = '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let _deleteModal = null;
|
||||||
|
function getDeleteModal() {
|
||||||
|
if (!_deleteModal) {
|
||||||
|
_deleteModal = new tabler.Modal(document.getElementById('delete-modal'));
|
||||||
|
}
|
||||||
|
return _deleteModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptDelete(name) {
|
||||||
|
document.getElementById('delete-modal-name').textContent = name;
|
||||||
|
const btn = document.getElementById('delete-confirm-btn');
|
||||||
|
btn.onclick = () => doDelete(name);
|
||||||
|
getDeleteModal().show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete(name) {
|
||||||
|
getDeleteModal().hide();
|
||||||
|
try {
|
||||||
|
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
|
toast('Provider deleted', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
if (result && result.message) {
|
||||||
|
toast(result.message, 'warning');
|
||||||
|
}
|
||||||
|
await loadProviders();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Delete failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProvider() {
|
||||||
|
const name = document.getElementById('add-name').value.trim();
|
||||||
|
const baseUrl = document.getElementById('add-base-url').value.trim();
|
||||||
|
const apiKeyEnv = document.getElementById('add-api-key-env').value.trim();
|
||||||
|
const hasEnergyTelemetry = document.getElementById('add-has-energy-telemetry').checked;
|
||||||
|
const enabled = document.getElementById('add-enabled').checked;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
toast('Provider name is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!baseUrl) {
|
||||||
|
toast('Base URL is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: name,
|
||||||
|
base_url: baseUrl,
|
||||||
|
api_key_env: apiKeyEnv || null,
|
||||||
|
has_energy_telemetry: hasEnergyTelemetry,
|
||||||
|
enabled: enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
toast('Provider created', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
if (result && result.message) {
|
||||||
|
toast(result.message, 'warning');
|
||||||
|
}
|
||||||
|
document.getElementById('add-name').value = '';
|
||||||
|
document.getElementById('add-base-url').value = '';
|
||||||
|
document.getElementById('add-api-key-env').value = '';
|
||||||
|
document.getElementById('add-has-energy-telemetry').checked = false;
|
||||||
|
document.getElementById('add-enabled').checked = true;
|
||||||
|
await loadProviders();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Create failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════
|
||||||
|
INIT
|
||||||
|
═══════════════════════════════════════ */
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
renderStaticIcons();
|
||||||
|
document.getElementById('provider-list').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const name = btn.dataset.providerName;
|
||||||
|
if (btn.dataset.action === 'edit') startProviderEdit(name);
|
||||||
|
else if (btn.dataset.action === 'delete') promptDelete(name);
|
||||||
|
});
|
||||||
|
loadProviders();
|
||||||
|
connectSSE();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -726,12 +726,17 @@ classifier:
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch_providers:
|
dispatch_providers:
|
||||||
# NeuralWatt is the only provider. The dict shape and the models table's
|
|
||||||
# (model_id, provider) key are kept so a second one can be added without a
|
|
||||||
# migration.
|
|
||||||
neuralwatt:
|
neuralwatt:
|
||||||
base_url: "https://api.neuralwatt.com/v1"
|
base_url: "https://api.neuralwatt.com/v1"
|
||||||
api_key_env: "NEURALWATT_API_KEY"
|
api_key_env: "NEURALWATT_API_KEY"
|
||||||
|
has_energy_telemetry: true
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
openrouter:
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
api_key_env: "OPENROUTER_API_KEY"
|
||||||
|
has_energy_telemetry: false
|
||||||
|
enabled: true
|
||||||
|
|
||||||
local_compute:
|
local_compute:
|
||||||
# "Gaming mode", inverted: true means the router may use local hardware.
|
# "Gaming mode", inverted: true means the router may use local hardware.
|
||||||
|
|||||||
141
src/admin.py
141
src/admin.py
@@ -30,6 +30,7 @@ from collections.abc import Callable
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Literal, Optional
|
from typing import Any, List, Literal, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -43,6 +44,7 @@ import metrics
|
|||||||
import routing
|
import routing
|
||||||
from config import (
|
from config import (
|
||||||
BUILTIN_PROFILES,
|
BUILTIN_PROFILES,
|
||||||
|
DispatchProvider,
|
||||||
FlexPreference,
|
FlexPreference,
|
||||||
RouterConfig,
|
RouterConfig,
|
||||||
RoutingProfile,
|
RoutingProfile,
|
||||||
@@ -720,6 +722,13 @@ class _ProfileUpdateBody(BaseModel):
|
|||||||
allowed_model_ids: Optional[list[str]] = None
|
allowed_model_ids: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class _ProviderUpdateBody(BaseModel):
|
||||||
|
base_url: str
|
||||||
|
api_key_env: str
|
||||||
|
has_energy_telemetry: bool
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
def _runtime_state(cfg: Any) -> dict:
|
def _runtime_state(cfg: Any) -> dict:
|
||||||
"""Read every toggle knob's current in-memory value off ``cfg``."""
|
"""Read every toggle knob's current in-memory value off ``cfg``."""
|
||||||
return {
|
return {
|
||||||
@@ -768,6 +777,7 @@ def build_router(
|
|||||||
_admin_models = _REPO_ROOT / "admin" / "frontend" / "models.html"
|
_admin_models = _REPO_ROOT / "admin" / "frontend" / "models.html"
|
||||||
_admin_decisions = _REPO_ROOT / "admin" / "frontend" / "decisions.html"
|
_admin_decisions = _REPO_ROOT / "admin" / "frontend" / "decisions.html"
|
||||||
_admin_profiles = _REPO_ROOT / "admin" / "frontend" / "profiles.html"
|
_admin_profiles = _REPO_ROOT / "admin" / "frontend" / "profiles.html"
|
||||||
|
_admin_providers = _REPO_ROOT / "admin" / "frontend" / "providers.html"
|
||||||
_admin_proficiency = _REPO_ROOT / "admin" / "frontend" / "proficiency.html"
|
_admin_proficiency = _REPO_ROOT / "admin" / "frontend" / "proficiency.html"
|
||||||
_admin_logo = _REPO_ROOT / "admin" / "frontend" / "6krrt-logo.webp"
|
_admin_logo = _REPO_ROOT / "admin" / "frontend" / "6krrt-logo.webp"
|
||||||
|
|
||||||
@@ -801,6 +811,10 @@ def build_router(
|
|||||||
def admin_profiles_page() -> FileResponse:
|
def admin_profiles_page() -> FileResponse:
|
||||||
return FileResponse(_admin_profiles, media_type="text/html", headers=_NO_CACHE_HEADERS)
|
return FileResponse(_admin_profiles, media_type="text/html", headers=_NO_CACHE_HEADERS)
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
def admin_providers_page() -> FileResponse:
|
||||||
|
return FileResponse(_admin_providers, media_type="text/html", headers=_NO_CACHE_HEADERS)
|
||||||
|
|
||||||
@router.get("/proficiency")
|
@router.get("/proficiency")
|
||||||
def admin_proficiency_page() -> FileResponse:
|
def admin_proficiency_page() -> FileResponse:
|
||||||
return FileResponse(_admin_proficiency, media_type="text/html", headers=_NO_CACHE_HEADERS)
|
return FileResponse(_admin_proficiency, media_type="text/html", headers=_NO_CACHE_HEADERS)
|
||||||
@@ -1242,6 +1256,133 @@ def build_router(
|
|||||||
"message": "A restart is required for this change to take effect",
|
"message": "A restart is required for this change to take effect",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _provider_provenance(name: str) -> dict:
|
||||||
|
base_store = load_config_store_safe(config_path) or {}
|
||||||
|
base_providers = base_store.get("dispatch_providers") or {}
|
||||||
|
try:
|
||||||
|
overlay = load_config_store(config_local_path) or {}
|
||||||
|
except FileNotFoundError:
|
||||||
|
overlay = {}
|
||||||
|
overlay_providers = overlay.get("dispatch_providers") or {}
|
||||||
|
return {
|
||||||
|
"in_base": name in base_providers,
|
||||||
|
"in_overlay": name in overlay_providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _merged_providers() -> dict[str, Any]:
|
||||||
|
merged = _load_merged_config_store(config_path, config_local_path)
|
||||||
|
return merged.get("dispatch_providers") or {}
|
||||||
|
|
||||||
|
def _provider_block_dict(body: _ProviderUpdateBody) -> dict[str, Any]:
|
||||||
|
return body.model_dump()
|
||||||
|
|
||||||
|
def _validate_provider_url(base_url: str) -> None:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"base_url must be an http(s) URL, got {base_url!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _persist_provider(
|
||||||
|
name: str, provider_dict: Optional[dict[str, Any]], *, delete: bool = False
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
_persist_to(
|
||||||
|
config_path,
|
||||||
|
config_local_path,
|
||||||
|
("dispatch_providers", name),
|
||||||
|
provider_dict,
|
||||||
|
delete=delete,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=exc.errors()[0]["msg"],
|
||||||
|
) from exc
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="provider not found") from exc
|
||||||
|
|
||||||
|
@router.get("/api/providers")
|
||||||
|
def admin_providers_list() -> list[dict[str, Any]]:
|
||||||
|
providers = _merged_providers()
|
||||||
|
result = []
|
||||||
|
for name, p in providers.items():
|
||||||
|
provenance = _provider_provenance(name)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"base_url": p["base_url"],
|
||||||
|
"api_key_env": p["api_key_env"],
|
||||||
|
"has_energy_telemetry": bool(p.get("has_energy_telemetry", False)),
|
||||||
|
"enabled": bool(p.get("enabled", True)),
|
||||||
|
"in_base": provenance["in_base"],
|
||||||
|
"in_overlay": provenance["in_overlay"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/api/providers/{name}")
|
||||||
|
def admin_provider_detail(name: str) -> dict[str, Any]:
|
||||||
|
providers = _merged_providers()
|
||||||
|
if name not in providers:
|
||||||
|
raise HTTPException(status_code=404, detail="provider not found")
|
||||||
|
p = providers[name]
|
||||||
|
provenance = _provider_provenance(name)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"base_url": p["base_url"],
|
||||||
|
"api_key_env": p["api_key_env"],
|
||||||
|
"has_energy_telemetry": bool(p.get("has_energy_telemetry", False)),
|
||||||
|
"enabled": bool(p.get("enabled", True)),
|
||||||
|
"in_base": provenance["in_base"],
|
||||||
|
"in_overlay": provenance["in_overlay"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/providers/{name}")
|
||||||
|
def admin_provider_update(name: str, body: _ProviderUpdateBody) -> dict[str, Any]:
|
||||||
|
_validate_provider_url(body.base_url)
|
||||||
|
provider_dict = _provider_block_dict(body)
|
||||||
|
try:
|
||||||
|
DispatchProvider(**provider_dict)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=exc.errors()[0]["msg"],
|
||||||
|
) from exc
|
||||||
|
_persist_provider(name, provider_dict)
|
||||||
|
return {
|
||||||
|
"provider": {"name": name, **provider_dict},
|
||||||
|
"message": "A restart is required for this change to take effect",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/api/providers/{name}")
|
||||||
|
def admin_provider_delete(name: str) -> dict[str, Any]:
|
||||||
|
if name == cfg.dispatch_settings.default_provider:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Cannot delete provider {name!r}: it is the current "
|
||||||
|
f"dispatch_settings.default_provider."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
provenance = _provider_provenance(name)
|
||||||
|
if provenance["in_base"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"provider {name!r} is defined in config/config.yaml and "
|
||||||
|
f"is read-only"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not provenance["in_overlay"]:
|
||||||
|
raise HTTPException(status_code=404, detail="provider not found")
|
||||||
|
_persist_provider(name, None, delete=True)
|
||||||
|
return {
|
||||||
|
"deleted": name,
|
||||||
|
"message": "A restart is required for this change to take effect",
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/api/models")
|
@router.get("/api/models")
|
||||||
def admin_models() -> list:
|
def admin_models() -> list:
|
||||||
"""All models with per-category proficiency, for the admin model table."""
|
"""All models with per-category proficiency, for the admin model table."""
|
||||||
|
|||||||
@@ -884,6 +884,8 @@ class LocalDispatchModel(StrictModel):
|
|||||||
class DispatchProvider(StrictModel):
|
class DispatchProvider(StrictModel):
|
||||||
base_url: str
|
base_url: str
|
||||||
api_key_env: str
|
api_key_env: str
|
||||||
|
has_energy_telemetry: bool = False
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(StrictModel):
|
class LoggingConfig(StrictModel):
|
||||||
|
|||||||
@@ -544,10 +544,9 @@ def _classifier_client() -> OpenAI:
|
|||||||
# GLOBAL across requests rather than per-request — that is what bounds cloud
|
# GLOBAL across requests rather than per-request — that is what bounds cloud
|
||||||
# spend during a sustained outage to one attempt per cooldown window.
|
# spend during a sustained outage to one attempt per cooldown window.
|
||||||
_last_classifier_failure: float = 0.0
|
_last_classifier_failure: float = 0.0
|
||||||
# Timestamp of the last account-level provider refusal. If the account is out
|
# Per-provider timestamp of the last account-level refusal. A refusal on one
|
||||||
# of credit, a cloud classification is guaranteed to fail AND to cost a
|
# provider must not poison classification or fallback decisions for another.
|
||||||
# request; skip step 4 entirely for the cooldown window.
|
_provider_refusal_since: dict[str, float] = {}
|
||||||
_last_account_refusal: float = 0.0
|
|
||||||
|
|
||||||
# The in-flight request's session key, for the two cascade steps that need it.
|
# The in-flight request's session key, for the two cascade steps that need it.
|
||||||
#
|
#
|
||||||
@@ -574,10 +573,9 @@ def _record_success_cooldown() -> None:
|
|||||||
_last_classifier_failure = 0.0
|
_last_classifier_failure = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _record_account_refusal() -> None:
|
def _record_provider_refusal(provider: str) -> None:
|
||||||
"""Note an account-level refusal so the cascade stops paying for cloud."""
|
"""Note an account-level refusal for a specific provider."""
|
||||||
global _last_account_refusal
|
_provider_refusal_since[provider] = time.time()
|
||||||
_last_account_refusal = time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def _classifier_backoff_active() -> bool:
|
def _classifier_backoff_active() -> bool:
|
||||||
@@ -930,8 +928,10 @@ def _classify_cascade(
|
|||||||
if cf is None:
|
if cf is None:
|
||||||
return None
|
return None
|
||||||
cooldown = cfg.classifier.cooldown_seconds
|
cooldown = cfg.classifier.cooldown_seconds
|
||||||
if time.time() - _last_account_refusal < cooldown:
|
if time.time() - _provider_refusal_since.get(
|
||||||
logs.warning("classify_cloud_skipped", reason="account_refusal")
|
cfg.dispatch_settings.default_provider, 0.0
|
||||||
|
) < cooldown:
|
||||||
|
logs.warning("classify_cloud_skipped", reason="provider_refusal")
|
||||||
return None
|
return None
|
||||||
client = _cloud_classifier_client(cf)
|
client = _cloud_classifier_client(cf)
|
||||||
if client is None:
|
if client is None:
|
||||||
@@ -1884,6 +1884,8 @@ def extract_telemetry(payload: dict) -> Telemetry:
|
|||||||
yields an all-None Telemetry rather than an error, and the neutral-0.5
|
yields an all-None Telemetry rather than an error, and the neutral-0.5
|
||||||
path in scoring covers the gap.
|
path in scoring covers the gap.
|
||||||
"""
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
energy = payload.get("energy") or {}
|
energy = payload.get("energy") or {}
|
||||||
cost = payload.get("cost") or {}
|
cost = payload.get("cost") or {}
|
||||||
kwh = energy.get("energy_kwh")
|
kwh = energy.get("energy_kwh")
|
||||||
@@ -3511,6 +3513,39 @@ def _check_pinned_capabilities(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_pinned_provider(model_id: str) -> Optional[str]:
|
||||||
|
"""Return the ``provider`` that owns *model_id* in the catalog.
|
||||||
|
|
||||||
|
Queries ``SELECT provider FROM models WHERE model_id = ?`` and returns the
|
||||||
|
sole result. When the same model_id lives on multiple providers
|
||||||
|
(for example *gpt-4a* on both *neuralwatt* and *openrouter*), the function
|
||||||
|
returns the configured ``default_provider`` so the resolution is
|
||||||
|
deterministic.
|
||||||
|
|
||||||
|
Returns ``None`` when *model_id* is not found — the caller falls back to
|
||||||
|
the ``default_provider`` in that case.
|
||||||
|
"""
|
||||||
|
conn = _db()
|
||||||
|
try:
|
||||||
|
providers = list(cfg.dispatch_providers)
|
||||||
|
placeholders = ",".join("?" * len(providers))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT provider FROM models WHERE model_id = ? AND provider IN ({placeholders})",
|
||||||
|
(model_id, *providers),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if len(rows) == 1:
|
||||||
|
return rows[0]["provider"]
|
||||||
|
if len(rows) > 1:
|
||||||
|
# Prefer the configured default when there's ambiguity.
|
||||||
|
default = cfg.dispatch_settings.default_provider
|
||||||
|
if any(r["provider"] == default for r in rows):
|
||||||
|
return default
|
||||||
|
return rows[0]["provider"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
def list_models():
|
def list_models():
|
||||||
"""The routable catalog, in OpenAI's list shape.
|
"""The routable catalog, in OpenAI's list shape.
|
||||||
@@ -3555,17 +3590,24 @@ def _sniff_telemetry_line(line: str) -> Optional[tuple[str, dict]]:
|
|||||||
`data: [DONE]`. They are SSE comments, so every ordinary client ignores
|
`data: [DONE]`. They are SSE comments, so every ordinary client ignores
|
||||||
them — which is exactly why the stream can be proxied through untouched
|
them — which is exactly why the stream can be proxied through untouched
|
||||||
while still being read on the way past.
|
while still being read on the way past.
|
||||||
|
|
||||||
|
Providers such as OpenRouter may emit other SSE comment lines (keepalive
|
||||||
|
comments beginning with ``: OPENROUTER``); these are deliberately ignored.
|
||||||
"""
|
"""
|
||||||
if not line.startswith(":"):
|
if not isinstance(line, str) or not line.startswith(":"):
|
||||||
return None
|
return None
|
||||||
body = line[1:].strip()
|
body = line[1:].strip()
|
||||||
for key in ("energy", "cost"):
|
if not body:
|
||||||
prefix = f"{key} "
|
return None
|
||||||
if body.startswith(prefix):
|
first_word = body.split(None, 1)[0]
|
||||||
try:
|
if first_word not in ("energy", "cost"):
|
||||||
return key, json.loads(body[len(prefix):])
|
return None
|
||||||
except json.JSONDecodeError:
|
prefix = f"{first_word} "
|
||||||
return None
|
if body.startswith(prefix):
|
||||||
|
try:
|
||||||
|
return first_word, json.loads(body[len(prefix):])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -3605,23 +3647,33 @@ def chat_completions(body: dict[str, Any], background: BackgroundTasks):
|
|||||||
|
|
||||||
requested = body.get("model") or ROUTER_MODEL
|
requested = body.get("model") or ROUTER_MODEL
|
||||||
# Some clients (opencode among them) send the model as `provider/model`,
|
# Some clients (opencode among them) send the model as `provider/model`,
|
||||||
# for a pin as much as for `auto`. Real ids can contain slashes too
|
# for a pin as much as for `auto`. Real ids can contain slashes too
|
||||||
# (`deepseek-ai/DeepSeek-V4-Flash`), so a stripped suffix is only trusted
|
# (`deepseek-ai/DeepSeek-V4-Flash`), so a stripped suffix is only trusted
|
||||||
# once it resolves to a real catalog row -- otherwise the id, slash and
|
# once it resolves to a real catalog row -- otherwise the id, slash and
|
||||||
# all, is what gets dispatched, on the chance the slash is really part of
|
# all, is what gets dispatched, on the chance the slash is really part of
|
||||||
# it. Without this, a pin sent as `llm-router/gemma-4-31b` kept its
|
# it.
|
||||||
# prefix past this point: the capability pre-check looked up a model_id
|
ALIAS_PREFIX = "llm-router/"
|
||||||
# the catalog has never heard of, found no row, and fail-closed a pin
|
if requested.startswith(ALIAS_PREFIX):
|
||||||
# that could satisfy the request into a false 422 -- and even past that
|
# opencode's pin alias: try the remainder first. This preserves full
|
||||||
# check, the same unstripped id would have gone upstream and drawn a 400
|
# OpenRouter vendor/model ids like `openai/gpt-6-astra` that the
|
||||||
# from NeuralWatt, which only knows the bare form.
|
# rsplit heuristic below would mangle to `gpt-6-astra`.
|
||||||
bare = requested.rsplit("/", 1)[-1]
|
candidate = requested[len(ALIAS_PREFIX):]
|
||||||
wants_routing = bare == ROUTER_MODEL or bare.startswith(f"{ROUTER_MODEL}:")
|
if _model_exists(candidate) or _local_dispatch_config_for(candidate) is not None:
|
||||||
if wants_routing or (
|
requested = candidate
|
||||||
bare != requested
|
else:
|
||||||
and (_model_exists(bare) or _local_dispatch_config_for(bare) is not None)
|
# Fallback to the existing suffix rule so a virtual name such as
|
||||||
):
|
# `llm-router/auto` still routes instead of being passed through.
|
||||||
requested = bare
|
bare = requested.rsplit("/", 1)[-1]
|
||||||
|
if bare == ROUTER_MODEL or bare.startswith(f"{ROUTER_MODEL}:"):
|
||||||
|
requested = bare
|
||||||
|
else:
|
||||||
|
bare = requested.rsplit("/", 1)[-1]
|
||||||
|
if bare != requested and (
|
||||||
|
_model_exists(bare) or _local_dispatch_config_for(bare) is not None
|
||||||
|
):
|
||||||
|
requested = bare
|
||||||
|
|
||||||
|
wants_routing = requested == ROUTER_MODEL or requested.startswith(f"{ROUTER_MODEL}:")
|
||||||
|
|
||||||
logs.debug(
|
logs.debug(
|
||||||
"request",
|
"request",
|
||||||
@@ -3864,7 +3916,9 @@ def chat_completions(body: dict[str, Any], background: BackgroundTasks):
|
|||||||
category = decision.classification.task_category
|
category = decision.classification.task_category
|
||||||
else:
|
else:
|
||||||
cloud_provider = cfg.dispatch_settings.default_provider
|
cloud_provider = cfg.dispatch_settings.default_provider
|
||||||
target, provider, category = requested, cloud_provider, "general_chat"
|
resolved = _resolve_pinned_provider(requested)
|
||||||
|
provider = resolved if resolved is not None else cloud_provider
|
||||||
|
target, category = requested, "general_chat"
|
||||||
if provider == cloud_provider and (
|
if provider == cloud_provider and (
|
||||||
entry := _local_dispatch_config_for(target)
|
entry := _local_dispatch_config_for(target)
|
||||||
) is not None:
|
) is not None:
|
||||||
@@ -4087,10 +4141,10 @@ def chat_completions(body: dict[str, Any], background: BackgroundTasks):
|
|||||||
cfg.circuit_breaker.backoff_multiplier,
|
cfg.circuit_breaker.backoff_multiplier,
|
||||||
)
|
)
|
||||||
if _account_level_refusal(resp.status_code):
|
if _account_level_refusal(resp.status_code):
|
||||||
# The account is refusing. A cloud CLASSIFICATION would
|
# The provider account is refusing. A cloud CLASSIFICATION
|
||||||
# fail the same way and still cost a request, so tell the
|
# would fail the same way and still cost a request, so tell
|
||||||
# cascade to skip step 3 for the cooldown window.
|
# the cascade to skip step 3 for this provider's cooldown.
|
||||||
_record_account_refusal()
|
_record_provider_refusal(provider)
|
||||||
if _account_level_refusal(resp.status_code) or not alternatives:
|
if _account_level_refusal(resp.status_code) or not alternatives:
|
||||||
# Both spec §1c triggers: every cloud candidate exhausted, or a
|
# Both spec §1c triggers: every cloud candidate exhausted, or a
|
||||||
# non-retryable account-level refusal — the remaining cloud rows
|
# non-retryable account-level refusal — the remaining cloud rows
|
||||||
@@ -4253,14 +4307,20 @@ def chat_completions(body: dict[str, Any], background: BackgroundTasks):
|
|||||||
)
|
)
|
||||||
yield f"data: {json.dumps({'error': {'message': detail}})}\n\n".encode()
|
yield f"data: {json.dumps({'error': {'message': detail}})}\n\n".encode()
|
||||||
return
|
return
|
||||||
|
provider_has_telemetry = cfg.dispatch_providers[
|
||||||
|
provider
|
||||||
|
].has_energy_telemetry
|
||||||
try:
|
try:
|
||||||
for raw in upstream.iter_lines(decode_unicode=True):
|
for raw in upstream.iter_lines(decode_unicode=True):
|
||||||
if raw is None:
|
if raw is None:
|
||||||
continue
|
continue
|
||||||
sniffed = _sniff_telemetry_line(raw)
|
if provider_has_telemetry:
|
||||||
if sniffed:
|
sniffed = _sniff_telemetry_line(raw)
|
||||||
collected[sniffed[0]] = sniffed[1]
|
if sniffed:
|
||||||
elif raw.startswith("data: ") and raw.strip() != "data: [DONE]":
|
collected[sniffed[0]] = sniffed[1]
|
||||||
|
elif _sniff_telemetry_line(raw) is not None:
|
||||||
|
continue
|
||||||
|
if raw.startswith("data: ") and raw.strip() != "data: [DONE]":
|
||||||
try:
|
try:
|
||||||
chunk = json.loads(raw[6:])
|
chunk = json.loads(raw[6:])
|
||||||
if chunk.get("id"):
|
if chunk.get("id"):
|
||||||
@@ -4399,9 +4459,9 @@ def chat_completions(body: dict[str, Any], background: BackgroundTasks):
|
|||||||
upstream_text = attempt.text[:500]
|
upstream_text = attempt.text[:500]
|
||||||
attempt.close()
|
attempt.close()
|
||||||
if _account_level_refusal(attempt.status_code):
|
if _account_level_refusal(attempt.status_code):
|
||||||
# Same reason as the streaming path: an account-level refusal
|
# Same reason as the non-streaming path: an account-level refusal
|
||||||
# makes the cloud classifier a guaranteed waste of a request.
|
# on this provider makes its cloud classifier a waste of a request.
|
||||||
_record_account_refusal()
|
_record_provider_refusal(provider)
|
||||||
break
|
break
|
||||||
if upstream is None:
|
if upstream is None:
|
||||||
fallback = try_local_fallback(target, upstream_status)
|
fallback = try_local_fallback(target, upstream_status)
|
||||||
|
|||||||
196
src/poller.py
196
src/poller.py
@@ -32,11 +32,22 @@ from typing import Optional
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from config import LocalDispatchModel, RouterConfig, load_config
|
from config import LocalDispatchModel, RouterConfig, load_config
|
||||||
|
from tier import apply_tiering
|
||||||
|
|
||||||
MODELS_URL = "https://api.neuralwatt.com/v1/models"
|
MODELS_URL = "https://api.neuralwatt.com/v1/models"
|
||||||
|
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 20 # seconds
|
REQUEST_TIMEOUT = 20 # seconds
|
||||||
|
|
||||||
|
OPENROUTER_VIRTUAL_ROUTERS = frozenset({
|
||||||
|
"openrouter/auto",
|
||||||
|
"openrouter/auto-beta",
|
||||||
|
"openrouter/free",
|
||||||
|
"openrouter/fusion",
|
||||||
|
"openrouter/pareto-code",
|
||||||
|
"openrouter/bodybuilder",
|
||||||
|
})
|
||||||
|
|
||||||
# Serving-class suffixes. NeuralWatt ships one base model as several catalog
|
# Serving-class suffixes. NeuralWatt ships one base model as several catalog
|
||||||
# rows that differ only by these tokens, and they combine freely — hence ids
|
# rows that differ only by these tokens, and they combine freely — hence ids
|
||||||
# like 'glm-5.2-short-fast-flex'. They are stripped from the end of the id one
|
# like 'glm-5.2-short-fast-flex'. They are stripped from the end of the id one
|
||||||
@@ -222,6 +233,86 @@ def fetch_neuralwatt(provider: str) -> list[ModelRow]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _openrouter_price_to_cost_per_1m(value: Optional[str]) -> Optional[float]:
|
||||||
|
"""Convert an OpenRouter per-token price string to cost per 1M tokens."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
per_token = float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return per_token * 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_openrouter_model(raw_model: dict) -> Optional[ModelRow]:
|
||||||
|
"""Normalize a single OpenRouter /v1/models entry into a ModelRow.
|
||||||
|
|
||||||
|
Returns ``None`` for virtual routers (``openrouter/*``) so callers can
|
||||||
|
drop them cleanly without losing the rest of the catalog.
|
||||||
|
"""
|
||||||
|
model_id = raw_model.get("id") or ""
|
||||||
|
if model_id in OPENROUTER_VIRTUAL_ROUTERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
canonical_slug = raw_model.get("canonical_slug") or model_id
|
||||||
|
pricing = raw_model.get("pricing", {}) or {}
|
||||||
|
top_provider = raw_model.get("top_provider", {}) or {}
|
||||||
|
architecture = raw_model.get("architecture", {}) or {}
|
||||||
|
reasoning = raw_model.get("reasoning", {}) or {}
|
||||||
|
supported_parameters = raw_model.get("supported_parameters") or []
|
||||||
|
input_modalities = architecture.get("input_modalities") or []
|
||||||
|
|
||||||
|
prompt_price = _openrouter_price_to_cost_per_1m(pricing.get("prompt"))
|
||||||
|
completion_price = _openrouter_price_to_cost_per_1m(pricing.get("completion"))
|
||||||
|
|
||||||
|
variant = model_id.split(":")[-1] if ":" in model_id else "standard"
|
||||||
|
|
||||||
|
return ModelRow(
|
||||||
|
model_id=model_id,
|
||||||
|
provider="openrouter",
|
||||||
|
base_model_id=canonical_slug,
|
||||||
|
display_name=raw_model.get("name"),
|
||||||
|
cost_per_1m_prompt=prompt_price,
|
||||||
|
cost_per_1m_completion=completion_price,
|
||||||
|
cost_per_1m_prompt_cached=None,
|
||||||
|
context_window=top_provider.get("context_length")
|
||||||
|
or raw_model.get("context_length"),
|
||||||
|
max_output_tokens=top_provider.get("max_completion_tokens"),
|
||||||
|
supports_tools="tools" in supported_parameters,
|
||||||
|
supports_json_mode="response_format" in supported_parameters,
|
||||||
|
supports_vision="image" in input_modalities,
|
||||||
|
supports_reasoning="reasoning" in supported_parameters,
|
||||||
|
reasoning_default_enabled=bool(reasoning.get("default_enabled")),
|
||||||
|
latency_class="flex" if ":batch" in model_id else "standard",
|
||||||
|
reasoning_mode=reasoning.get("mode") or "none",
|
||||||
|
context_variant=variant,
|
||||||
|
access_level="public",
|
||||||
|
pricing_tbd=prompt_price is None,
|
||||||
|
deprecated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_openrouter(provider: str) -> list[ModelRow]:
|
||||||
|
"""Fetch the public OpenRouter catalog and normalize it into ModelRows.
|
||||||
|
|
||||||
|
The OpenRouter `/v1/models` endpoint is unauthenticated and paginated with
|
||||||
|
a default page size of 500 (max 1000). A single request covers the whole
|
||||||
|
catalog. ``pricing.web_search`` and ``pricing.cache_read`` differentials
|
||||||
|
are intentionally omitted from catalog cost estimates; routing uses the
|
||||||
|
prompt and completion price only.
|
||||||
|
"""
|
||||||
|
resp = requests.get(OPENROUTER_MODELS_URL, timeout=REQUEST_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
|
||||||
|
rows: list[ModelRow] = []
|
||||||
|
for raw_model in payload.get("data", []):
|
||||||
|
row = parse_openrouter_model(raw_model)
|
||||||
|
if row is not None:
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def upsert(conn: sqlite3.Connection, rows: list[ModelRow], cfg: RouterConfig) -> None:
|
def upsert(conn: sqlite3.Connection, rows: list[ModelRow], cfg: RouterConfig) -> None:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -383,55 +474,90 @@ def upsert_local_dispatch_models(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def mark_stale(conn: sqlite3.Connection, cfg: RouterConfig) -> None:
|
def mark_stale(
|
||||||
"""Flag rows that weren't touched by this poll run as stale, rather than
|
conn: sqlite3.Connection,
|
||||||
silently leaving old data looking current. Assumes the caller has already
|
cfg: RouterConfig,
|
||||||
established the fetched catalog was plausible (see the row-count floor
|
*,
|
||||||
check before upsert)."""
|
provider: Optional[str] = None,
|
||||||
conn.execute(
|
) -> None:
|
||||||
"""
|
"""Flag rows that weren't touched by this poll run as stale.
|
||||||
UPDATE models
|
|
||||||
SET availability = 'stale'
|
When *provider* is given, only rows for that provider are considered;
|
||||||
WHERE availability = 'active'
|
when absent (the historical default across the rest of this repo), all
|
||||||
AND julianday('now') - julianday(last_updated) > ?
|
rows are scoped — matching the previous global behaviour.
|
||||||
""",
|
|
||||||
(cfg.freshness.stale_after_days,),
|
Assumes the caller has already established the fetched catalog was
|
||||||
)
|
plausible (see the row-count floor check before upsert).
|
||||||
|
"""
|
||||||
|
where = "availability = 'active' AND julianday('now') - julianday(last_updated) > ?"
|
||||||
|
params: list[object] = [cfg.freshness.stale_after_days]
|
||||||
|
if provider is not None:
|
||||||
|
where += " AND provider = ?"
|
||||||
|
params.append(provider)
|
||||||
|
|
||||||
|
conn.execute(f"UPDATE models SET availability = 'stale' WHERE {where}", params)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
cfg = load_config("config/config.yaml")
|
cfg = load_config("config/config.yaml")
|
||||||
provider = cfg.dispatch_settings.default_provider
|
|
||||||
conn = sqlite3.connect(cfg.database.path)
|
conn = sqlite3.connect(cfg.database.path)
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
upsert_local_dispatch_models(conn, cfg)
|
upsert_local_dispatch_models(conn, cfg)
|
||||||
|
|
||||||
try:
|
# Map provider keys (dispatch_providers dict keys) to their fetch functions.
|
||||||
rows = fetch_neuralwatt(provider)
|
# Only entries with keys matching the key are dispatched; unknown keys
|
||||||
except requests.RequestException as e:
|
# are logged and skipped so a future provider in config doesn't abort
|
||||||
print(f"[{provider}] FAILED: {e}", file=sys.stderr)
|
# the whole run.
|
||||||
conn.close()
|
FETCHERS: dict[str, callable] = {
|
||||||
return 1
|
"neuralwatt": fetch_neuralwatt,
|
||||||
|
"openrouter": fetch_openrouter,
|
||||||
|
}
|
||||||
|
|
||||||
if len(rows) == 0:
|
total_upserted = 0
|
||||||
raise CatalogTooSmall("NeuralWatt returned 0 models — this is not a valid catalog")
|
|
||||||
current_count = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM models WHERE provider=?", (provider,)
|
|
||||||
).fetchone()[0]
|
|
||||||
if len(rows) < (current_count // 2) and current_count > 0:
|
|
||||||
warnings.warn(
|
|
||||||
f"[{provider}] fetched only {len(rows)} models "
|
|
||||||
f"(current DB has {current_count}); proceeding but catalog may be truncated"
|
|
||||||
)
|
|
||||||
|
|
||||||
upsert(conn, rows, cfg)
|
for provider, prov_cfg in cfg.dispatch_providers.items():
|
||||||
print(f"[{provider}] upserted {len(rows)} models")
|
if not prov_cfg.enabled:
|
||||||
|
print(f"[{provider}] skipped (disabled)")
|
||||||
|
continue
|
||||||
|
|
||||||
mark_stale(conn, cfg)
|
fetcher = FETCHERS.get(provider)
|
||||||
|
if fetcher is None:
|
||||||
|
print(f"[{provider}] unknown provider — skipping (no fetcher)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = fetcher(provider)
|
||||||
|
except CatalogTooSmall as e:
|
||||||
|
print(f"[{provider}] CATALOG_TOO_SMALL: {e}; skipping provider", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[{provider}] FAILED: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(rows) == 0:
|
||||||
|
print(f"[{provider}] fetched 0 models — skipping provider", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider=?", (provider,)
|
||||||
|
).fetchone()[0]
|
||||||
|
if len(rows) < (current_count // 2) and current_count > 0:
|
||||||
|
warnings.warn(
|
||||||
|
f"[{provider}] fetched only {len(rows)} models "
|
||||||
|
f"(current DB has {current_count}); proceeding but catalog may be truncated"
|
||||||
|
)
|
||||||
|
|
||||||
|
upsert(conn, rows, cfg)
|
||||||
|
total_upserted += len(rows)
|
||||||
|
print(f"[{provider}] upserted {len(rows)} models")
|
||||||
|
|
||||||
|
mark_stale(conn, cfg, provider=provider)
|
||||||
|
|
||||||
|
apply_tiering(conn, cfg)
|
||||||
conn.close()
|
conn.close()
|
||||||
print(f"done, {len(rows)} rows upserted total")
|
print(f"done, {total_upserted} rows upserted total")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
259
tests/test_admin_providers.py
Normal file
259
tests/test_admin_providers.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""Tests for the admin provider CRUD endpoints and /admin/providers page route.
|
||||||
|
|
||||||
|
The page fixture creates a temporary ``admin/frontend/providers.html`` if the
|
||||||
|
file does not yet exist, and removes it after the test so no HTML file is left
|
||||||
|
in the repo as part of this change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from admin import build_router
|
||||||
|
from config import load_config
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(tmp_path: Path) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(str(tmp_path / "providers.db"))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
"""A TestClient for an isolated admin router over a temp config.yaml copy."""
|
||||||
|
(tmp_path / "config").mkdir(parents=True, exist_ok=True)
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
shutil.copyfile(ROOT / "config" / "config.yaml", config_yaml)
|
||||||
|
|
||||||
|
schema_sql = (ROOT / "config" / "schema.sql").read_text()
|
||||||
|
conn = _make_db(tmp_path)
|
||||||
|
conn.executescript(schema_sql)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
cfg = load_config(str(config_yaml))
|
||||||
|
|
||||||
|
def _db_factory() -> sqlite3.Connection:
|
||||||
|
return _make_db(tmp_path)
|
||||||
|
|
||||||
|
router = build_router(cfg, _db_factory, base_dir=str(tmp_path))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix="/admin")
|
||||||
|
|
||||||
|
page = ROOT / "admin" / "frontend" / "providers.html"
|
||||||
|
created_page = False
|
||||||
|
if not page.exists():
|
||||||
|
page.write_text("<html><body>Providers</body></html>")
|
||||||
|
created_page = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
if created_page:
|
||||||
|
page.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_PROVIDER = {
|
||||||
|
"base_url": "https://api.example.com/v1",
|
||||||
|
"api_key_env": "EXAMPLE_API_KEY",
|
||||||
|
"has_energy_telemetry": True,
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SENTINEL = "# SENTINEL_PRESERVED_12345"
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_header_marker(config_yaml: Path) -> None:
|
||||||
|
"""Prepend a unique marker comment just above the ``logging:`` map."""
|
||||||
|
text = config_yaml.read_text()
|
||||||
|
assert "logging:" in text
|
||||||
|
config_yaml.write_text(text.replace("logging:", f"{_SENTINEL}\nlogging:", 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_providers_page_returns_html(client):
|
||||||
|
"""GET /admin/providers serves the providers HTML page."""
|
||||||
|
resp = client.get("/admin/providers")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/html")
|
||||||
|
assert "Providers" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_crud_round_trip(client, tmp_path):
|
||||||
|
"""Create, read, and delete a provider through the overlay."""
|
||||||
|
local_yaml = tmp_path / "config" / "config.local.yaml"
|
||||||
|
|
||||||
|
resp = client.post("/admin/api/providers/testprov", json=_VALID_PROVIDER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["provider"]["name"] == "testprov"
|
||||||
|
assert body["provider"]["base_url"] == _VALID_PROVIDER["base_url"]
|
||||||
|
|
||||||
|
assert local_yaml.exists()
|
||||||
|
text = local_yaml.read_text()
|
||||||
|
assert "testprov:" in text
|
||||||
|
assert "dispatch_providers:" in text
|
||||||
|
|
||||||
|
resp = client.get("/admin/api/providers/testprov")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
detail = resp.json()
|
||||||
|
assert detail["name"] == "testprov"
|
||||||
|
assert detail["base_url"] == _VALID_PROVIDER["base_url"]
|
||||||
|
assert detail["api_key_env"] == _VALID_PROVIDER["api_key_env"]
|
||||||
|
assert detail["has_energy_telemetry"] is True
|
||||||
|
assert detail["enabled"] is True
|
||||||
|
|
||||||
|
resp = client.get("/admin/api/providers")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
names = [p["name"] for p in resp.json()]
|
||||||
|
assert "testprov" in names
|
||||||
|
|
||||||
|
resp = client.delete("/admin/api/providers/testprov")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["deleted"] == "testprov"
|
||||||
|
|
||||||
|
resp = client.get("/admin/api/providers/testprov")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_post_invalid_url_returns_422(client, tmp_path):
|
||||||
|
"""A non-URL base_url is rejected without writing to the overlay."""
|
||||||
|
local_yaml = tmp_path / "config" / "config.local.yaml"
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/api/providers/badurl",
|
||||||
|
json={
|
||||||
|
"base_url": "not-a-url",
|
||||||
|
"api_key_env": "BAD_API_KEY",
|
||||||
|
"has_energy_telemetry": False,
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert not local_yaml.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_delete_default_returns_422_and_leaves_config(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
"""Deleting dispatch_settings.default_provider is rejected and config is untouched."""
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
local_yaml = tmp_path / "config" / "config.local.yaml"
|
||||||
|
base_before = config_yaml.read_bytes()
|
||||||
|
local_before = local_yaml.read_bytes() if local_yaml.exists() else b""
|
||||||
|
|
||||||
|
resp = client.delete("/admin/api/providers/neuralwatt")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "neuralwatt" in detail
|
||||||
|
assert "default_provider" in detail
|
||||||
|
|
||||||
|
assert config_yaml.read_bytes() == base_before
|
||||||
|
assert not local_yaml.exists()
|
||||||
|
assert local_before == b""
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_delete_base_configured_returns_403_and_explains_provenance(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
"""Deleting a base-configured non-default provider returns 403 naming config/config.yaml."""
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
base_before = config_yaml.read_bytes()
|
||||||
|
|
||||||
|
resp = client.delete("/admin/api/providers/openrouter")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "openrouter" in detail
|
||||||
|
assert "config/config.yaml" in detail
|
||||||
|
assert "read-only" in detail.lower()
|
||||||
|
|
||||||
|
assert config_yaml.read_bytes() == base_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_delete_unknown_returns_404(client, tmp_path):
|
||||||
|
"""Deleting a provider that does not exist returns 404."""
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
base_before = config_yaml.read_bytes()
|
||||||
|
|
||||||
|
resp = client.delete("/admin/api/providers/nonexistent")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "provider not found" in resp.json()["detail"]
|
||||||
|
|
||||||
|
assert config_yaml.read_bytes() == base_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_post_writes_overlay_and_preserves_base_backup_created(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
"""POSTing a valid provider writes only the overlay, leaves base alone, and backs up."""
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
local_yaml = tmp_path / "config" / "config.local.yaml"
|
||||||
|
_insert_header_marker(config_yaml)
|
||||||
|
base_before = config_yaml.read_bytes()
|
||||||
|
|
||||||
|
resp = client.post("/admin/api/providers/testprov", json=_VALID_PROVIDER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["provider"]["name"] == "testprov"
|
||||||
|
assert body["provider"]["base_url"] == _VALID_PROVIDER["base_url"]
|
||||||
|
|
||||||
|
assert config_yaml.read_bytes() == base_before
|
||||||
|
assert local_yaml.exists()
|
||||||
|
local_text = local_yaml.read_text()
|
||||||
|
assert "dispatch_providers:" in local_text
|
||||||
|
assert "testprov:" in local_text
|
||||||
|
assert "base_url: https://api.example.com/v1" in local_text
|
||||||
|
|
||||||
|
backups = sorted(tmp_path.glob("config/config.local.yaml.bak.*"))
|
||||||
|
assert len(backups) == 1
|
||||||
|
assert backups[0].read_text().strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_list_includes_base_and_overlay_providers(client, tmp_path):
|
||||||
|
"""GET /admin/api/providers includes both base-configured and overlay providers."""
|
||||||
|
config_yaml = tmp_path / "config" / "config.yaml"
|
||||||
|
local_yaml = tmp_path / "config" / "config.local.yaml"
|
||||||
|
|
||||||
|
local_yaml.write_text(
|
||||||
|
"# Machine-local overlay -- test fixture\n"
|
||||||
|
"dispatch_providers:\n"
|
||||||
|
" overlayprov:\n"
|
||||||
|
" base_url: https://overlay.example.com/v1\n"
|
||||||
|
" api_key_env: OVERLAY_API_KEY\n"
|
||||||
|
" has_energy_telemetry: false\n"
|
||||||
|
" enabled: true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/admin/api/providers")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
names = {p["name"] for p in resp.json()}
|
||||||
|
assert names >= {"neuralwatt", "openrouter", "overlayprov"}
|
||||||
|
|
||||||
|
config_before = config_yaml.read_bytes()
|
||||||
|
assert config_yaml.read_bytes() == config_before
|
||||||
|
|
||||||
|
by_name = {p["name"]: p for p in resp.json()}
|
||||||
|
assert by_name["neuralwatt"]["base_url"] is not None
|
||||||
|
assert by_name["openrouter"]["base_url"] is not None
|
||||||
|
assert by_name["overlayprov"]["base_url"] == "https://overlay.example.com/v1"
|
||||||
|
|
||||||
|
assert by_name["neuralwatt"]["in_base"] is True
|
||||||
|
assert by_name["neuralwatt"]["in_overlay"] is False
|
||||||
|
assert by_name["openrouter"]["in_base"] is True
|
||||||
|
assert by_name["openrouter"]["in_overlay"] is False
|
||||||
|
assert by_name["overlayprov"]["in_base"] is False
|
||||||
|
assert by_name["overlayprov"]["in_overlay"] is True
|
||||||
|
|
||||||
|
detail = client.get("/admin/api/providers/neuralwatt").json()
|
||||||
|
assert detail["in_base"] is True
|
||||||
|
assert detail["in_overlay"] is False
|
||||||
|
detail = client.get("/admin/api/providers/overlayprov").json()
|
||||||
|
assert detail["in_base"] is False
|
||||||
|
assert detail["in_overlay"] is True
|
||||||
@@ -936,6 +936,127 @@ def test_a_non_neuralwatt_vendor_pin_is_not_stripped(router, monkeypatch):
|
|||||||
assert calls[0]["body"]["model"] == "other-vendor/model_id"
|
assert calls[0]["body"]["model"] == "other-vendor/model_id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_openrouter_model_uses_openrouter_provider(router, monkeypatch):
|
||||||
|
"""A pin to an OpenRouter-only model must resolve to the openrouter provider.
|
||||||
|
|
||||||
|
The old code always used ``cfg.dispatch_settings.default_provider``
|
||||||
|
(neuralwatt) on the passthrough path, so a pin targeting an OpenRouter
|
||||||
|
id would end up calling **neuralwatt** with an unrecognised model name,
|
||||||
|
getting a 400. Now ``_resolve_pinned_provider`` looks up the owning
|
||||||
|
provider in the catalog and returns it.
|
||||||
|
"""
|
||||||
|
client, calls, db_path = router
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, tier, context_window,
|
||||||
|
effective_context_window, max_output_tokens,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion,
|
||||||
|
supports_vision, supports_json_mode,
|
||||||
|
latency_class, reasoning_mode, context_variant,
|
||||||
|
access_level, availability, last_updated
|
||||||
|
) VALUES (?, 'openrouter', ?, 3, 262128, 192500, 16384, 0.10, 20.0,
|
||||||
|
1, 1, 'standard', 'default', 'full', 'public', 'active',
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
("openai/gpt-6-astra", "openai/gpt-6-astra"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Wire up the openrouter provider config
|
||||||
|
from config import DispatchProvider
|
||||||
|
monkeypatch.setitem(
|
||||||
|
dispatcher.cfg.dispatch_providers,
|
||||||
|
"openrouter",
|
||||||
|
DispatchProvider(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key_env="OPENROUTER_API_KEY",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "openai/gpt-6-astra", "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == "openai/gpt-6-astra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_router_openrouter_prefix_strips_correctly(router, monkeypatch):
|
||||||
|
"""``llm-router/openai/gpt-6-astra`` strips to ``openai/gpt-6-astra``
|
||||||
|
and resolves to the ``openrouter`` provider.
|
||||||
|
|
||||||
|
The old rsplit heuristic stripped to just ``gpt-6-astra``, which didn't
|
||||||
|
exist in any provider's table. The new prefix-first logic strips the
|
||||||
|
``llm-router/`` prefix and checks if the remainder exists — for
|
||||||
|
OpenRouter vendor/model ids this resolves correctly.
|
||||||
|
"""
|
||||||
|
client, calls, db_path = router
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, tier, context_window,
|
||||||
|
effective_context_window, max_output_tokens,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion,
|
||||||
|
supports_vision, supports_json_mode,
|
||||||
|
latency_class, reasoning_mode, context_variant,
|
||||||
|
access_level, availability, last_updated
|
||||||
|
) VALUES (?, 'openrouter', ?, 3, 262128, 192500, 16384, 0.10, 20.0,
|
||||||
|
1, 1, 'standard', 'default', 'full', 'public', 'active',
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
("openai/gpt-6-astra", "openai/gpt-6-astra"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
from config import DispatchProvider
|
||||||
|
monkeypatch.setitem(
|
||||||
|
dispatcher.cfg.dispatch_providers,
|
||||||
|
"openrouter",
|
||||||
|
DispatchProvider(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key_env="OPENROUTER_API_KEY",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "llm-router/openai/gpt-6-astra", "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == "openai/gpt-6-astra"
|
||||||
|
assert resp.headers["X-Router-Model"] == "openai/gpt-6-astra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuralwatt_only_pin_uses_default_provider(router):
|
||||||
|
"""Models seeded only on neuralwatt resolve back to the default provider.
|
||||||
|
|
||||||
|
The existing fixture seeds CHEAP and DEAR on the neuralwatt provider.
|
||||||
|
``_resolve_pinned_provider`` finds exactly one row and returns
|
||||||
|
``neuralwatt``, which matches ``cfg.dispatch_settings.default_provider``
|
||||||
|
— but the critical thing is the resolution path goes through the
|
||||||
|
catalog, not a hardcoded default.
|
||||||
|
"""
|
||||||
|
client, calls, _ = router
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": CHEAP, "messages": _messages()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert calls[0]["body"]["model"] == CHEAP
|
||||||
|
assert resp.headers["X-Router-Model"] == CHEAP
|
||||||
|
|
||||||
|
|
||||||
def test_the_two_pass_reroute_passes_image_capability(router):
|
def test_the_two_pass_reroute_passes_image_capability(router):
|
||||||
"""The measured-context reroute must carry the image gate, not lose it."""
|
"""The measured-context reroute must carry the image gate, not lose it."""
|
||||||
client, calls, _ = router
|
client, calls, _ = router
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""The classifier circuit breaker actually breaking the circuit.
|
"""The classifier circuit breaker actually breaking the circuit.
|
||||||
|
|
||||||
``_last_classifier_failure`` was written by ``_record_failure`` and read by
|
``_last_classifier_failure`` was written by ``_record_failure`` and read by
|
||||||
nothing: the cascade gated only its *cloud* step, and on a different timestamp
|
nothing: the cascade gated only its *cloud* step, and on a per-provider refusal
|
||||||
(``_last_account_refusal``). So a dead local classifier was re-dialled on every
|
state (``_provider_refusal_since``). So a dead local classifier was re-dialled
|
||||||
request. A refused connection is cheap; a hung or VPN-black-holed Ollama costs
|
on every request. A refused connection is cheap; a hung or VPN-black-holed
|
||||||
|
Ollama costs
|
||||||
``classifier.timeout_seconds`` — 120s on the reference deployment — per request,
|
``classifier.timeout_seconds`` — 120s on the reference deployment — per request,
|
||||||
for as long as the outage lasts.
|
for as long as the outage lasts.
|
||||||
|
|
||||||
@@ -29,12 +30,13 @@ def _closed_circuit(monkeypatch):
|
|||||||
"""Start every test with an empty cache and a closed circuit."""
|
"""Start every test with an empty cache and a closed circuit."""
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
||||||
monkeypatch.setattr(dispatcher, "_last_account_refusal", 0.0)
|
dispatcher._provider_refusal_since.clear()
|
||||||
# No session key -> cascade steps 1 and 2 are impossible, so a skipped
|
# No session key -> cascade steps 1 and 2 are impossible, so a skipped
|
||||||
# local attempt lands on the static fallback and nothing touches the DB.
|
# local attempt lands on the static fallback and nothing touches the DB.
|
||||||
token = dispatcher._current_session_key.set(None)
|
token = dispatcher._current_session_key.set(None)
|
||||||
yield
|
yield
|
||||||
dispatcher._current_session_key.reset(token)
|
dispatcher._current_session_key.reset(token)
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ def _clean_state(monkeypatch):
|
|||||||
"""Each test starts with an empty cache and a closed circuit."""
|
"""Each test starts with an empty cache and a closed circuit."""
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
||||||
monkeypatch.setattr(dispatcher, "_last_account_refusal", 0.0)
|
dispatcher._provider_refusal_since.clear()
|
||||||
yield
|
yield
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ def test_cloud_step_used_when_configured(db, monkeypatch):
|
|||||||
assert got.required_context_tokens == 1234
|
assert got.required_context_tokens == 1234
|
||||||
|
|
||||||
|
|
||||||
def test_account_refusal_skips_the_cloud_step(db, monkeypatch):
|
def test_provider_refusal_skips_the_cloud_step(db, monkeypatch):
|
||||||
"""Out of credit -> a cloud classification is a guaranteed wasted request."""
|
"""Out of credit -> a cloud classification is a guaranteed wasted request."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dispatcher.cfg.classifier,
|
dispatcher.cfg.classifier,
|
||||||
@@ -171,9 +172,9 @@ def test_account_refusal_skips_the_cloud_step(db, monkeypatch):
|
|||||||
"_cloud_classifier_client",
|
"_cloud_classifier_client",
|
||||||
lambda cf: called.append(1) or object(),
|
lambda cf: called.append(1) or object(),
|
||||||
)
|
)
|
||||||
dispatcher._record_account_refusal()
|
dispatcher._record_provider_refusal("neuralwatt")
|
||||||
assert dispatcher._classify_cascade("sess-6", "sys", "user") is None
|
assert dispatcher._classify_cascade("sess-6", "sys", "user") is None
|
||||||
assert called == [], "cloud client built despite an account-level refusal"
|
assert called == [], "cloud client built despite a provider-level refusal"
|
||||||
|
|
||||||
|
|
||||||
def test_cloud_failure_degrades_to_the_static_guess(db, monkeypatch):
|
def test_cloud_failure_degrades_to_the_static_guess(db, monkeypatch):
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import session_cache
|
|||||||
def _clean_state(monkeypatch):
|
def _clean_state(monkeypatch):
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
||||||
monkeypatch.setattr(dispatcher, "_last_account_refusal", 0.0)
|
dispatcher._provider_refusal_since.clear()
|
||||||
monkeypatch.setattr(dispatcher, "_cached_auto_classifier", None)
|
monkeypatch.setattr(dispatcher, "_cached_auto_classifier", None)
|
||||||
monkeypatch.setattr(dispatcher, "_auto_classifier_resolved_at", 0.0)
|
monkeypatch.setattr(dispatcher, "_auto_classifier_resolved_at", 0.0)
|
||||||
token = dispatcher._current_session_key.set(None)
|
token = dispatcher._current_session_key.set(None)
|
||||||
yield
|
yield
|
||||||
dispatcher._current_session_key.reset(token)
|
dispatcher._current_session_key.reset(token)
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ def gaming_mode(monkeypatch):
|
|||||||
"""Local compute off, with the cloud classifier the mode requires."""
|
"""Local compute off, with the cloud classifier the mode requires."""
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
|
||||||
monkeypatch.setattr(dispatcher, "_last_account_refusal", 0.0)
|
dispatcher._provider_refusal_since.clear()
|
||||||
monkeypatch.setattr(dispatcher.cfg.local_compute, "enabled", False)
|
monkeypatch.setattr(dispatcher.cfg.local_compute, "enabled", False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dispatcher.cfg.classifier, "cloud_fallback", _cloud_fallback()
|
dispatcher.cfg.classifier, "cloud_fallback", _cloud_fallback()
|
||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
session_cache.clear()
|
session_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
270
tests/test_multi_provider_dispatch.py
Normal file
270
tests/test_multi_provider_dispatch.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""Multi-provider dispatch edge cases.
|
||||||
|
|
||||||
|
Exercises the dispatcher where two configured providers diverge in telemetry
|
||||||
|
shape and in per-provider refusal state. Nothing here calls real providers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import dispatcher
|
||||||
|
import session_cache
|
||||||
|
from config import DispatchProvider
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
|
||||||
|
|
||||||
|
CHEAP = "cheap-model"
|
||||||
|
DEAR = "dear-model"
|
||||||
|
|
||||||
|
# Stream lines carrying OpenRouter's keepalive comment *instead* of energy/cost.
|
||||||
|
OPENROUTER_STREAM_LINES = [
|
||||||
|
'data: {"id":"chatcmpl-or-1","choices":[{"delta":{"content":"hel"}}]}',
|
||||||
|
"",
|
||||||
|
': OPENROUTER PROCESSING',
|
||||||
|
'data: {"id":"chatcmpl-or-1","choices":[{"delta":{"content":"lo"},'
|
||||||
|
'"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}',
|
||||||
|
"",
|
||||||
|
"data: [DONE]",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_state(monkeypatch):
|
||||||
|
"""Isolate the module-level refusal map between tests."""
|
||||||
|
session_cache.clear()
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
|
yield
|
||||||
|
dispatcher._provider_refusal_since.clear()
|
||||||
|
session_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def router(tmp_path, monkeypatch):
|
||||||
|
"""A chat-completions fixture with two providers in the catalog."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
# Seed a cheap model under the default (neuralwatt) provider.
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, tier, context_window,
|
||||||
|
effective_context_window, max_output_tokens,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion,
|
||||||
|
supports_vision, supports_json_mode,
|
||||||
|
latency_class, reasoning_mode, context_variant,
|
||||||
|
access_level, availability, last_updated
|
||||||
|
) VALUES (?, ?, ?, 2, 262128, 192500, 16384, ?, ?,
|
||||||
|
1, 1, 'standard', 'default', 'full', 'public', 'active',
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
(CHEAP, "neuralwatt", CHEAP, 0.30 / 3, 0.30),
|
||||||
|
)
|
||||||
|
# Seed an OpenRouter-only frontier model.
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, tier, context_window,
|
||||||
|
effective_context_window, max_output_tokens,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion,
|
||||||
|
supports_vision, supports_json_mode,
|
||||||
|
latency_class, reasoning_mode, context_variant,
|
||||||
|
access_level, availability, last_updated
|
||||||
|
) VALUES (?, 'openrouter', ?, 3, 262128, 192500, 16384, 0.10, 20.0,
|
||||||
|
1, 1, 'standard', 'default', 'full', 'public', 'active',
|
||||||
|
'2026-08-22T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
("openai/gpt-6-astra", "openai/gpt-6-astra"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(db_path))
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.local_vision, "enabled", False)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.session_cache, "enabled", False)
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.exploration, "epsilon", 0.0)
|
||||||
|
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
|
||||||
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, stream=False, timeout=None):
|
||||||
|
calls.append({"url": url, "body": json, "stream": stream})
|
||||||
|
if stream:
|
||||||
|
return _FakeResponse(lines=OPENROUTER_STREAM_LINES)
|
||||||
|
return _FakeResponse(_completion(json["model"]))
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.requests, "post", fake_post)
|
||||||
|
yield TestClient(dispatcher.app), calls, db_path
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
"""Minimal requests.Response stand-in."""
|
||||||
|
|
||||||
|
def __init__(self, payload=None, *, status_code=200, lines=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
self._lines = lines or []
|
||||||
|
self.text = json.dumps(self._payload)
|
||||||
|
self.headers = {}
|
||||||
|
self.request = None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
def iter_lines(self, decode_unicode=False):
|
||||||
|
yield from self._lines
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _completion(model):
|
||||||
|
return {
|
||||||
|
"id": "chatcmpl-test-1",
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "hello there"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 31, "completion_tokens": 12},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _energy_row(db_path):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT model_id, energy_kwh, cost_usd FROM energy_observations ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrouter_streaming_ignores_keepalive_comments_and_logs_null_telemetry(router):
|
||||||
|
"""A provider with has_energy_telemetry=false can emit `: OPENROUTER ...`
|
||||||
|
comments without crashing the stream or logging fake telemetry.
|
||||||
|
"""
|
||||||
|
client, calls, db_path = router
|
||||||
|
# Wire the openrouter provider config (already in config.yaml, but ensure
|
||||||
|
# the test fixture has it for clarity).
|
||||||
|
dispatcher.cfg.dispatch_providers["openrouter"] = DispatchProvider(
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key_env="OPENROUTER_API_KEY",
|
||||||
|
has_energy_telemetry=False,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "openai/gpt-6-astra",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"stream": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||||
|
# The keepalive comment must reach the client unchanged.
|
||||||
|
assert ": OPENROUTER PROCESSING" in resp.text
|
||||||
|
# No crash on the SSE framing.
|
||||||
|
assert "data: [DONE]" in resp.text
|
||||||
|
|
||||||
|
row = _energy_row(db_path)
|
||||||
|
assert row is not None
|
||||||
|
assert row["model_id"] == "openai/gpt-6-astra"
|
||||||
|
assert row["energy_kwh"] is None
|
||||||
|
assert row["cost_usd"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_refusal_is_isolated_between_providers(router, monkeypatch):
|
||||||
|
"""A refusal on neuralwatt must skip the cloud classifier only when the
|
||||||
|
default provider is neuralwatt; a request whose default provider is
|
||||||
|
openrouter must still be allowed to use the cloud classifier.
|
||||||
|
"""
|
||||||
|
client, calls, db_path = router
|
||||||
|
|
||||||
|
# Configure a cloud classifier so the cascade has something to skip.
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
cloud_called = {"neuralwatt": False, "openrouter": False}
|
||||||
|
|
||||||
|
def fake_cloud_classify(*args, **kwargs):
|
||||||
|
cloud_called[dispatcher.cfg.dispatch_settings.default_provider] = True
|
||||||
|
return {
|
||||||
|
"task_category": "reasoning_math",
|
||||||
|
"task_tier": 3,
|
||||||
|
"required_context_tokens": 100,
|
||||||
|
"confidence": 0.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dispatcher.cfg.classifier,
|
||||||
|
"cloud_fallback",
|
||||||
|
SimpleNamespace(
|
||||||
|
base_url="https://cloud.example/v1",
|
||||||
|
model="cloud-classifier",
|
||||||
|
timeout_seconds=2,
|
||||||
|
api_key_env=None,
|
||||||
|
max_output_tokens=1024,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(dispatcher, "_cloud_classifier_client", lambda cf: object())
|
||||||
|
monkeypatch.setattr(session_cache, "classify_one", fake_cloud_classify)
|
||||||
|
# Ensure classify() degrades so the cascade reaches the cloud step.
|
||||||
|
monkeypatch.setattr(dispatcher, "classify", lambda task, context: dispatcher.Classification(
|
||||||
|
task_category="general_chat",
|
||||||
|
task_tier=2,
|
||||||
|
required_context_tokens=10,
|
||||||
|
confidence=0.0,
|
||||||
|
source="fallback",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Record a refusal for neuralwatt.
|
||||||
|
dispatcher._record_provider_refusal("neuralwatt")
|
||||||
|
|
||||||
|
# With default_provider=neuralwatt, the cloud step should be skipped.
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.dispatch_settings, "default_provider", "neuralwatt")
|
||||||
|
assert dispatcher._classify_cascade("sess-nw", "sys", "user") is None
|
||||||
|
assert cloud_called["neuralwatt"] is False
|
||||||
|
|
||||||
|
# With default_provider=openrouter, the refusal on neuralwatt must not apply.
|
||||||
|
monkeypatch.setattr(dispatcher.cfg.dispatch_settings, "default_provider", "openrouter")
|
||||||
|
result = dispatcher._classify_cascade("sess-or", "sys", "user")
|
||||||
|
assert result is not None
|
||||||
|
assert result.source == "classifier_cloud"
|
||||||
|
assert cloud_called["openrouter"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_telemetry_empty_payload_has_no_energy_or_cost():
|
||||||
|
"""An empty payload yields an all-None Telemetry rather than an error."""
|
||||||
|
telemetry = dispatcher.extract_telemetry({})
|
||||||
|
assert telemetry.energy_kwh is None
|
||||||
|
assert telemetry.cost_usd is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sniff_telemetry_line_ignores_foreign_comments():
|
||||||
|
"""SSE comment lines that do not carry energy/cost (e.g. OpenRouter
|
||||||
|
keepalives) are deliberately ignored."""
|
||||||
|
assert dispatcher._sniff_telemetry_line(": OPENROUTER PROCESSING") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sniff_telemetry_line_parses_energy_comment():
|
||||||
|
"""A `: energy {...}` SSE comment line is parsed into a truthy
|
||||||
|
(word, payload) tuple."""
|
||||||
|
parsed = dispatcher._sniff_telemetry_line(": energy {}")
|
||||||
|
assert parsed is not None
|
||||||
|
word, payload = parsed
|
||||||
|
assert word == "energy"
|
||||||
|
assert payload == {}
|
||||||
676
tests/test_multi_provider_poller.py
Normal file
676
tests/test_multi_provider_poller.py
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
"""Tests for the multi-provider main loop in poller.py.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- two providers both upsert
|
||||||
|
- one provider fails with RequestException while the other succeeds (failure isolation)
|
||||||
|
- disabled provider is skipped
|
||||||
|
- zero-row provider is logged as skip
|
||||||
|
- per-provider mark_stale scopes staleness to the queried provider
|
||||||
|
- upstream mark_stale (provider=None) retains global behavior
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import poller
|
||||||
|
from config import RouterConfig, load_config
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
|
||||||
|
REAL_CFG = load_config(str(ROOT / "config" / "config.yaml"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_response(json_data: dict):
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.json.return_value = json_data
|
||||||
|
fake.raise_for_status.return_value = None
|
||||||
|
fake.status_code = 200
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_item(model_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"metadata": {
|
||||||
|
"display_name": model_id.replace("-", " ").title(),
|
||||||
|
"description": "test model",
|
||||||
|
"pricing": {
|
||||||
|
"input_per_million": 0.30,
|
||||||
|
"output_per_million": 0.60,
|
||||||
|
"cached_input_per_million": 0.20,
|
||||||
|
"pricing_tbd": False,
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"tools": True,
|
||||||
|
"json_mode": True,
|
||||||
|
"vision": False,
|
||||||
|
"reasoning": False,
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"max_context_length": 131072,
|
||||||
|
"max_output_tokens": 8192,
|
||||||
|
},
|
||||||
|
"deprecated": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _openrouter_model(
|
||||||
|
model_id: str,
|
||||||
|
prompt: str = "0.000001",
|
||||||
|
context_length: int = 131072,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"name": model_id.replace("/", " ").title(),
|
||||||
|
"canonical_slug": model_id,
|
||||||
|
"pricing": {"prompt": prompt, "completion": "0.000002"},
|
||||||
|
"top_provider": {
|
||||||
|
"context_length": context_length,
|
||||||
|
"max_completion_tokens": 8192,
|
||||||
|
},
|
||||||
|
"architecture": {"input_modalities": ["text"]},
|
||||||
|
"supported_parameters": [],
|
||||||
|
"reasoning": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_models(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
models: list[dict],
|
||||||
|
provider: str = "neuralwatt",
|
||||||
|
last_updated_days_ago: int = 0,
|
||||||
|
) -> None:
|
||||||
|
ts = (
|
||||||
|
datetime.now(timezone.utc) - timedelta(days=last_updated_days_ago)
|
||||||
|
).isoformat()
|
||||||
|
for m in models:
|
||||||
|
deprecated = bool(m.get("deprecated", False))
|
||||||
|
availability = "deprecated" if deprecated else m.get("availability", "active")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, 0.30, 0.60, 0.20,
|
||||||
|
131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
m["model_id"],
|
||||||
|
provider,
|
||||||
|
m.get("base_model_id", m["model_id"]),
|
||||||
|
m.get(
|
||||||
|
"display_name",
|
||||||
|
f"Model {m['model_id'].title().replace('_', ' ')}",
|
||||||
|
),
|
||||||
|
availability,
|
||||||
|
ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def tmp_db(tmp_path):
|
||||||
|
"""Create an empty temp DB with schema and return a helper tuple.
|
||||||
|
|
||||||
|
Returns ``(seed_and_check, cfg)`` where ``seed_and_check`` is a callable
|
||||||
|
that opens a connection, lets the caller seed it, and returns the
|
||||||
|
connection. The connection must be closed by the test. main() opens its
|
||||||
|
own connection, so we cannot keep a fixture-level connection open.
|
||||||
|
"""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
cfg = REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(update={"stale_after_days": 300}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
c = connect()
|
||||||
|
c.executescript(SCHEMA_SQL)
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
yield connect, cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def monkeypatch_cfg(monkeypatch):
|
||||||
|
"""Return a helper that sets up a minimal dispatch_providers config."""
|
||||||
|
def _make(providers: dict) -> RouterConfig:
|
||||||
|
return REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(
|
||||||
|
update={"path": str(tmp_path_provider[0] / "test.db")}
|
||||||
|
),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(
|
||||||
|
update={"stale_after_days": 300}
|
||||||
|
),
|
||||||
|
"dispatch_providers": providers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# We'll create tmp paths on demand. Use a list so the closure gets updated.
|
||||||
|
tmp_dirs: list[Path] = []
|
||||||
|
|
||||||
|
def _make_with_dir(providers: dict, tmp_path: Path) -> tuple[RouterConfig, str]:
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
cfg = REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(
|
||||||
|
update={"stale_after_days": 300}
|
||||||
|
),
|
||||||
|
"dispatch_providers": providers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tmp_dirs.append(tmp_path)
|
||||||
|
return cfg, db_path
|
||||||
|
|
||||||
|
return _make_with_dir, tmp_dirs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_providers_both_upsert(tmp_db, monkeypatch):
|
||||||
|
"""Two enabled providers are fetched, upserted, and total reported."""
|
||||||
|
connect, cfg = tmp_db
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
poller.requests,
|
||||||
|
"get",
|
||||||
|
lambda url, timeout: _fake_response(
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
_catalog_item("nw-model-a"),
|
||||||
|
_catalog_item("nw-model-b"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
nw_rows = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt'"
|
||||||
|
).fetchone()[0]
|
||||||
|
or_rows = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='openrouter'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert nw_rows == 2
|
||||||
|
assert or_rows == 2
|
||||||
|
assert exit_code == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_provider_fails_other_succeeds(tmp_db, monkeypatch):
|
||||||
|
"""Failure in one provider's fetch does not abort the other."""
|
||||||
|
connect, cfg = tmp_db
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _failing_get(url, timeout):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if "openrouter" in url:
|
||||||
|
# OpenRouter fetch goes first (dict iteration order)
|
||||||
|
if call_count == 1:
|
||||||
|
return _fake_response(
|
||||||
|
{"data": [_openrouter_model("openai/gpt-6-astra")]}
|
||||||
|
)
|
||||||
|
# Second neuralwatt call
|
||||||
|
return _fake_response({"data": [_catalog_item("model_a")]})
|
||||||
|
# NeuralWatt fails
|
||||||
|
raise requests.RequestException("network error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
monkeypatch.setattr(poller.requests, "get", _failing_get)
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
nw_rows = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt'"
|
||||||
|
).fetchone()[0]
|
||||||
|
or_rows = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='openrouter'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert nw_rows == 0, "failed provider should have 0 rows"
|
||||||
|
assert or_rows == 1, "succeeded provider should have 1 row"
|
||||||
|
assert exit_code == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_provider_skipped(tmp_db, monkeypatch):
|
||||||
|
"""Provider with enabled=False is never fetched or upserted."""
|
||||||
|
connect, cfg = tmp_db
|
||||||
|
# Configure only neuralwatt as disabled
|
||||||
|
nw_cfg = cfg.dispatch_providers.get("neuralwatt")
|
||||||
|
if nw_cfg is None:
|
||||||
|
nw_cfg = REAL_CFG.dispatch_providers["neuralwatt"]
|
||||||
|
disabled_cfg = nw_cfg.model_copy(update={"enabled": False})
|
||||||
|
|
||||||
|
cfg = cfg.model_copy(
|
||||||
|
update={
|
||||||
|
"dispatch_providers": {"neuralwatt": disabled_cfg}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
# If the provider is skipped, requests.get should never be called
|
||||||
|
monkeypatch.setattr(poller.requests, "get", MagicMock(side_effect=RuntimeError("should not be called")))
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
nw_rows = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert nw_rows == 0
|
||||||
|
assert exit_code == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_row_provider_logged_as_skip(tmp_path, monkeypatch):
|
||||||
|
"""A provider that returns an empty catalog is logged and skipped."""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
cfg = REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(
|
||||||
|
update={"stale_after_days": 300}
|
||||||
|
),
|
||||||
|
"dispatch_providers": {"neuralwatt": REAL_CFG.dispatch_providers["neuralwatt"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
poller.requests,
|
||||||
|
"get",
|
||||||
|
lambda url, timeout: _fake_response({"data": []}),
|
||||||
|
)
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
stderr_capture = io.StringIO()
|
||||||
|
monkeypatch.setattr("sys.stderr", stderr_capture)
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
stderr_content = stderr_capture.getvalue()
|
||||||
|
assert "0 models" in stderr_content or "skipping" in stderr_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_process_tiering_runs_after_upsert(tmp_path, monkeypatch):
|
||||||
|
"""Successful poll assigns tiers so rows are routable immediately."""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
conn_init = sqlite3.connect(db_path)
|
||||||
|
conn_init.row_factory = sqlite3.Row
|
||||||
|
conn_init.executescript(SCHEMA_SQL)
|
||||||
|
conn_init.close()
|
||||||
|
|
||||||
|
cfg = REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(
|
||||||
|
update={"stale_after_days": 300}
|
||||||
|
),
|
||||||
|
"dispatch_providers": {
|
||||||
|
"openrouter": REAL_CFG.dispatch_providers["openrouter"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
poller.requests,
|
||||||
|
"get",
|
||||||
|
lambda url, timeout: _fake_response(
|
||||||
|
{"data": [_openrouter_model("openai/gpt-6-astra", context_length=200000)]}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT tier FROM models WHERE provider='openrouter' AND model_id='openai/gpt-6-astra'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["tier"] is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_provider_mark_stale_scopes_to_provider(tmp_db, monkeypatch):
|
||||||
|
"""Staleness update for one provider doesn't affect the other."""
|
||||||
|
connect, cfg = tmp_db
|
||||||
|
|
||||||
|
# Seed stale rows for both providers
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
_seed_models(
|
||||||
|
conn,
|
||||||
|
[{"model_id": "nw_old"}],
|
||||||
|
provider="neuralwatt",
|
||||||
|
last_updated_days_ago=10,
|
||||||
|
)
|
||||||
|
# Insert openrouter row with availability='active' and old timestamp
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('or_old', 'openrouter', 'or_old', 'Old OR',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
# Also insert a 'fresh' old-provider row that should NOT become stale
|
||||||
|
fresh_ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('nw_fresh', 'neuralwatt', 'nw_fresh', 'Fresh NW',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(fresh_ts,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('or_fresh', 'openrouter', 'or_fresh', 'Fresh OR',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(fresh_ts,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Fresh timestamp for upsert
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
poller.requests,
|
||||||
|
"get",
|
||||||
|
lambda url, timeout: _fake_response({"data": [_catalog_item("nw_fresh")]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = cfg.model_copy(
|
||||||
|
update={
|
||||||
|
"freshness": cfg.freshness.model_copy(update={"stale_after_days": 5})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
poller.main()
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
# nw_old should have been marked stale (was active, not fetched this run)
|
||||||
|
stale_nw = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='neuralwatt'"
|
||||||
|
).fetchone()[0]
|
||||||
|
# or_old should NOT be stale — mark_stale was only called for neuralwatt provider
|
||||||
|
stale_or = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='openrouter'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert stale_nw == 1, f"Expected 1 stale neuralwatt row, got {stale_nw}"
|
||||||
|
# openrouter's stale rows remain because mark_stale scoped to neuralwatt
|
||||||
|
assert stale_or == 1, f"Expected OR stale rows unchanged, got {stale_or} (or_old was already stale from old_ts)"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_provider_key_skipped(tmp_path, monkeypatch):
|
||||||
|
"""An unknown provider key in dispatch_providers is logged and skipped."""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
conn_init = sqlite3.connect(db_path)
|
||||||
|
conn_init.executescript(SCHEMA_SQL)
|
||||||
|
conn_init.close()
|
||||||
|
|
||||||
|
# Use a known entry as a template for the unknown provider shape.
|
||||||
|
template = REAL_CFG.dispatch_providers.get(
|
||||||
|
"neuralwatt", REAL_CFG.dispatch_providers["openrouter"]
|
||||||
|
)
|
||||||
|
unknown_cfg = template.model_copy(update={"enabled": True})
|
||||||
|
|
||||||
|
cfg_with_unknown = REAL_CFG.model_copy(
|
||||||
|
update={
|
||||||
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
|
"freshness": REAL_CFG.freshness.model_copy(
|
||||||
|
update={"stale_after_days": 300}
|
||||||
|
),
|
||||||
|
"dispatch_providers": {
|
||||||
|
"neuralwatt": REAL_CFG.dispatch_providers["neuralwatt"],
|
||||||
|
"unknown_provider": unknown_cfg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg_with_unknown)
|
||||||
|
# Unknown provider should be skipped before any fetch.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
poller.requests,
|
||||||
|
"get",
|
||||||
|
MagicMock(
|
||||||
|
side_effect=lambda url, timeout: (
|
||||||
|
_fake_response({"data": [_catalog_item("model_a")]})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code = poller.main()
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# unknown_provider produced no rows
|
||||||
|
unknown_rows = sqlite3.connect(db_path).execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE provider='unknown_provider'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert unknown_rows == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_stale_global_behavior_unaffected():
|
||||||
|
"""mark_stale() with no provider arg retains the old global behavior."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db") as f:
|
||||||
|
conn = sqlite3.connect(f.name)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
|
||||||
|
# Insert two rows from different providers, both active and old
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('model_a', 'nw', 'model_a', 'A',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('model_b', 'openrouter', 'model_b', 'B',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Call mark_stale without provider (global behavior)
|
||||||
|
mark_stale_fn = poller.mark_stale
|
||||||
|
mark_stale_fn(conn, REAL_CFG)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
stale_total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE availability='stale'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
assert stale_total == 2, f"All rows should be stale globally, got {stale_total}"
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_provider_mark_stale_scoped(tmp_path):
|
||||||
|
"""mark_stale(provider=...) only marks rows for that provider."""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
|
||||||
|
|
||||||
|
# Seed one active, old row for each provider
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('nw_old', 'neuralwatt', 'nw_old', 'A',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO models (
|
||||||
|
model_id, provider, base_model_id, display_name,
|
||||||
|
cost_per_1m_prompt, cost_per_1m_completion, cost_per_1m_prompt_cached,
|
||||||
|
context_window, effective_context_window, max_output_tokens,
|
||||||
|
supports_tools, supports_json_mode, supports_vision, supports_reasoning,
|
||||||
|
reasoning_default_enabled, latency_class, reasoning_mode,
|
||||||
|
context_variant, access_level, pricing_tbd, deprecated,
|
||||||
|
availability, last_updated
|
||||||
|
) VALUES ('or_old', 'openrouter', 'or_old', 'B',
|
||||||
|
0.30, 0.60, 0.20, 131072, 192500, 8192,
|
||||||
|
1, 1, 0, 0,
|
||||||
|
0, 'standard', 'default', 'full', 'public', 0, 0,
|
||||||
|
'active', ?)
|
||||||
|
""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# mark_stale scoped to neuralwatt
|
||||||
|
poller.mark_stale(conn, REAL_CFG, provider="neuralwatt")
|
||||||
|
|
||||||
|
stale_nw = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='neuralwatt'"
|
||||||
|
).fetchone()[0]
|
||||||
|
stale_or = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='openrouter'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
assert stale_nw == 1, "neuralwatt row should be stale"
|
||||||
|
assert stale_or == 0, "openrouter row should NOT be stale when scoped to neuralwatt"
|
||||||
|
|
||||||
|
conn.close()
|
||||||
225
tests/test_openrouter_poller.py
Normal file
225
tests/test_openrouter_poller.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Offline tests for the OpenRouter catalog fetcher and parser in poller.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from poller import (
|
||||||
|
OPENROUTER_VIRTUAL_ROUTERS,
|
||||||
|
ModelRow,
|
||||||
|
fetch_openrouter,
|
||||||
|
parse_openrouter_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fake_response(json_data: dict):
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.json.return_value = json_data
|
||||||
|
fake.raise_for_status.return_value = None
|
||||||
|
fake.status_code = 200
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _openrouter_model(
|
||||||
|
model_id: str,
|
||||||
|
*,
|
||||||
|
canonical_slug: str | None = None,
|
||||||
|
prompt: str = "0.000001",
|
||||||
|
completion: str = "0.000002",
|
||||||
|
context_length: int = 131072,
|
||||||
|
max_completion_tokens: int = 8192,
|
||||||
|
modalities: list[str] | None = None,
|
||||||
|
supported_parameters: list[str] | None = None,
|
||||||
|
reasoning: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Build a realistic OpenRouter /v1/models entry."""
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"name": model_id.replace("/", " ").title(),
|
||||||
|
"canonical_slug": canonical_slug or model_id,
|
||||||
|
"pricing": {
|
||||||
|
"prompt": prompt,
|
||||||
|
"completion": completion,
|
||||||
|
"web_search": "0.000003",
|
||||||
|
"cache_read": "0.0000005",
|
||||||
|
},
|
||||||
|
"top_provider": {
|
||||||
|
"context_length": context_length,
|
||||||
|
"max_completion_tokens": max_completion_tokens,
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"input_modalities": modalities or ["text"],
|
||||||
|
},
|
||||||
|
"supported_parameters": supported_parameters or [],
|
||||||
|
"reasoning": reasoning,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_openrouter_model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_populates_required_fields():
|
||||||
|
raw = _openrouter_model(
|
||||||
|
"openai/gpt-6-astra",
|
||||||
|
canonical_slug="gpt-6-astra",
|
||||||
|
prompt="0.0000015",
|
||||||
|
completion="0.0000060",
|
||||||
|
context_length=200000,
|
||||||
|
max_completion_tokens=32768,
|
||||||
|
modalities=["text", "image"],
|
||||||
|
supported_parameters=["tools", "response_format", "reasoning"],
|
||||||
|
reasoning={"mode": "medium", "default_enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row == ModelRow(
|
||||||
|
model_id="openai/gpt-6-astra",
|
||||||
|
provider="openrouter",
|
||||||
|
base_model_id="gpt-6-astra",
|
||||||
|
display_name="Openai Gpt-6-Astra",
|
||||||
|
cost_per_1m_prompt=1.5,
|
||||||
|
cost_per_1m_completion=6.0,
|
||||||
|
cost_per_1m_prompt_cached=None,
|
||||||
|
context_window=200000,
|
||||||
|
max_output_tokens=32768,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_json_mode=True,
|
||||||
|
supports_vision=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
reasoning_default_enabled=True,
|
||||||
|
latency_class="standard",
|
||||||
|
reasoning_mode="medium",
|
||||||
|
context_variant="standard",
|
||||||
|
access_level="public",
|
||||||
|
pricing_tbd=False,
|
||||||
|
deprecated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_batch_variant_maps_to_flex():
|
||||||
|
raw = _openrouter_model(
|
||||||
|
"anthropic/claude-4-opus:batch",
|
||||||
|
canonical_slug="claude-4-opus",
|
||||||
|
)
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row.latency_class == "flex"
|
||||||
|
assert row.context_variant == "batch"
|
||||||
|
assert row.model_id == "anthropic/claude-4-opus:batch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_free_variant_maps_to_standard():
|
||||||
|
raw = _openrouter_model(
|
||||||
|
"meta-llama/llama-4-maverick:free",
|
||||||
|
canonical_slug="llama-4-maverick",
|
||||||
|
)
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row.latency_class == "standard"
|
||||||
|
assert row.context_variant == "free"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_reasoning_defaults():
|
||||||
|
raw = _openrouter_model("google/gemini-2.5-flash")
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row.reasoning_mode == "none"
|
||||||
|
assert row.reasoning_default_enabled is False
|
||||||
|
assert row.supports_reasoning is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("router_id", sorted(OPENROUTER_VIRTUAL_ROUTERS))
|
||||||
|
def test_parse_openrouter_model_excludes_virtual_routers(router_id):
|
||||||
|
raw = _openrouter_model(router_id)
|
||||||
|
|
||||||
|
assert parse_openrouter_model(raw) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_marks_missing_prompt_price_as_tbd():
|
||||||
|
raw = _openrouter_model("x-ai/grok-3", prompt="")
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row.cost_per_1m_prompt is None
|
||||||
|
assert row.pricing_tbd is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_openrouter_model_gracefully_handles_malformed_prices():
|
||||||
|
raw = _openrouter_model("x-ai/grok-3-mini", prompt="free", completion="N/A")
|
||||||
|
|
||||||
|
row = parse_openrouter_model(raw)
|
||||||
|
|
||||||
|
assert row.cost_per_1m_prompt is None
|
||||||
|
assert row.cost_per_1m_completion is None
|
||||||
|
assert row.pricing_tbd is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_openrouter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch.object(requests, "get")
|
||||||
|
def test_fetch_openrouter_returns_model_rows(mock_get):
|
||||||
|
mock_get.return_value = _fake_response(
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
_openrouter_model(
|
||||||
|
"openai/gpt-6-astra",
|
||||||
|
canonical_slug="gpt-6-astra",
|
||||||
|
),
|
||||||
|
_openrouter_model(
|
||||||
|
"anthropic/claude-4-opus:batch",
|
||||||
|
canonical_slug="claude-4-opus",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = fetch_openrouter("openrouter")
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0].model_id == "openai/gpt-6-astra"
|
||||||
|
assert rows[0].base_model_id == "gpt-6-astra"
|
||||||
|
assert rows[1].model_id == "anthropic/claude-4-opus:batch"
|
||||||
|
assert rows[1].latency_class == "flex"
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"https://openrouter.ai/api/v1/models", timeout=20
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch.object(requests, "get")
|
||||||
|
def test_fetch_openrouter_excludes_virtual_routers(mock_get):
|
||||||
|
mock_get.return_value = _fake_response(
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
_openrouter_model("openrouter/auto"),
|
||||||
|
_openrouter_model("openrouter/free"),
|
||||||
|
_openrouter_model("openrouter/fusion"),
|
||||||
|
_openrouter_model("openai/gpt-6-astra", canonical_slug="gpt-6-astra"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = fetch_openrouter("openrouter")
|
||||||
|
|
||||||
|
assert [r.model_id for r in rows] == ["openai/gpt-6-astra"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch.object(requests, "get")
|
||||||
|
def test_fetch_openrouter_propagates_request_exception(mock_get):
|
||||||
|
mock_get.side_effect = requests.RequestException("offline")
|
||||||
|
|
||||||
|
with pytest.raises(requests.RequestException):
|
||||||
|
fetch_openrouter("openrouter")
|
||||||
@@ -127,12 +127,17 @@ def tmp_db(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
Yields ``(conn, cfg)``. Caller can seed the DB; fixture closes conn at
|
Yields ``(conn, cfg)``. Caller can seed the DB; fixture closes conn at
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
The fixture overrides ``dispatch_providers`` to contain only ``neuralwatt``
|
||||||
|
(the provider under test) so the multi-provider main loop doesn't also
|
||||||
|
try to fetch from ``openrouter`` and other providers.
|
||||||
"""
|
"""
|
||||||
db_path = str(tmp_path / "test.db")
|
db_path = str(tmp_path / "test.db")
|
||||||
cfg = REAL_CFG.model_copy(
|
cfg = REAL_CFG.model_copy(
|
||||||
update={
|
update={
|
||||||
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
"database": REAL_CFG.database.model_copy(update={"path": db_path}),
|
||||||
"freshness": REAL_CFG.freshness.model_copy(update={"stale_after_days": 300}),
|
"freshness": REAL_CFG.freshness.model_copy(update={"stale_after_days": 300}),
|
||||||
|
"dispatch_providers": {"neuralwatt": REAL_CFG.dispatch_providers["neuralwatt"]},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
@@ -147,11 +152,11 @@ def tmp_db(tmp_path, monkeypatch):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_zero_rows_exits_nonzero_and_marks_nothing_stale(tmp_db, monkeypatch):
|
def test_zero_rows_exits_nonzero_and_marks_nothing_stale(tmp_db, monkeypatch):
|
||||||
"""(a) Zero-row fetch raises CatalogTooSmall.
|
"""(a) Zero-row fetch no longer aborts the process.
|
||||||
|
|
||||||
This may be returned through the requests.RequestException handler (exit
|
In the multi-provider main, CatalogTooSmall is caught and the provider
|
||||||
code 1) or raised directly; the important behavior is that the run fails
|
is skipped — the run completes with 0 rows upserted and no rows marked
|
||||||
and no rows are marked stale.
|
stale.
|
||||||
"""
|
"""
|
||||||
conn, cfg = tmp_db
|
conn, cfg = tmp_db
|
||||||
_seed_models(conn, [
|
_seed_models(conn, [
|
||||||
@@ -159,13 +164,10 @@ def test_zero_rows_exits_nonzero_and_marks_nothing_stale(tmp_db, monkeypatch):
|
|||||||
{"model_id": "model_beta"},
|
{"model_id": "model_beta"},
|
||||||
], last_updated_days_ago=10)
|
], last_updated_days_ago=10)
|
||||||
|
|
||||||
monkeypatch_mock = MagicMock(side_effect=poller.mark_stale)
|
|
||||||
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
monkeypatch.setattr(poller, "load_config", lambda p: cfg)
|
||||||
monkeypatch.setattr(poller.requests, "get", lambda url, timeout: _fake_response({"data": []}))
|
monkeypatch.setattr(poller.requests, "get", lambda url, timeout: _fake_response({"data": []}))
|
||||||
monkeypatch.setattr(poller, "mark_stale", monkeypatch_mock)
|
|
||||||
|
|
||||||
with pytest.raises(poller.CatalogTooSmall):
|
exit_code = poller.main()
|
||||||
poller.main()
|
|
||||||
|
|
||||||
total = conn.execute(
|
total = conn.execute(
|
||||||
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt'"
|
"SELECT COUNT(*) FROM models WHERE provider='neuralwatt'"
|
||||||
@@ -176,7 +178,6 @@ def test_zero_rows_exits_nonzero_and_marks_nothing_stale(tmp_db, monkeypatch):
|
|||||||
|
|
||||||
assert total == 2
|
assert total == 2
|
||||||
assert stale == 0
|
assert stale == 0
|
||||||
assert monkeypatch_mock.call_count == 0, "mark_stale must not be called"
|
|
||||||
|
|
||||||
|
|
||||||
def test_below_half_warns_but_proceeds(tmp_db, monkeypatch):
|
def test_below_half_warns_but_proceeds(tmp_db, monkeypatch):
|
||||||
@@ -336,10 +337,11 @@ def test_previously_stale_row_recovers(tmp_db, monkeypatch):
|
|||||||
assert row["availability"] == "active"
|
assert row["availability"] == "active"
|
||||||
|
|
||||||
|
|
||||||
def test_request_exception_marks_nothing(tmp_db, monkeypatch):
|
def test_request_exception_skip_provider(tmp_db, monkeypatch):
|
||||||
"""(e) RequestException during fetch -> no upserts, no stale marking.
|
"""(e) RequestException during fetch -> skip provider, DB untouched.
|
||||||
|
|
||||||
Existing rows stay exactly as they were.
|
With multi-provider main, a fetch error skips the provider rather than
|
||||||
|
aborting the whole run. Existing rows stay exactly as they were.
|
||||||
"""
|
"""
|
||||||
conn, cfg = tmp_db
|
conn, cfg = tmp_db
|
||||||
_seed_models(conn, [
|
_seed_models(conn, [
|
||||||
@@ -365,7 +367,7 @@ def test_request_exception_marks_nothing(tmp_db, monkeypatch):
|
|||||||
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='neuralwatt'"
|
"SELECT COUNT(*) FROM models WHERE availability='stale' AND provider='neuralwatt'"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
assert exit_code == 1
|
assert exit_code == 0
|
||||||
assert total == 2
|
assert total == 2
|
||||||
assert stale == 0
|
assert stale == 0
|
||||||
neuralwatt_active = conn.execute(
|
neuralwatt_active = conn.execute(
|
||||||
|
|||||||
Reference in New Issue
Block a user