commit cbc385f6c7c2b3c431e70e04892765b6240b7ebe Author: infosave2007 Date: Tue Jun 30 10:51:37 2026 +0300 feat: universal LLM gateway with embedded multilingual admin console - OpenAI-compatible gateway with intelligent routing via cortiq-router (allaigate) - Embedded build-free admin SPA at /admin: 7 languages (en/ru/de/fr/es/zh/tr), light/dark themes, hot config reload (no restart) - Admin API, per-request statistics + Prometheus /metrics, secret store, playground - English documentation (primary) with Russian translations (*.ru.md) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f1c3d60 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize line endings +* text=auto eol=lf + +# The embedded admin SPA is first-party but ships as a UI asset; exclude it from +# GitHub's language statistics so the repo is correctly identified as Rust. +web/** linguist-vendored +docs/screenshots/** linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ac1ea5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Build + run: cargo build --release + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d28ac0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/target +**/*.rs.bk +# Cargo.lock is committed: this is an application (binary), so we want reproducible builds. +config/gateway.toml +config/secrets.toml +config/stats.jsonl +config/*.toml.tmp +.env +*.log +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad6d53f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +### Added +- **Embedded admin web console** at `/admin` — a build-free single-page app compiled + into the binary via `rust-embed`. Manage models, routing, protocols, virtual keys + and secrets from the browser; changes are hot-reloaded (no restart). +- **Multilingual UI** in 7 languages (en, ru, de, fr, es, zh, tr) with browser + auto-detection, plus light/dark themes. +- **Admin API** under `/admin/api/*` (Bearer admin token): config, models (CRUD + + probe), routing, protocols, settings, keys, secrets, stats, requests, and a + playground `test` endpoint. +- **Hot config reload** — config-derived state lives behind `arc-swap`; writes + validate → atomically rewrite the TOML → swap, with no restart. +- **Request statistics** — in-memory aggregates, per-minute time buckets, and a ring + buffer of recent requests, appended to a JSONL file and replayed on startup. +- **Prometheus `GET /metrics`** endpoint. +- **Secret store** (`config/secrets.toml`, mode `0600`) — provider keys can be set + from the UI without writing them into `gateway.toml`; values are never returned by + the API. +- English documentation as the primary language, with Russian translations + (`README.ru.md`, `docs/*.ru.md`). + +### Changed +- The Anthropic provider now returns a clear error instead of panicking when called + (the adapter is still planned for v0.2). + +## [0.0.1] +- Initial skeleton: OpenAI Chat Completions inbound adapter, OpenAI-compatible + outbound provider, routing table with fallback and graceful degradation, and + cost/token accounting. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5dbd99b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +*Read this in [Русский](CONTRIBUTING.ru.md).* + +# Contributing to Cortiq Gateway + +Thank you for your interest in the project! This is an open gateway and we welcome contributions. + +## Getting started + +1. Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — design overview and roadmap. +2. Review [docs/PROTOCOLS.md](docs/PROTOCOLS.md) and [docs/ROUTING.md](docs/ROUTING.md). +3. The current phase is **Phase 0** (skeleton). Good entry-point tasks are labeled + `good first issue`. + +## How to add... + +- **A new inbound protocol** — implement the `InboundProtocol` trait in `src/protocols/`, + translate to/from the canonical model (`ChatRequest`/`ChatResponse`), and add the + corresponding flag in `[protocols]`. Do not touch the providers. +- **A new provider** — implement the `Provider` trait in `src/providers/`. Most local + servers are OpenAI-compatible, so reusing `openai.rs` is often sufficient. +- **A new routing policy** — extend `src/routing.rs` and the `[routing.policy]` section. + +Core principle: N protocols × M providers communicate **only** through the canonical model — +no direct protocol↔provider translations. + +## Style and checks + +```bash +cargo fmt --all +cargo clippy --all-targets -- -D warnings +cargo test +``` + +- Match the style of the surrounding code. +- Secrets go only through `*_env` variables — never commit them to the repository. +- New functionality must be accompanied by tests and updates to `docs/`. + +## Pull requests + +- Keep PRs small and focused. One PR = one logical unit of change. +- In the description: what, why, and how it was tested. +- Link the related issue if one exists. + +## License + +Contributions are accepted under [Apache-2.0](LICENSE). diff --git a/CONTRIBUTING.ru.md b/CONTRIBUTING.ru.md new file mode 100644 index 0000000..5f9d88f --- /dev/null +++ b/CONTRIBUTING.ru.md @@ -0,0 +1,45 @@ +*Read this in [English](CONTRIBUTING.md).* + +# Contributing to Cortiq Gateway + +Спасибо за интерес к проекту! Это открытый шлюз, и мы рады контрибьюциям. + +## С чего начать + +1. Прочитайте [docs/ARCHITECTURE.md](docs/ARCHITECTURE.ru.md) — устройство и дорожная карта. +2. Посмотрите [docs/PROTOCOLS.md](docs/PROTOCOLS.ru.md) и [docs/ROUTING.md](docs/ROUTING.ru.md). +3. Текущая фаза — **Phase 0** (скелет). Хорошие первые задачи помечены `good first issue`. + +## Как добавить... + +- **Новый входящий протокол** — реализуйте трейт `InboundProtocol` в `src/protocols/`, + переведите его в/из канонической модели (`ChatRequest`/`ChatResponse`), добавьте флаг + в `[protocols]`. Не трогайте провайдеры. +- **Нового провайдера** — реализуйте трейт `Provider` в `src/providers/`. Большинство + локальных серверов OpenAI-совместимы → часто достаточно переиспользовать `openai.rs`. +- **Новую политику роутинга** — расширьте `src/routing.rs` и секцию `[routing.policy]`. + +Принцип: N протоколов × M провайдеров общаются **только** через каноническую модель — +никаких прямых переводов «протокол↔провайдер». + +## Стиль и проверки + +```bash +cargo fmt --all +cargo clippy --all-targets -- -D warnings +cargo test +``` + +- Пишите код в стиле окружающего кода. +- Секреты — только через `*_env`, никогда в репозиторий. +- Новый функционал сопровождайте тестами и обновлением docs/. + +## Pull requests + +- Небольшие, сфокусированные PR. Один PR — одна логическая единица. +- В описании: что, зачем, как протестировано. +- Линкуйте issue, если есть. + +## Лицензия + +Контрибьюции принимаются под [Apache-2.0](LICENSE). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7be3006 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2162 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cortiq-gateway" +version = "0.0.1" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "axum", + "bytes", + "clap", + "futures", + "mime_guess", + "reqwest", + "rust-embed", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a11152 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "cortiq-gateway" +version = "0.0.1" +edition = "2021" +license = "Apache-2.0" +description = "Universal LLM gateway with intelligent routing via cortiq-router" +repository = "https://github.com/infosave2007/cortiq-gateway" +readme = "README.md" +keywords = ["llm", "gateway", "proxy", "openai", "routing"] +categories = ["web-programming::http-server"] + +[dependencies] +# async runtime + HTTP server +tokio = { version = "1", features = ["full"] } +axum = { version = "0.7", features = ["macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["limit", "trace", "cors"] } + +# HTTP client to providers and to cortiq-router (rustls, no OpenSSL) +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } + +# serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +# hot-reloadable shared state + embedded admin SPA +arc-swap = "1" +rust-embed = "8" +mime_guess = "2" + +# streaming / async utils +futures = "0.3" +async-trait = "0.1" +bytes = "1" +tokio-stream = "0.1" + +# CLI / config +clap = { version = "4", features = ["derive"] } + +# errors / logging / metrics +thiserror = "1" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..818aaf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,179 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Cortiq + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c7ac82 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# Cortiq Gateway + +**English** · [Русский](README.ru.md) + +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +![Rust](https://img.shields.io/badge/Rust-2021-orange.svg) +![Status](https://img.shields.io/badge/status-active-success.svg) + +**A universal LLM gateway with intelligent routing.** +One OpenAI-compatible endpoint → automatic model selection from your pool +(cheap local + expensive hosted) based on the task type and complexity decided by +[allaigate / cortiq-router](https://api.allaigate.com). + +> In your agent/SDK you only change `base_url` — and you get "smart" routing across +> models. No model-selection logic on the client side. + +``` +┌─────────────┐ OpenAI / Anthropic / MCP ┌──────────────────┐ +│ Agent / │ ─────────────────────────────▶│ Cortiq Gateway │ +│ developer │ (standard protocol) │ (this project) │ +└─────────────┘ ◀─────────────────────────────└──────────────────┘ + response + metadata │ │ + │ /v1/route│ call LLM + ▼ ▼ + ┌──────────────┐ ┌─────────────────────┐ + │ cortiq-router│ │ Model pool │ + │ (task type, │ │ • local llama.cpp │ + │ complexity) │ │ • ollama / vLLM │ + └──────────────┘ │ • OpenAI / Claude │ + └─────────────────────┘ +``` + +--- + +## ✨ Highlights + +- **Drop-in OpenAI API.** Point any OpenAI client at the gateway and send `model: "cortiq-auto"`. +- **Intelligent routing.** `complexity.tier` → ordered model pool (`low → local`, `high → cloud`), with fallback and graceful degradation when the router is down. +- **Built-in multilingual admin console.** Manage models, routing, protocols, keys and secrets from a web UI — **no hand-editing TOML, no restart** (changes are hot-reloaded). Available in **7 languages** (en, ru, de, fr, es, zh, tr) with light/dark themes. +- **Live analytics.** Per-request stats (tokens, cost, latency, success rate, failovers), time-series charts, breakdowns by model/tier/task, and a Prometheus `GET /metrics` endpoint. +- **Playground.** Send a prompt through the live pipeline and inspect the routing decision. +- **Single self-contained binary.** The SPA is embedded into the Rust binary — nothing extra to deploy. +- **Secrets stay in the gateway.** Agents hold only a virtual gateway key; provider keys live in the gateway and never leak to clients. + +--- + +## 🖥️ Admin console + +![Dashboard](docs/screenshots/dashboard.png) + +The gateway ships with an embedded web console at **`/admin`**: + +| | | +|---|---| +| ![Models](docs/screenshots/models.png) | ![Routing](docs/screenshots/routing.png) | +| **Models** — add/edit/probe models, manage provider keys | **Routing** — visual tier editor (ordered, drag-to-reorder) | +| ![Playground](docs/screenshots/playground.png) | ![Dashboard](docs/screenshots/dashboard.png) | +| **Playground** — test the live pipeline, see the decision | **Dashboard** — traffic, cost, latency analytics | + +```bash +cargo run --release -- --config config/gateway.toml --admin-token +# open http://localhost:9000/admin?token= +``` + +If `--admin-token` / `[admin].token_env` are not set, a token is generated at startup +and printed to the log. All `/admin/api/*` endpoints require a Bearer admin token; +secret values are never returned by the API (only their presence: `store` / `env` / `missing`). + +--- + +## 🚀 Quick start + +```bash +# 1. start a router (the hosted allaigate router, or a local cortiq-router) +# it listens e.g. on http://localhost:8080 (or https://api.allaigate.com) + +# 2. describe your model pool +cp config/gateway.example.toml config/gateway.toml +$EDITOR config/gateway.toml + +# 3. run the gateway +cargo run --release -- --config config/gateway.toml +# Gateway listens on 0.0.0.0:9000 and serves the admin console at /admin +``` + +Now any OpenAI client works through the gateway: + +```python +from openai import OpenAI +client = OpenAI(base_url="http://localhost:9000/v1", api_key="sk-gw-...") + +resp = client.chat.completions.create( + model="cortiq-auto", # ← magic model = "choose for me" + messages=[{"role": "user", "content": "Solve x^2 - 5x + 6 = 0"}], +) +print(resp.choices[0].message.content) +# response headers show what the gateway picked: +# X-Cortiq-Task-Label: math +# X-Cortiq-Complexity-Tier: low +# X-Cortiq-Selected-Model: local-qwen +``` + +`model: "cortiq-auto"` enables routing. Any **real** model name from the config +(`"gpt-4o-mini"`, `"local-qwen"`) is a direct passthrough, without routing. + +> **Using the hosted allaigate router?** Set `url = "https://138.226.222.209"`, +> `verify_tls = false`, `taxonomy_id = "data-assistant"`, and a `cortiq_…` key in +> `CORTIQ_ROUTER_KEY`. On hard prompts the router escalates to an oracle (~10 s), so +> set `timeout_ms = 12000+` — otherwise the gateway gracefully degrades to the default model. + +--- + +## Request flow + +1. Client → `POST /v1/chat/completions` (or Anthropic/MCP) with `model: "cortiq-auto"`. +2. The gateway extracts the **text to route on** (strategy is configurable, see [docs/ROUTING.md](docs/ROUTING.md)). +3. Gateway → `cortiq-router /v1/route` → gets `task_label` + `complexity.tier`. +4. Gateway selects a model from the pool via the routing table (with a fallback order). +5. Gateway → the selected model's provider (translating the protocol if needed), returns the response. +6. Gateway attaches routing metadata in headers/`usage` and records cost & statistics. + +Details — [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +--- + +## Supported protocols (inbound) + +Each adapter is toggled in the config — take only what you need. + +| Protocol | Endpoint | Status | +|---|---|---| +| OpenAI Chat Completions | `POST /v1/chat/completions` | ✅ implemented | +| OpenAI Completions (legacy) | `POST /v1/completions` | planned | +| OpenAI Embeddings | `POST /v1/embeddings` | planned | +| OpenAI Models | `GET /v1/models` | planned | +| Anthropic Messages | `POST /v1/messages` | planned | +| MCP (Model Context Protocol) | JSON-RPC / SSE | planned | +| Native passthrough | `POST /route` | planned | + +## Supported providers (outbound) + +| Provider | Covers | +|---|---| +| `openai` (OpenAI-compatible) | OpenAI, OpenRouter, Together, Groq, **vLLM, llama.cpp server, LM Studio, Ollama (`/v1`)** | +| `anthropic` | Claude Messages API (planned) | +| `ollama` | native Ollama API | +| `http` | arbitrary HTTP endpoint (custom adapter) | + +Most local servers already expose an OpenAI-compatible API — so the `openai` +adapter makes the gateway **universal** with almost no effort. + +--- + +## Configuration + +```toml +listen = "0.0.0.0:9000" + +[router] +url = "http://localhost:8080" +api_key_env = "CORTIQ_ROUTER_KEY" + +[[models]] +id = "local-qwen" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "qwen2.5-7b-instruct" +cost_tier = "cheap" + +[[models]] +id = "claude-opus" +provider = "anthropic" +base_url = "https://api.anthropic.com" +model = "claude-opus-4" +api_key_env = "ANTHROPIC_API_KEY" +cost_tier = "expensive" + +[routing] +low = ["local-qwen"] +high = ["claude-opus", "local-qwen"] +default = "local-qwen" +``` + +Full example — [config/gateway.example.toml](config/gateway.example.toml). +Routing & cost-aware selection — [docs/ROUTING.md](docs/ROUTING.md). +Everything in the config is also editable from the admin console at runtime. + +--- + +## Cross-standards (like grown-up APIs) + +- **Auth:** `Authorization: Bearer sk-gw-...` (virtual gateway keys). +- **Errors:** OpenAI-compatible `{"error": {...}}` envelope — parsed natively by SDKs. +- **Routing metadata:** `X-Cortiq-*` headers on every response. +- **Cost accounting:** token usage + USD cost and the selected model. +- **Observability:** `GET /metrics` (Prometheus), `GET /healthz`, `GET /readyz`. + +Details — [docs/PROTOCOLS.md](docs/PROTOCOLS.md). + +--- + +## Build & test + +```bash +cargo build --release # single self-contained binary (SPA embedded) +cargo test # config round-trip, routing validation +``` + +--- + +## Status + +🚧 Implemented so far: OpenAI Chat Completions, routing with fallback & graceful +degradation, cost/token accounting, the **embedded multilingual admin console with +hot config reload**, statistics and Prometheus `GET /metrics`. Planned: streaming +(SSE), Anthropic provider/inbound, embeddings, MCP. See the roadmap in +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Contributions welcome — see +[CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..6080a8c --- /dev/null +++ b/README.ru.md @@ -0,0 +1,177 @@ +# Cortiq Gateway + +[English](README.md) · **Русский** + +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +![Rust](https://img.shields.io/badge/Rust-2021-orange.svg) +![Status](https://img.shields.io/badge/status-active-success.svg) + +**Универсальный LLM-шлюз с интеллектуальной маршрутизацией.** +Один OpenAI-совместимый endpoint → автоматический выбор модели из вашего пула +(локальные дешёвые + хостовые дорогие) на основе типа задачи и сложности, которые +определяет [allaigate / cortiq-router](https://api.allaigate.com). + +> Меняете в своём агенте/SDK только `base_url` — и получаете «умный» роутинг между +> моделями. Никакой логики выбора модели на стороне клиента. + +``` +┌─────────────┐ OpenAI / Anthropic / MCP ┌──────────────────┐ +│ Агент / │ ─────────────────────────────▶│ Cortiq Gateway │ +│ разработчик │ (стандартный протокол) │ (этот проект) │ +└─────────────┘ ◀─────────────────────────────└──────────────────┘ + ответ + метаданные │ │ + │ /v1/route│ вызов LLM + ▼ ▼ + ┌──────────────┐ ┌─────────────────────┐ + │ cortiq-router│ │ Пул моделей │ + │ (тип задачи, │ │ • local llama.cpp │ + │ сложность) │ │ • ollama / vLLM │ + └──────────────┘ │ • OpenAI / Claude │ + └─────────────────────┘ +``` + +--- + +## ✨ Ключевое + +- **Drop-in OpenAI API.** Направьте любой OpenAI-клиент на шлюз и шлите `model: "cortiq-auto"`. +- **Интеллектуальный роутинг.** `complexity.tier` → упорядоченный пул моделей (`low → локально`, `high → облако`), с fallback и мягкой деградацией, если роутер недоступен. +- **Встроенная многоязычная панель управления.** Модели, роутинг, протоколы, ключи и секреты — из веб-интерфейса, **без ручного TOML и без рестарта** (горячая перезагрузка). **7 языков** (en, ru, de, fr, es, zh, tr), тёмная/светлая тема. +- **Живая аналитика.** Статистика по запросам (токены, стоимость, латентность, доля успеха, failover), графики, разбивка по моделям/полосам/задачам и Prometheus `GET /metrics`. +- **Playground.** Прогон промпта через живой пайплайн с разбором решения роутинга. +- **Один самодостаточный бинарь.** SPA встроена в Rust-бинарь — ничего лишнего разворачивать не нужно. +- **Секреты не покидают шлюз.** Агенты держат только виртуальный ключ шлюза; ключи провайдеров живут в шлюзе и наружу не уходят. + +--- + +## 🖥️ Панель управления + +![Дашборд](docs/screenshots/dashboard.png) + +В шлюз встроена веб-панель на **`/admin`**: + +| | | +|---|---| +| ![Модели](docs/screenshots/models.png) | ![Роутинг](docs/screenshots/routing.png) | +| **Models** — добавление/правка/probe моделей, ключи провайдеров | **Routing** — визуальный редактор полос (упорядоченный) | +| ![Playground](docs/screenshots/playground.png) | ![Дашборд](docs/screenshots/dashboard.png) | +| **Playground** — тест живого пайплайна с решением роутинга | **Dashboard** — трафик, стоимость, латентность | + +```bash +cargo run --release -- --config config/gateway.toml --admin-token <ВАШ_ТОКЕН> +# откройте http://localhost:9000/admin?token=<ВАШ_ТОКЕН> +``` + +Если `--admin-token` / `[admin].token_env` не заданы — токен генерируется при старте и +печатается в лог. Все эндпоинты `/admin/api/*` требуют Bearer admin-токен; значения +секретов API наружу не отдаёт (только статус наличия: `store` / `env` / `missing`). + +--- + +## 🚀 Быстрый старт + +```bash +# 1. поднимите роутер (хостовый allaigate или локальный cortiq-router) +# он слушает, например, http://localhost:8080 (или https://api.allaigate.com) + +# 2. опишите свой пул моделей +cp config/gateway.example.toml config/gateway.toml +$EDITOR config/gateway.toml + +# 3. запустите шлюз +cargo run --release -- --config config/gateway.toml +# Шлюз слушает 0.0.0.0:9000 и отдаёт панель на /admin +``` + +Теперь любой OpenAI-клиент работает через шлюз: + +```python +from openai import OpenAI +client = OpenAI(base_url="http://localhost:9000/v1", api_key="sk-gw-...") + +resp = client.chat.completions.create( + model="cortiq-auto", # ← магическая модель = «выбери сам» + messages=[{"role": "user", "content": "Solve x^2 - 5x + 6 = 0"}], +) +print(resp.choices[0].message.content) +# заголовки ответа покажут выбор шлюза: +# X-Cortiq-Task-Label: math +# X-Cortiq-Complexity-Tier: low +# X-Cortiq-Selected-Model: local-qwen +``` + +`model: "cortiq-auto"` включает роутинг. Любое **реальное** имя модели из конфига +(`"gpt-4o-mini"`, `"local-qwen"`) — это passthrough напрямую, без роутинга. + +> **Используете хостовый allaigate-роутер?** Укажите `url = "https://138.226.222.209"`, +> `verify_tls = false`, `taxonomy_id = "data-assistant"` и ключ `cortiq_…` в +> `CORTIQ_ROUTER_KEY`. На сложных запросах роутер эскалирует к oracle (~10 с), поэтому +> ставьте `timeout_ms = 12000+` — иначе шлюз мягко деградирует на default-модель. + +--- + +## Поток запроса + +1. Клиент → `POST /v1/chat/completions` (или Anthropic/MCP) с `model: "cortiq-auto"`. +2. Шлюз извлекает **текст для маршрутизации** (стратегия настраивается, см. [docs/ROUTING.ru.md](docs/ROUTING.ru.md)). +3. Шлюз → `cortiq-router /v1/route` → получает `task_label` + `complexity.tier`. +4. Шлюз выбирает модель из пула по таблице роутинга (с порядком fallback). +5. Шлюз → провайдер выбранной модели (транслируя протокол при необходимости), возвращает ответ. +6. Шлюз отдаёт метаданные роутинга в заголовках/`usage`, считает стоимость и статистику. + +Подробно — [docs/ARCHITECTURE.ru.md](docs/ARCHITECTURE.ru.md). + +--- + +## Поддерживаемые протоколы (входящие) + +| Протокол | Endpoint | Статус | +|---|---|---| +| OpenAI Chat Completions | `POST /v1/chat/completions` | ✅ реализовано | +| OpenAI Completions (legacy) | `POST /v1/completions` | план | +| OpenAI Embeddings | `POST /v1/embeddings` | план | +| OpenAI Models | `GET /v1/models` | план | +| Anthropic Messages | `POST /v1/messages` | план | +| MCP (Model Context Protocol) | JSON-RPC / SSE | план | +| Native passthrough | `POST /route` | план | + +## Поддерживаемые провайдеры (исходящие) + +| Провайдер | Покрывает | +|---|---| +| `openai` (OpenAI-совместимый) | OpenAI, OpenRouter, Together, Groq, **vLLM, llama.cpp server, LM Studio, Ollama (`/v1`)** | +| `anthropic` | Claude Messages API (план) | +| `ollama` | нативный Ollama API | +| `http` | произвольный HTTP-эндпоинт (свой адаптер) | + +--- + +## Конфигурация + +Полный пример — [config/gateway.example.toml](config/gateway.example.toml). +Модель роутинга и cost-aware выбор — [docs/ROUTING.ru.md](docs/ROUTING.ru.md). +Всё из конфига также правится из панели управления в рантайме. + +--- + +## Сборка и тесты + +```bash +cargo build --release # один самодостаточный бинарь (SPA встроена) +cargo test # round-trip конфига, валидация роутинга +``` + +--- + +## Статус + +🚧 Уже работает: OpenAI Chat Completions, роутинг с fallback и мягкой деградацией, +учёт стоимости/токенов, **встроенная многоязычная панель управления с горячей +перезагрузкой конфига**, статистика и Prometheus `GET /metrics`. В планах: стриминг +(SSE), провайдер/вход Anthropic, embeddings, MCP. Дорожная карта — +[docs/ARCHITECTURE.ru.md](docs/ARCHITECTURE.ru.md). Контрибьюции приветствуются — +см. [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md). + +## Лицензия + +Apache-2.0 — см. [LICENSE](LICENSE). diff --git a/config/gateway.example.toml b/config/gateway.example.toml new file mode 100644 index 0000000..776a7b0 --- /dev/null +++ b/config/gateway.example.toml @@ -0,0 +1,152 @@ +# ============================================================================ +# Cortiq Gateway — example configuration +# Copy to gateway.toml and edit for your own model pool. +# Do NOT store secrets here — provide them via *_env (environment variables) +# or set them from the admin console (stored in config/secrets.toml). +# ============================================================================ + +listen = "0.0.0.0:9000" + +# --- connection to cortiq-router (decides task type / complexity) ----------- +# Hosted allaigate router: url = "https://138.226.222.209", verify_tls = false +# (cert is bound to an IP), taxonomy_id = "data-assistant", a cortiq_… key in +# CORTIQ_ROUTER_KEY. +# NOTE: on hard prompts the router escalates to an oracle LLM (~10 s) — for the +# hosted router set timeout_ms = 12000+, otherwise the gateway degrades to default. +[router] +url = "http://localhost:8080" +api_key_env = "CORTIQ_ROUTER_KEY" # name of the env var holding the router key +timeout_ms = 800 # timeout for the /v1/route call (12000+ for oracle) +taxonomy_id = "general.task-type" # for allaigate: "data-assistant" +verify_tls = true # false if the router uses an IP / self-signed cert + +# --- how to pick the text to classify from the conversation ----------------- +[route] +text_strategy = "last_user" # last_user | last_user_plus_system | concat_all +max_chars = 4000 +cache_ttl = "60s" +profile = "balanced" # cost-saver | balanced | quality-first + +# ============================================================================ +# Model pool: mix cheap local models and expensive cloud ones. +# provider = "openai" covers OpenAI, OpenRouter, Together, Groq, and also +# LOCAL servers vLLM / llama.cpp / LM Studio / Ollama (/v1). +# ============================================================================ + +[[models]] +id = "local-qwen" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "qwen2.5-7b-instruct" +cost_tier = "cheap" +price_in = 0.0 +price_out = 0.0 +caps = ["tools"] + +[[models]] +id = "gpt-4o-mini" +provider = "openai" +base_url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key_env = "OPENAI_API_KEY" +cost_tier = "mid" +price_in = 0.15 +price_out = 0.60 +caps = ["tools", "vision"] + +[[models]] +id = "claude-opus" +provider = "anthropic" +base_url = "https://api.anthropic.com" +model = "claude-opus-4" +api_key_env = "ANTHROPIC_API_KEY" +cost_tier = "expensive" +price_in = 15.0 +price_out = 75.0 +caps = ["tools", "vision"] + +# --- embedding model (for /v1/embeddings in provider mode) ------------------ +[[models]] +id = "local-embed" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "nomic-embed-text" +cost_tier = "cheap" +kind = "embedding" + +# ============================================================================ +# Routing table: complexity tier → ordered pool (priority = fallback chain) +# ============================================================================ +[routing] +low = ["local-qwen"] +medium = ["gpt-4o-mini", "local-qwen"] +high = ["claude-opus", "gpt-4o-mini"] +default = "gpt-4o-mini" # used when the router is unreachable + +[routing.policy] +mode = "fixed_table" # fixed_table | cost_aware +# for cost_aware: +# min_class = { low = "cheap", medium = "mid", high = "expensive" } +# max_cost_usd_per_request = 0.50 + +# --- resilience ------------------------------------------------------------- +[breaker] +threshold = 5 +cooldown = "30s" + +# ============================================================================ +# Inbound protocols — enable only what you need (toggles apply live in the UI) +# ============================================================================ +[protocols] +openai_chat = true +openai_completions = true +openai_embeddings = true +openai_models = true +anthropic_messages = false +mcp = false +native_passthrough = true + +# --- response routing metadata ---------------------------------------------- +[cortiq] +echo = true # add a "cortiq" field to the response body (X-Cortiq-* headers are always sent) + +# ============================================================================ +# Authentication: virtual gateway keys (agents get these, not provider keys). +# If this section is empty, the gateway runs in open mode (local development). +# ============================================================================ +[[api_keys]] +key = "sk-gw-team-a" +account = "team-a" +rate_per_min = 600 +# allow_models = ["cortiq-auto", "local-qwen"] # restrict available models (optional) + +# --- idempotency and logging ------------------------------------------------ +[idempotency] +ttl = "10m" + +[log] +bodies = false # do NOT log message bodies (privacy) — default +level = "info" + +[telemetry] +metrics = true # GET /metrics (Prometheus) +otlp_endpoint_env = "OTEL_EXPORTER_OTLP_ENDPOINT" # optional + +# ============================================================================ +# Admin web console — embedded in the binary, served at /admin +# ============================================================================ +[admin] +enabled = true +# Name of the env var holding the admin token. If unset (or empty), a token is +# generated at startup and printed to the log (see also the --admin-token flag). +token_env = "CORTIQ_ADMIN_TOKEN" +# listen = "127.0.0.1:9001" # optional: separate bind for admin (default: main listen) + +# ============================================================================ +# Request statistics (dashboard + /metrics). Kept in memory + appended to JSONL. +# ============================================================================ +[stats] +enabled = true +file = "config/stats.jsonl" # empty = in-memory only +ring_size = 500 # how many recent requests to keep in the feed +retention = "7d" # aggregate/series depth (replayed on startup) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..12c8236 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,205 @@ +*Read this in [Русский](ARCHITECTURE.ru.md).* + +# Cortiq Gateway Architecture + +This document describes the internal design of the gateway, request flow, abstractions, and +roadmap. Related documents: [PROTOCOLS.md](PROTOCOLS.md) (protocol contracts), +[ROUTING.md](ROUTING.md) (model selection and cost model). + +--- + +## 1. Purpose and Scope + +**Cortiq Gateway** is an L7 proxy that: + +1. accepts requests in one of the **standard protocols** (OpenAI, Anthropic, MCP); +2. asks **cortiq-router** what type of task it is and how complex; +3. selects a specific model from the **provider pool** (local + cloud); +4. calls the provider, **translating the protocol** when necessary; +5. returns the response to the client (streaming and non-streaming), adding routing metadata; +6. tracks cost and (optionally) closes the feedback loop back to the router. + +What the gateway does **not** do: it does not store or train models (that is the router's +job), and it does not implement the LLMs themselves (that is the providers' job). The gateway +is thin, stateless (except for idempotency caches and rate-limit state), and horizontally +scalable. + +--- + +## 2. Component Diagram + +``` + ┌───────────────────────────────────────────────┐ + │ Cortiq Gateway │ + │ │ + client (any protocol) │ ┌──────────────┐ ┌───────────────────┐ │ + ───────────────────────▶│──▶│ Inbound │──▶│ Core pipeline │ │ + │ │ Protocol │ │ │ │ + │ │ Adapters │ │ 1. extract text │ │ + │ │ (openai/ │ │ 2. route() │────┼──▶ cortiq-router + │ │ anthropic/ │ │ 3. select model │ │ /v1/route + │ │ mcp) │ │ 4. translate │ │ + │ └──────────────┘ │ 5. call provider │────┼──▶ Provider pool + │ │ 6. stream back │ │ (openai/anthropic/ + │ ┌──────────────┐ │ 7. usage/cost │◀───┼── ollama/http) + │ │ Cross-cutting│ │ 8. feedback (opt)│────┼──▶ cortiq-router + │ │ auth · rate │ └───────────────────┘ │ /v1/feedback + │ │ idem · trace │ │ + │ └──────────────┘ │ + └───────────────────────────────────────────────┘ +``` + +Code modules (`src/`): + +| Module | Responsibility | +|---|---| +| `main.rs` | config loading, route registration for enabled protocols, server startup | +| `config.rs` | TOML parsing: model pool, routing table, protocols, keys | +| `router_client.rs` | client for `cortiq-router` (`/v1/route`, `/v1/feedback`) | +| `registry.rs` | model pool registry + `id → provider+endpoint` resolution | +| `routing.rs` | `(label, tier) → ordered model list`; fallback/default | +| `pipeline.rs` | core: extract → route → select → translate → call → stream | +| `providers/mod.rs` | `Provider` trait (unified LLM call interface) | +| `providers/openai.rs` | OpenAI-compatible adapter (covers vLLM/llama.cpp/ollama/openrouter) | +| `providers/anthropic.rs` | Anthropic Messages adapter | +| `protocols/mod.rs` | inbound adapter trait + shared types for the canonical message model | +| `protocols/openai_chat.rs` | `/v1/chat/completions` ↔ canonical model | +| `protocols/anthropic_messages.rs` | `/v1/messages` ↔ canonical model | +| `protocols/mcp.rs` | MCP server (JSON-RPC), routing exposed as a tool | +| `auth.rs` | virtual keys, rate limiting, quotas | +| `telemetry.rs` | Prometheus metrics, OpenTelemetry spans, request IDs | +| `error.rs` | unified error type + rendering into OpenAI error envelope | + +--- + +## 3. Canonical Model (internal neutral format) + +To avoid N incoming protocols × M providers turning into N×M translation paths, +everything is normalized internally to a **single canonical request/response model**. + +``` +ChatRequest { + routing: RoutingDirective, // auto | pinned(model_id) | profile + messages: Vec, // role + content (+ tool_calls) + tools: Vec, + params: GenParams, // temperature, max_tokens, top_p, stop, ... + stream: bool, + meta: RequestMeta, // idempotency key, traceparent, account +} + +ChatResponse { + id, model_used, choices, usage, finish_reason, cortiq: RouteInfo +} +``` + +- Inbound adapter: `protocol-specific → ChatRequest` and `ChatResponse → protocol-specific`. +- Outbound provider: `ChatRequest → provider wire` and `provider wire → ChatResponse`. + +Adding a new protocol = one inbound adapter. Adding a new provider = one outbound adapter. +No combinatorial explosion. + +--- + +## 4. Extension Traits + +```rust +// inbound protocol +#[async_trait] +trait InboundProtocol { + fn routes(&self) -> Router; // axum routes for this protocol + fn name(&self) -> &'static str; // "openai_chat", ... +} + +// outbound provider +#[async_trait] +trait Provider: Send + Sync { + fn id(&self) -> &str; + async fn chat(&self, req: ChatRequest) -> Result; + async fn chat_stream(&self, req: ChatRequest) -> Result; // SSE stream + async fn embed(&self, req: EmbedRequest) -> Result; + fn capabilities(&self) -> Caps; // tools? streaming? vision? +} +``` + +The registry (`registry.rs`) holds a `HashMap>` built +from `[[models]]`. The routing table (`routing.rs`) operates on `model_id`. + +--- + +## 5. `route → select → call` Flow in Detail + +1. **Extract.** Text for classification is taken from `messages`. Strategy from config: + `last_user` (default) | `concat_all` | `last_user_plus_system`. Long text is + truncated to `route.max_chars` (protection against cost/DoS). +2. **Route.** `router_client.route(text, profile)` → `{task_label, complexity}`. + Cached by text hash for `route.cache_ttl` (minor savings on repeated requests). +3. **Select.** `routing.select(label, tier)` → ordered `Vec`. + Considers: model health, model `capabilities` (e.g. whether tools are needed), + and the cost policy (see [ROUTING.md](ROUTING.md)). +4. **Translate + Call.** Canonical `ChatRequest` → provider. Streaming is proxied + chunk-by-chunk (SSE → SSE) without buffering. +5. **Failover.** If a provider returns 5xx / timeout / overloaded — the next model + in the list is tried. The circuit breaker marks an unhealthy model to avoid + hammering it. +6. **Degrade.** If cortiq-router is unavailable, `routing.default` is used (soft + degradation — the request does not fail). + +--- + +## 6. Reliability + +| Mechanism | Behavior | +|---|---| +| Timeouts | separate timeouts for router call, provider connect, and provider stream | +| Failover | ordered model list within the tier | +| Circuit breaker | temporarily excludes a model after repeated errors | +| Router down | `routing.default` | +| All tier providers down | OpenAI error `503 upstream_unavailable`, `retriable=true` | +| Idempotency | repeat with the same `Idempotency-Key` returns the stored response | + +--- + +## 7. Security and Multi-tenancy + +- **Key isolation.** Agents hold only a virtual gateway key; provider keys + (`*_API_KEY`) live in the gateway environment and are never exposed externally. +- **Accounts.** Each `[[api_keys]]` entry maps to an account with its own rate limit, + quota, and optionally its own allowed-model subset and routing table. +- **PII.** Redaction is performed in the router before escalation to the oracle; the + gateway can additionally enable logging without request bodies (`log.bodies = false` + by default). +- **Audit.** Every request records: `request_id`, account, selected model, tokens, cost, + and decision path — without the prompt text by default. + +--- + +## 8. Observability + +`GET /metrics` (Prometheus), `GET /healthz`, `GET /readyz`. Metrics: + +``` +gw_requests_total{protocol,account} +gw_route_calls_total # calls to cortiq-router +gw_model_selected_total{model_id,tier} +gw_provider_calls_total{provider,model_id,outcome} +gw_failovers_total +gw_tokens_total{model_id,direction} +gw_cost_usd_total{account,model_id} +gw_latency_seconds{stage} # route | provider | total (histogram) +``` + +OpenTelemetry spans: `inbound → route → provider`, with `traceparent` forwarding. + +--- + +## 9. Roadmap + +| Phase | Contents | +|---|---| +| **v0.1** | OpenAI Chat/Completions/Embeddings/Models, `openai` provider, routing + fallback, auth, metrics, streaming | +| **v0.2** | `anthropic` provider + inbound Anthropic Messages, cost-aware selection, idempotency, circuit breaker | +| **v0.3** | MCP server, native `ollama` provider, OpenTelemetry, routing cache | +| **v0.4** | Per-account routing tables, feedback loop to router, load tests, Helm chart | +| **v1.0** | Contract stabilization, OpenAPI spec, official examples for crewAI/LangChain/Hermes | + +Current phase: **Phase 0** (skeleton + specification in this repository). diff --git a/docs/ARCHITECTURE.ru.md b/docs/ARCHITECTURE.ru.md new file mode 100644 index 0000000..8619865 --- /dev/null +++ b/docs/ARCHITECTURE.ru.md @@ -0,0 +1,202 @@ +*Read this in [English](ARCHITECTURE.md).* + +# Архитектура Cortiq Gateway + +Документ описывает внутреннее устройство шлюза, поток запроса, абстракции и +дорожную карту. Сопутствующие документы: [PROTOCOLS.md](PROTOCOLS.ru.md) (контракты +протоколов), [ROUTING.md](ROUTING.ru.md) (модель выбора моделей и стоимости). + +--- + +## 1. Назначение и границы + +**Cortiq Gateway** — это L7-прокси, который: + +1. принимает запрос в одном из **стандартных протоколов** (OpenAI, Anthropic, MCP); +2. спрашивает **cortiq-router**, какой это тип задачи и насколько он сложный; +3. выбирает конкретную модель из **пула провайдеров** (локальные + облачные); +4. вызывает провайдера, **транслируя протокол** при необходимости; +5. возвращает ответ клиенту (стриминг и без), добавляя метаданные роутинга; +6. учитывает стоимость и (опц.) замыкает петлю обратной связи в роутер. + +Что шлюз **не** делает: не хранит и не обучает модели (это роутер), не реализует +сами LLM (это провайдеры). Шлюз — тонкий, без состояния (кроме кэшей идемпотентности +и rate-limit), горизонтально масштабируется. + +--- + +## 2. Компонентная схема + +``` + ┌───────────────────────────────────────────────┐ + │ Cortiq Gateway │ + │ │ + client (any protocol) │ ┌──────────────┐ ┌───────────────────┐ │ + ───────────────────────▶│──▶│ Inbound │──▶│ Core pipeline │ │ + │ │ Protocol │ │ │ │ + │ │ Adapters │ │ 1. extract text │ │ + │ │ (openai/ │ │ 2. route() │────┼──▶ cortiq-router + │ │ anthropic/ │ │ 3. select model │ │ /v1/route + │ │ mcp) │ │ 4. translate │ │ + │ └──────────────┘ │ 5. call provider │────┼──▶ Provider pool + │ │ 6. stream back │ │ (openai/anthropic/ + │ ┌──────────────┐ │ 7. usage/cost │◀───┼── ollama/http) + │ │ Cross-cutting│ │ 8. feedback (opt)│────┼──▶ cortiq-router + │ │ auth · rate │ └───────────────────┘ │ /v1/feedback + │ │ idem · trace │ │ + │ └──────────────┘ │ + └───────────────────────────────────────────────┘ +``` + +Модули кода (`src/`): + +| Модуль | Ответственность | +|---|---| +| `main.rs` | загрузка конфига, сборка роутов по включённым протоколам, запуск сервера | +| `config.rs` | парсинг TOML: пул моделей, таблица роутинга, протоколы, ключи | +| `router_client.rs` | клиент к `cortiq-router` (`/v1/route`, `/v1/feedback`) | +| `registry.rs` | реестр моделей пула + резолв `id → провайдер+endpoint` | +| `routing.rs` | `(label, tier) → упорядоченный список моделей`; fallback/default | +| `pipeline.rs` | ядро: extract → route → select → translate → call → stream | +| `providers/mod.rs` | трейт `Provider` (унифицированный вызов LLM) | +| `providers/openai.rs` | OpenAI-совместимый адаптер (covers vLLM/llama.cpp/ollama/openrouter) | +| `providers/anthropic.rs` | адаптер Anthropic Messages | +| `protocols/mod.rs` | трейт входящего адаптера + общие типы канонической модели сообщения | +| `protocols/openai_chat.rs` | вход `/v1/chat/completions` ↔ каноническая модель | +| `protocols/anthropic_messages.rs` | вход `/v1/messages` ↔ каноническая модель | +| `protocols/mcp.rs` | MCP-сервер (JSON-RPC), маршрутизация как инструмент | +| `auth.rs` | виртуальные ключи, rate-limit, квоты | +| `telemetry.rs` | метрики Prometheus, OpenTelemetry-спаны, request-id | +| `error.rs` | единый тип ошибки + рендер в OpenAI-конверт | + +--- + +## 3. Каноническая модель (внутренний нейтральный формат) + +Чтобы N входящих протоколов × M провайдеров не превратились в N×M переводов, +внутри всё приводится к **одной канонической модели** запроса/ответа. + +``` +ChatRequest { + routing: RoutingDirective, // auto | pinned(model_id) | profile + messages: Vec, // role + content (+ tool_calls) + tools: Vec, + params: GenParams, // temperature, max_tokens, top_p, stop, ... + stream: bool, + meta: RequestMeta, // idempotency key, traceparent, account +} + +ChatResponse { + id, model_used, choices, usage, finish_reason, cortiq: RouteInfo +} +``` + +- Входящий адаптер: `protocol-specific → ChatRequest` и `ChatResponse → protocol-specific`. +- Исходящий провайдер: `ChatRequest → provider wire` и `provider wire → ChatResponse`. + +Добавить новый протокол = один входящий адаптер. Добавить нового провайдера = +один исходящий адаптер. Никакого комбинаторного взрыва. + +--- + +## 4. Трейты-расширения + +```rust +// входящий протокол +#[async_trait] +trait InboundProtocol { + fn routes(&self) -> Router; // axum-роуты этого протокола + fn name(&self) -> &'static str; // "openai_chat", ... +} + +// исходящий провайдер +#[async_trait] +trait Provider: Send + Sync { + fn id(&self) -> &str; + async fn chat(&self, req: ChatRequest) -> Result; + async fn chat_stream(&self, req: ChatRequest) -> Result; // SSE-поток + async fn embed(&self, req: EmbedRequest) -> Result; + fn capabilities(&self) -> Caps; // tools? streaming? vision? +} +``` + +Реестр (`registry.rs`) держит `HashMap>`, построенный +из `[[models]]`. Таблица роутинга (`routing.rs`) оперирует `model_id`. + +--- + +## 5. Поток `route → select → call` детально + +1. **Extract.** Из `messages` берётся текст для классификации. Стратегия из конфига: + `last_user` (по умолчанию) | `concat_all` | `last_user_plus_system`. Длинный + текст усечётся до `route.max_chars` (защита от стоимости/DoS). +2. **Route.** `router_client.route(text, profile)` → `{task_label, complexity}`. + Кэшируется по хэшу текста на `route.cache_ttl` (мелкая экономия на повторах). +3. **Select.** `routing.select(label, tier)` → упорядоченный `Vec`. + Учитываются: доступность модели (health), её `capabilities` (нужны ли tools), + и cost-политика (см. [ROUTING.md](ROUTING.ru.md)). +4. **Translate + Call.** Канонический `ChatRequest` → провайдер. Стриминг проксируется + чанк-в-чанк (SSE → SSE) без буферизации. +5. **Failover.** Если провайдер вернул 5xx/таймаут/перегрузку — следующий из списка. + Circuit breaker помечает «больную» модель, чтобы не долбить её. +6. **Degrade.** Если cortiq-router недоступен — берётся `routing.default` (мягкая + деградация, запрос не падает). + +--- + +## 6. Надёжность + +| Механизм | Поведение | +|---|---| +| Таймауты | отдельные на router-вызов, connect и стрим провайдера | +| Failover | по упорядоченному списку моделей полосы | +| Circuit breaker | временно исключает модель после серии ошибок | +| Router down | `routing.default` | +| Все провайдеры полосы down | OpenAI-ошибка `503 upstream_unavailable`, `retriable=true` | +| Идемпотентность | повтор с тем же `Idempotency-Key` отдаёт сохранённый ответ | + +--- + +## 7. Безопасность и мультиарендность + +- **Изоляция ключей.** Агенты держат только виртуальный ключ шлюза; ключи провайдеров + (`*_API_KEY`) живут в окружении шлюза и наружу не уходят. +- **Аккаунты.** Каждый `[[api_keys]]` → аккаунт с rate-limit, квотой, (опц.) своим + подмножеством разрешённых моделей и своей таблицей роутинга. +- **PII.** Редакция выполняется в роутере перед эскалацией к оракулу; шлюз дополнительно + может включить логирование без тел сообщений (`log.bodies = false` по умолчанию). +- **Аудит.** Каждый запрос: `request_id`, аккаунт, выбранная модель, токены, стоимость, + путь решения — без текста промпта по умолчанию. + +--- + +## 8. Наблюдаемость + +`GET /metrics` (Prometheus), `GET /healthz`, `GET /readyz`. Метрики: + +``` +gw_requests_total{protocol,account} +gw_route_calls_total # обращения к cortiq-router +gw_model_selected_total{model_id,tier} +gw_provider_calls_total{provider,model_id,outcome} +gw_failovers_total +gw_tokens_total{model_id,direction} +gw_cost_usd_total{account,model_id} +gw_latency_seconds{stage} # route | provider | total (histogram) +``` + +OpenTelemetry-спаны: `inbound → route → provider`, с пробросом `traceparent`. + +--- + +## 9. Дорожная карта + +| Фаза | Содержимое | +|---|---| +| **v0.1** | OpenAI Chat/Completions/Embeddings/Models, провайдер `openai`, роутинг + fallback, auth, метрики, стриминг | +| **v0.2** | Провайдер `anthropic` + входящий Anthropic Messages, cost-aware выбор, идемпотентность, circuit breaker | +| **v0.3** | MCP-сервер, провайдер `ollama` нативный, OpenTelemetry, кэш роутинга | +| **v0.4** | Per-account таблицы роутинга, feedback-loop в роутер, нагрузочные тесты, Helm-чарт | +| **v1.0** | Стабилизация контракта, OpenAPI-спека, официальные примеры под crewAI/LangChain/Hermes | + +Текущая фаза — **Phase 0** (скелет + спецификация в этом репозитории). diff --git a/docs/PROTOCOLS.md b/docs/PROTOCOLS.md new file mode 100644 index 0000000..aecd61b --- /dev/null +++ b/docs/PROTOCOLS.md @@ -0,0 +1,324 @@ +*Read this in [Русский](PROTOCOLS.ru.md).* + +# Cortiq Gateway Protocols + +Contracts for inbound protocols and cross-standard mechanisms. Each protocol is enabled +by a flag in `[protocols]`. Internally, all requests are normalized to the canonical model +(see [ARCHITECTURE.md](ARCHITECTURE.md#3-canonical-model-internal-neutral-format)). + +Routing rule common to all protocols: + +- `model = "cortiq-auto"` → enable intelligent routing via cortiq-router. +- `model = "cortiq-auto:quality-first"` → routing with the specified policy profile + (`cost-saver` | `balanced` | `quality-first`). +- `model = ""` → passthrough to that model without routing. + +--- + +## Table of Contents + +1. [OpenAI Chat Completions](#1-openai-chat-completions) +2. [OpenAI Completions (legacy)](#2-openai-completions-legacy) +3. [OpenAI Embeddings](#3-openai-embeddings) +4. [OpenAI Models](#4-openai-models) +5. [Anthropic Messages](#5-anthropic-messages) +6. [MCP (Model Context Protocol)](#6-mcp-model-context-protocol) +7. [Native passthrough](#7-native-passthrough) +8. [Cross-standard mechanisms](#8-cross-standard-mechanisms) + +--- + +## 1. OpenAI Chat Completions + +`POST /v1/chat/completions` — the primary endpoint; the de facto standard for agents. + +### Request + +```jsonc +{ + "model": "cortiq-auto", // routing; or a real id for passthrough + "messages": [ + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } + ], + "temperature": 0.7, + "max_tokens": 1024, + "stream": false, + "tools": [ /* function-calling, proxied to the provider as-is */ ], + "tool_choice": "auto", + "stop": ["\n\n"], + "top_p": 1.0, + "response_format": { "type": "json_object" } +} +``` + +Standard OpenAI fields are supported; unknown fields are proxied to the provider unchanged +(forward-compatible). + +### Response (non-streaming) + +```jsonc +{ + "id": "chatcmpl-9f...", + "object": "chat.completion", + "created": 1718800000, + "model": "local-qwen", // the ACTUALLY selected model + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "x = 2 or x = 3" }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 24, + "completion_tokens": 11, + "total_tokens": 35 + }, + "cortiq": { // extension (enabled with cortiq.echo=true) + "task_label": "math", + "complexity": { "score": 0.22, "tier": "low" }, + "selected_model": "local-qwen", + "route_source": "router", // router | cache | fallback | pinned + "router_request_id": "req_...", + "cost_usd": 0.0 + } +} +``` + +> The `cortiq` field is an optional extension. OpenAI SDKs ignore it. If the client does +> not want extra fields, disable `cortiq.echo` — the metadata will remain in the response +> headers. + +### Metadata in response headers (always present) + +``` +X-Cortiq-Request-Id: req_... +X-Cortiq-Task-Label: math +X-Cortiq-Complexity-Score: 0.22 +X-Cortiq-Complexity-Tier: low +X-Cortiq-Selected-Model: local-qwen +X-Cortiq-Route-Source: router +X-Cortiq-Cost-Usd: 0.000000 +``` + +### Response (streaming, `stream: true`) + +SSE in OpenAI format: a series of `chat.completion.chunk` events, terminated by +`data: [DONE]`. The gateway proxies provider chunks one-to-one (without buffering), +adding routing metadata to HTTP headers before the stream begins. + +``` +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]} + +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"content":"x = 2"},"index":0}]} + +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]} + +data: [DONE] +``` + +--- + +## 2. OpenAI Completions (legacy) + +`POST /v1/completions` — for legacy integrations. Internally, `prompt` is wrapped into a +single user message and follows the same pipeline. Response `object` is `"text_completion"`. + +```jsonc +// request +{ "model": "cortiq-auto", "prompt": "Translate to French: hello", "max_tokens": 64 } +// response +{ "object": "text_completion", "model": "local-qwen", + "choices": [ { "text": "bonjour", "index": 0, "finish_reason": "stop" } ], + "usage": { "prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8 } } +``` + +--- + +## 3. OpenAI Embeddings + +`POST /v1/embeddings`. Two modes (configurable): + +- `provider` — proxy to an embedding model from the pool; +- `router` — return an embedding from the **cortiq-router encoder** (the same vector the + router uses for classification — useful for consistent RAG). + +```jsonc +// request +{ "model": "cortiq-embed", "input": ["first text", "second text"] } +// response +{ + "object": "list", + "data": [ + { "object": "embedding", "index": 0, "embedding": [0.01, -0.02, /* ... */] }, + { "object": "embedding", "index": 1, "embedding": [0.03, 0.00, /* ... */] } + ], + "model": "cortiq-embed", + "usage": { "prompt_tokens": 4, "total_tokens": 4 } +} +``` + +--- + +## 4. OpenAI Models + +`GET /v1/models` — auto-discovery for clients. Returns the gateway's virtual models +(`cortiq-auto`, `cortiq-embed`) and all real models in the pool. + +```jsonc +{ + "object": "list", + "data": [ + { "id": "cortiq-auto", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "local-qwen", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "gpt-4o-mini", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "claude-opus", "object": "model", "owned_by": "cortiq-gateway" } + ] +} +``` + +--- + +## 5. Anthropic Messages + +`POST /v1/messages` — for Claude-native agents and the Anthropic SDK. + +```jsonc +// request +{ + "model": "cortiq-auto", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [ { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } ], + "stream": false +} +// response +{ + "id": "msg_...", + "type": "message", + "role": "assistant", + "model": "local-qwen", + "content": [ { "type": "text", "text": "x = 2 or x = 3" } ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 24, "output_tokens": 11 } +} +``` + +Streaming uses native Anthropic events (`message_start`, `content_block_delta`, +`message_delta`, `message_stop`). If the selected model is a non-Anthropic provider, +the gateway translates through the canonical model in both directions. + +--- + +## 6. MCP (Model Context Protocol) + +The gateway runs an **MCP server** (JSON-RPC 2.0 over HTTP+SSE or stdio) and exposes +routing as tools for MCP-native orchestrators. + +Published tools: + +| Tool | Purpose | +|---|---| +| `route_and_complete` | classify + execute on the selected model, return the response | +| `classify_task` | classification only (task type + complexity), no execution | + +`tools/list` returns JSON schemas. Example `tools/call` invocation: + +```jsonc +// request (JSON-RPC) +{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "route_and_complete", + "arguments": { "prompt": "Solve x^2 - 5x + 6 = 0", "profile": "balanced" } + } +} +// response +{ + "jsonrpc": "2.0", "id": 1, + "result": { + "content": [ { "type": "text", "text": "x = 2 or x = 3" } ], + "isError": false, + "_meta": { "task_label": "math", "tier": "low", "selected_model": "local-qwen" } + } +} +``` + +--- + +## 7. Native passthrough + +`POST /route` — a thin wrapper over `cortiq-router /v1/route` for clients that need only +the **routing decision** (without execution). The request body and response match the router +contract (see `cortiq-router/docs/ФОРМАТ_ЗАПРОСА_И_ОТВЕТА.md`). Useful for debugging and +for integrators who want to manage the LLM call themselves. + +--- + +## 8. Cross-standard mechanisms + +Common to all HTTP protocols. + +### 8.1 Authentication + +``` +Authorization: Bearer sk-gw-... +``` +Virtual gateway key. Mapped to an account (`[[api_keys]]`). If no keys are configured, +the gateway runs in open mode (for local development). Provider keys are never exposed. + +### 8.2 Idempotency + +``` +Idempotency-Key: +``` +A repeat request with the same key within the `idempotency.ttl` window returns the +**stored** response (safe agent retries, without double-invoking the LLM or double billing). + +### 8.3 Rate limiting + +On `429`, the following headers are returned: + +``` +Retry-After: 2 +X-RateLimit-Limit: 600 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1718800060 +``` + +### 8.4 Tracing + +W3C `traceparent` is accepted and forwarded; OpenTelemetry spans cover +`inbound→route→provider`. The response includes `X-Request-Id`. This enables an +end-to-end trace from agent → gateway → provider. + +### 8.5 Error format (OpenAI-compatible) + +```jsonc +{ + "error": { + "message": "no healthy model available for tier 'high'", + "type": "upstream_unavailable", // invalid_request_error | authentication_error | rate_limit_error | upstream_unavailable | internal_error + "code": "no_healthy_model", + "param": null + } +} +``` + +OpenAI/Anthropic SDKs parse this envelope natively. Status code mapping: + +| Type | HTTP | retriable | +|---|---|---| +| `invalid_request_error` | 400 | no | +| `authentication_error` | 401 | no | +| `rate_limit_error` | 429 | yes | +| `upstream_unavailable` | 502/503 | yes | +| `internal_error` | 500 | yes | + +### 8.6 Cost tracking + +`usage` is returned in the protocol format (OpenAI/Anthropic). Additionally, cost is +included in the `X-Cortiq-Cost-Usd` header and in `cortiq.cost_usd` (if `cortiq.echo` +is enabled). The price is calculated from the model's `price_in`/`price_out` in the config +multiplied by the token counts. diff --git a/docs/PROTOCOLS.ru.md b/docs/PROTOCOLS.ru.md new file mode 100644 index 0000000..3f356b7 --- /dev/null +++ b/docs/PROTOCOLS.ru.md @@ -0,0 +1,321 @@ +*Read this in [English](PROTOCOLS.md).* + +# Протоколы Cortiq Gateway + +Контракты входящих протоколов и кросс-стандартных механизмов. Каждый протокол +включается флагом в `[protocols]`. Внутри всё приводится к канонической модели +(см. [ARCHITECTURE.md](ARCHITECTURE.ru.md#3-каноническая-модель)). + +Общее правило маршрутизации во всех протоколах: + +- `model = "cortiq-auto"` → включить интеллектуальный роутинг через cortiq-router. +- `model = "cortiq-auto:quality-first"` → роутинг с указанным policy-профилем + (`cost-saver` | `balanced` | `quality-first`). +- `model = "<реальный id из конфига>"` → passthrough к этой модели без роутинга. + +--- + +## Оглавление + +1. [OpenAI Chat Completions](#1-openai-chat-completions) +2. [OpenAI Completions (legacy)](#2-openai-completions-legacy) +3. [OpenAI Embeddings](#3-openai-embeddings) +4. [OpenAI Models](#4-openai-models) +5. [Anthropic Messages](#5-anthropic-messages) +6. [MCP (Model Context Protocol)](#6-mcp-model-context-protocol) +7. [Native passthrough](#7-native-passthrough) +8. [Кросс-стандарты](#8-кросс-стандарты) + +--- + +## 1. OpenAI Chat Completions + +`POST /v1/chat/completions` — основной endpoint, де-факто стандарт для агентов. + +### Запрос + +```jsonc +{ + "model": "cortiq-auto", // роутинг; или реальный id для passthrough + "messages": [ + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } + ], + "temperature": 0.7, + "max_tokens": 1024, + "stream": false, + "tools": [ /* function-calling, проксируется к провайдеру как есть */ ], + "tool_choice": "auto", + "stop": ["\n\n"], + "top_p": 1.0, + "response_format": { "type": "json_object" } +} +``` + +Поддерживаются стандартные поля OpenAI; неизвестные поля проксируются к провайдеру +без изменений (forward-compatible). + +### Ответ (без стриминга) + +```jsonc +{ + "id": "chatcmpl-9f...", + "object": "chat.completion", + "created": 1718800000, + "model": "local-qwen", // РЕАЛЬНО выбранная модель + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "x = 2 или x = 3" }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 24, + "completion_tokens": 11, + "total_tokens": 35 + }, + "cortiq": { // расширение (включается cortiq.echo=true) + "task_label": "math", + "complexity": { "score": 0.22, "tier": "low" }, + "selected_model": "local-qwen", + "route_source": "router", // router | cache | fallback | pinned + "router_request_id": "req_...", + "cost_usd": 0.0 + } +} +``` + +> Поле `cortiq` — необязательное расширение. OpenAI-SDK его игнорируют. Если клиент +> не хочет «лишних» полей — выключите `cortiq.echo`, метаданные останутся в заголовках. + +### Метаданные в заголовках (всегда) + +``` +X-Cortiq-Request-Id: req_... +X-Cortiq-Task-Label: math +X-Cortiq-Complexity-Score: 0.22 +X-Cortiq-Complexity-Tier: low +X-Cortiq-Selected-Model: local-qwen +X-Cortiq-Route-Source: router +X-Cortiq-Cost-Usd: 0.000000 +``` + +### Ответ (стриминг, `stream: true`) + +SSE, формат OpenAI: серия чанков `chat.completion.chunk`, завершается `data: [DONE]`. +Шлюз проксирует чанки провайдера один-в-один (без буферизации), добавив метаданные +роутинга в HTTP-заголовки до начала потока. + +``` +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]} + +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"content":"x = 2"},"index":0}]} + +data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]} + +data: [DONE] +``` + +--- + +## 2. OpenAI Completions (legacy) + +`POST /v1/completions` — для старых интеграций. Внутри `prompt` оборачивается в +одно user-сообщение и идёт по тому же пути. Ответ — `object: "text_completion"`. + +```jsonc +// запрос +{ "model": "cortiq-auto", "prompt": "Translate to French: hello", "max_tokens": 64 } +// ответ +{ "object": "text_completion", "model": "local-qwen", + "choices": [ { "text": "bonjour", "index": 0, "finish_reason": "stop" } ], + "usage": { "prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8 } } +``` + +--- + +## 3. OpenAI Embeddings + +`POST /v1/embeddings`. Два режима (настраивается): + +- `provider` — проксировать к embedding-модели из пула; +- `router` — вернуть эмбеддинг **энкодером cortiq-router** (тот же вектор, что роутер + использует для классификации — удобно для согласованного RAG). + +```jsonc +// запрос +{ "model": "cortiq-embed", "input": ["first text", "second text"] } +// ответ +{ + "object": "list", + "data": [ + { "object": "embedding", "index": 0, "embedding": [0.01, -0.02, /* ... */] }, + { "object": "embedding", "index": 1, "embedding": [0.03, 0.00, /* ... */] } + ], + "model": "cortiq-embed", + "usage": { "prompt_tokens": 4, "total_tokens": 4 } +} +``` + +--- + +## 4. OpenAI Models + +`GET /v1/models` — автодискавери для клиентов. Возвращает виртуальные модели шлюза +(`cortiq-auto`, `cortiq-embed`) и все реальные модели пула. + +```jsonc +{ + "object": "list", + "data": [ + { "id": "cortiq-auto", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "local-qwen", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "gpt-4o-mini", "object": "model", "owned_by": "cortiq-gateway" }, + { "id": "claude-opus", "object": "model", "owned_by": "cortiq-gateway" } + ] +} +``` + +--- + +## 5. Anthropic Messages + +`POST /v1/messages` — для Claude-нативных агентов и Anthropic SDK. + +```jsonc +// запрос +{ + "model": "cortiq-auto", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [ { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } ], + "stream": false +} +// ответ +{ + "id": "msg_...", + "type": "message", + "role": "assistant", + "model": "local-qwen", + "content": [ { "type": "text", "text": "x = 2 или x = 3" } ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 24, "output_tokens": 11 } +} +``` + +Стриминг — нативные Anthropic-события (`message_start`, `content_block_delta`, +`message_delta`, `message_stop`). Если выбранная модель — не-Anthropic-провайдер, +шлюз транслирует через каноническую модель в обе стороны. + +--- + +## 6. MCP (Model Context Protocol) + +Шлюз поднимает **MCP-сервер** (JSON-RPC 2.0 поверх HTTP+SSE или stdio) и публикует +маршрутизацию как инструменты для MCP-нативных оркестраторов. + +Публикуемые инструменты: + +| Инструмент | Назначение | +|---|---| +| `route_and_complete` | классифицировать + выполнить на выбранной модели, вернуть ответ | +| `classify_task` | только классификация (тип задачи + сложность), без выполнения | + +`tools/list` отдаёт JSON-схемы. Пример вызова `tools/call`: + +```jsonc +// запрос (JSON-RPC) +{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "route_and_complete", + "arguments": { "prompt": "Solve x^2 - 5x + 6 = 0", "profile": "balanced" } + } +} +// ответ +{ + "jsonrpc": "2.0", "id": 1, + "result": { + "content": [ { "type": "text", "text": "x = 2 или x = 3" } ], + "isError": false, + "_meta": { "task_label": "math", "tier": "low", "selected_model": "local-qwen" } + } +} +``` + +--- + +## 7. Native passthrough + +`POST /route` — тонкая обёртка над `cortiq-router /v1/route` для клиентов, которым +нужно только **решение** (без выполнения). Тело и ответ совпадают с контрактом роутера +(см. `cortiq-router/docs/ФОРМАТ_ЗАПРОСА_И_ОТВЕТА.md`). Полезно для отладки и для тех, +кто хочет сам управлять вызовом LLM. + +--- + +## 8. Кросс-стандарты + +Едины для всех HTTP-протоколов. + +### 8.1 Аутентификация + +``` +Authorization: Bearer sk-gw-... +``` +Виртуальный ключ шлюза. Маппится на аккаунт (`[[api_keys]]`). Если ключи не настроены — +открытый режим (для локальной разработки). Ключи провайдеров наружу не отдаются. + +### 8.2 Идемпотентность + +``` +Idempotency-Key: <уникальный-id-операции> +``` +Повтор с тем же ключом в окне `idempotency.ttl` отдаёт **сохранённый** ответ +(безопасные ретраи агентов, без двойного вызова LLM и двойного биллинга). + +### 8.3 Rate-limit + +На `429` возвращаются: + +``` +Retry-After: 2 +X-RateLimit-Limit: 600 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1718800060 +``` + +### 8.4 Трассировка + +Приём и проброс W3C `traceparent`; OpenTelemetry-спаны `inbound→route→provider`. +В ответе — `X-Request-Id`. Это позволяет сшить сквозной трейс агент → шлюз → провайдер. + +### 8.5 Формат ошибки (OpenAI-совместимый) + +```jsonc +{ + "error": { + "message": "no healthy model available for tier 'high'", + "type": "upstream_unavailable", // invalid_request_error | authentication_error | rate_limit_error | upstream_unavailable | internal_error + "code": "no_healthy_model", + "param": null + } +} +``` + +OpenAI/Anthropic SDK парсят такой конверт нативно. Сопоставление статусов: + +| Тип | HTTP | retriable | +|---|---|---| +| `invalid_request_error` | 400 | нет | +| `authentication_error` | 401 | нет | +| `rate_limit_error` | 429 | да | +| `upstream_unavailable` | 502/503 | да | +| `internal_error` | 500 | да | + +### 8.6 Учёт стоимости + +`usage` отдаётся в формате протокола (OpenAI/Anthropic). Дополнительно — стоимость +в заголовке `X-Cortiq-Cost-Usd` и в `cortiq.cost_usd` (если включено `cortiq.echo`). +Цена считается из `price_in`/`price_out` модели в конфиге × токены. diff --git a/docs/ROUTING.md b/docs/ROUTING.md new file mode 100644 index 0000000..7f33ff7 --- /dev/null +++ b/docs/ROUTING.md @@ -0,0 +1,164 @@ +*Read this in [Русский](ROUTING.ru.md).* + +# Routing and Cost + +How Cortiq Gateway translates a router decision (`task_label` + `complexity.tier`) into +selection of a specific model from your pool — and how to make this **cost-aware**: +cheap tasks stay local, expensive ones go to the cloud. + +--- + +## 1. Base model: complexity tier → model pool + +cortiq-router returns a complexity tier (`low` / `medium` / `high`, configurable in the +router). The `[routing]` table maps each tier to an **ordered list** of models — which +serves simultaneously as a priority order and a fallback chain. + +```toml +[routing] +low = ["local-qwen"] # simple → local only (free) +medium = ["gpt-4o-mini", "local-qwen"] # medium → cheap hosted, fall back to local +high = ["claude-opus", "gpt-4o-mini"] # complex → expensive model, fall back to mid +default = "gpt-4o-mini" # if the router is unavailable +``` + +Selection algorithm: + +``` +1. tier := router.complexity.tier (or the default tier if router is unavailable) +2. candidates := routing[tier] (ordered list of model_id) +3. for each model_id in order: + if the model is healthy AND supports the required capabilities (tools/vision): + select it → done +4. if all are unavailable: try the next tier down/up per policy, or return error 503 +``` + +--- + +## 2. Universal pool: cheap local + expensive hosted + +The primary developer scenario: mix a **free local** model with a **paid cloud** API, +and let the gateway decide which one to use. + +```toml +# Local model on vLLM / llama.cpp / Ollama (OpenAI-compatible endpoint) +[[models]] +id = "local-qwen" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "qwen2.5-7b-instruct" +cost_tier = "cheap" +price_in = 0.0 # local → 0 +price_out = 0.0 +caps = ["tools"] # capabilities (checked during selection) + +# Cheap cloud +[[models]] +id = "gpt-4o-mini" +provider = "openai" +base_url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key_env = "OPENAI_API_KEY" +cost_tier = "mid" +price_in = 0.15 # $/1M input tokens +price_out = 0.60 + +# Expensive cloud for complex tasks +[[models]] +id = "claude-opus" +provider = "anthropic" +base_url = "https://api.anthropic.com" +model = "claude-opus-4" +api_key_env = "ANTHROPIC_API_KEY" +cost_tier = "expensive" +price_in = 15.0 +price_out = 75.0 +caps = ["tools", "vision"] +``` + +Because both `local-qwen` and `gpt-4o-mini` use `provider = "openai"`, the gateway does +not care where the model is physically located — **local servers already speak the OpenAI +API**. This is what makes the proxy truly universal. + +--- + +## 3. Cost-aware mode (optional) + +Instead of a fixed table, you can enable selection by **budget/price** (v0.2): + +```toml +[routing.policy] +mode = "cost_aware" # fixed_table | cost_aware +# in cost_aware mode the gateway picks the cheapest model for each tier +# that meets the minimum quality class for that tier: +min_class = { low = "cheap", medium = "mid", high = "expensive" } +# upper price bound per request (guard against expensive calls) +max_cost_usd_per_request = 0.50 +``` + +Logic: among models that are at least `min_class[tier]` and within the price cap, the +cheapest available model is selected. This yields the "minimally sufficient" model for +the task complexity. + +--- + +## 4. Pinning and passthrough + +- **Passthrough.** `model = "local-qwen"` (a real id) — no router call; direct invocation. + Useful when the client already knows what it wants. +- **Profile pinning.** `model = "cortiq-auto:cost-saver"` — routing with a specific router + policy profile (escalates less often → cheaper). +- **Router version pinning** (v0.4). To prevent routing behavior from drifting due to + router self-learning, you can pin `model_version` — the agent gets reproducible routing. + +--- + +## 5. Text extraction for routing (multi-turn) + +An agent request is not a single string but a system prompt + history + tools. What to +send to the router for classification is configurable: + +```toml +[route] +text_strategy = "last_user" # last_user | concat_all | last_user_plus_system +max_chars = 4000 # truncate long context (cost/DoS protection) +cache_ttl = "60s" # cache routing decisions by text hash +profile = "balanced" # default policy profile +``` + +| Strategy | What is classified | When to use | +|---|---|---| +| `last_user` | last user message | default; cheap and accurate for most agents | +| `last_user_plus_system` | system prompt + last user message | when the role is set by the system prompt | +| `concat_all` | full conversation (truncated) | multi-turn with meaning spread across history | + +--- + +## 6. Failover and circuit breaker + +- **Failover.** On 5xx / timeout / `overloaded` from a provider — try the next model in + the list. +- **Circuit breaker.** After `breaker.threshold` consecutive errors, the model is marked + "open" for `breaker.cooldown`; requests skip it. Half-open: one probe request after the + cooldown expires. +- **Router down.** `routing.default` is used; the request does not fail (soft degradation). + +```toml +[breaker] +threshold = 5 +cooldown = "30s" +``` + +--- + +## 7. Feedback loop (optional, v0.4) + +When enabled, the gateway can send a `/v1/feedback` signal to the router after a successful +completion (e.g. based on the result of agent-side response validation) — the router will +fine-tune the task mask. Over time this makes routing fit your specific pool more precisely. + +```toml +[feedback] +enabled = false +# the ground-truth source is set by the integrator (e.g. the X-Cortiq-Correct-Label header) +``` diff --git a/docs/ROUTING.ru.md b/docs/ROUTING.ru.md new file mode 100644 index 0000000..b546fc3 --- /dev/null +++ b/docs/ROUTING.ru.md @@ -0,0 +1,162 @@ +*Read this in [English](ROUTING.md).* + +# Маршрутизация и стоимость + +Как Cortiq Gateway превращает решение роутера (`task_label` + `complexity.tier`) в +выбор конкретной модели из вашего пула — и как сделать это **cost-aware**: +дешёвое локально, дорогое в облаке. + +--- + +## 1. Базовая модель: полоса сложности → пул моделей + +cortiq-router возвращает полосу сложности (`low` / `medium` / `high`, настраивается +в роутере). Таблица `[routing]` сопоставляет полосе **упорядоченный список** моделей — +это одновременно приоритет и цепочка fallback. + +```toml +[routing] +low = ["local-qwen"] # простое → только локально (бесплатно) +medium = ["gpt-4o-mini", "local-qwen"] # средне → дешёвый хост, откат на локальное +high = ["claude-opus", "gpt-4o-mini"] # сложно → дорогая модель, откат на средн. +default = "gpt-4o-mini" # если роутер недоступен +``` + +Алгоритм выбора: + +``` +1. tier := router.complexity.tier (или default-полоса, если роутер недоступен) +2. candidates := routing[tier] (упорядоченный список model_id) +3. для каждого model_id по порядку: + если модель healthy И поддерживает нужные возможности (tools/vision): + выбрать её → выход +4. если все недоступны: следующая полоса вниз/вверх по политике, либо ошибка 503 +``` + +--- + +## 2. Универсальный пул: локальные дешёвые + хостовые дорогие + +Главный сценарий для разработчика: смешать **бесплатную локальную** модель и +**платный облачный** API, и пусть шлюз сам решает, что куда. + +```toml +# Локальная модель на vLLM / llama.cpp / Ollama (OpenAI-совместимый endpoint) +[[models]] +id = "local-qwen" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "qwen2.5-7b-instruct" +cost_tier = "cheap" +price_in = 0.0 # локально → 0 +price_out = 0.0 +caps = ["tools"] # что умеет (для проверки на этапе выбора) + +# Дешёвый облачный +[[models]] +id = "gpt-4o-mini" +provider = "openai" +base_url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key_env = "OPENAI_API_KEY" +cost_tier = "mid" +price_in = 0.15 # $/1M входных токенов +price_out = 0.60 + +# Дорогой облачный для сложного +[[models]] +id = "claude-opus" +provider = "anthropic" +base_url = "https://api.anthropic.com" +model = "claude-opus-4" +api_key_env = "ANTHROPIC_API_KEY" +cost_tier = "expensive" +price_in = 15.0 +price_out = 75.0 +caps = ["tools", "vision"] +``` + +Поскольку `local-qwen` и `gpt-4o-mini` оба `provider = "openai"`, шлюзу не важно, +где модель физически — **локальные серверы уже говорят на OpenAI API**. Это и делает +прокси универсальным. + +--- + +## 3. Cost-aware режим (опционально) + +Вместо жёсткой таблицы можно включить выбор по **бюджету/цене** (v0.2): + +```toml +[routing.policy] +mode = "cost_aware" # fixed_table | cost_aware +# при cost_aware шлюз для каждой полосы берёт самую дешёвую модель, +# удовлетворяющую минимальному «классу качества» полосы: +min_class = { low = "cheap", medium = "mid", high = "expensive" } +# верхняя граница цены за запрос (страховка от дорогих вызовов) +max_cost_usd_per_request = 0.50 +``` + +Логика: среди моделей не ниже `min_class[tier]` и не дороже лимита выбирается самая +дешёвая доступная. Это даёт «минимально достаточную» модель под сложность задачи. + +--- + +## 4. Pinning и passthrough + +- **Passthrough.** `model = "local-qwen"` (реальный id) — без обращения к роутеру, + прямой вызов. Полезно, когда клиент сам знает, что хочет. +- **Pinning профиля.** `model = "cortiq-auto:cost-saver"` — роутинг с конкретным + policy-профилем роутера (реже эскалирует → дешевле). +- **Pinning версии роутера** (v0.4). Чтобы поведение не «плыло» из-за самообучения + роутера, можно зафиксировать `model_version` — агент получает воспроизводимый роутинг. + +--- + +## 5. Извлечение текста для маршрутизации (мультитёрн) + +Агентский запрос — это не одна строка, а system + история + tools. Что слать роутеру +на классификацию, настраивается: + +```toml +[route] +text_strategy = "last_user" # last_user | concat_all | last_user_plus_system +max_chars = 4000 # усечение длинного контекста (стоимость/DoS) +cache_ttl = "60s" # кэш решения по хэшу текста +profile = "balanced" # дефолтный policy-профиль +``` + +| Стратегия | Что классифицируем | Когда | +|---|---|---| +| `last_user` | последнее сообщение пользователя | по умолчанию; дёшево и точно для большинства агентов | +| `last_user_plus_system` | system + последнее user | когда роль задаётся системным промптом | +| `concat_all` | весь диалог (усечённый) | мультитёрн со смыслом, размазанным по истории | + +--- + +## 6. Failover и circuit breaker + +- **Failover.** При 5xx/таймауте/`overloaded` от провайдера — следующая модель списка. +- **Circuit breaker.** После `breaker.threshold` ошибок подряд модель помечается + «открытой» на `breaker.cooldown`, запросы её минуют. Полупроброс — один пробный + запрос после кулдауна. +- **Router down.** Берётся `routing.default`, запрос не падает (мягкая деградация). + +```toml +[breaker] +threshold = 5 +cooldown = "30s" +``` + +--- + +## 7. Петля обратной связи (опционально, v0.4) + +Если включено, шлюз после успешного выполнения может отправить в роутер +`/v1/feedback` сигнал (например, по результату валидации ответа агентом) — роутер +дообучит маску задачи. Так маршрутизация со временем точнее ложится на ваш пул. + +```toml +[feedback] +enabled = false +# источник истины задаёт интегратор (например, заголовок X-Cortiq-Correct-Label) +``` diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..d8f09ef Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/models.png b/docs/screenshots/models.png new file mode 100644 index 0000000..a7ae94e Binary files /dev/null and b/docs/screenshots/models.png differ diff --git a/docs/screenshots/playground.png b/docs/screenshots/playground.png new file mode 100644 index 0000000..195616a Binary files /dev/null and b/docs/screenshots/playground.png differ diff --git a/docs/screenshots/routing.png b/docs/screenshots/routing.png new file mode 100644 index 0000000..86cddc2 Binary files /dev/null and b/docs/screenshots/routing.png differ diff --git a/src/admin/assets.rs b/src/admin/assets.rs new file mode 100644 index 0000000..ec8800f --- /dev/null +++ b/src/admin/assets.rs @@ -0,0 +1,58 @@ +//! Embedded static assets for the admin panel (SPA). The `web/` directory is compiled +//! into the binary via `rust-embed`, so the gateway remains a single self-contained file. +//! +//! Served as the main router's fallback: everything under `/admin/*` not matched by +//! an explicit route is served as a file; an unknown path under `/admin` falls back to +//! `index.html` (the client-side hash router handles navigation). + +use axum::{ + http::{header, StatusCode, Uri}, + response::{IntoResponse, Response}, +}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "web/"] +struct WebAssets; + +fn serve_file(path: &str) -> Response { + match WebAssets::get(path) { + Some(content) => { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + ( + [(header::CONTENT_TYPE, mime.as_ref())], + content.data.into_owned(), + ) + .into_response() + } + None => { + // SPA: unknown path under /admin → serve index.html + match WebAssets::get("index.html") { + Some(content) => ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + content.data.into_owned(), + ) + .into_response(), + None => (StatusCode::NOT_FOUND, "not found").into_response(), + } + } + } +} + +/// Application-wide fallback: serves `/admin/*`; everything else → 404 JSON. +pub async fn fallback(uri: Uri) -> Response { + let path = uri.path(); + if let Some(rest) = path.strip_prefix("/admin") { + let rest = rest.trim_start_matches('/'); + let file = if rest.is_empty() { "index.html" } else { rest }; + serve_file(file) + } else { + ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({ + "error": { "message": "not found", "type": "not_found", "code": "not_found" } + })), + ) + .into_response() + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..d8d3aa0 --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,756 @@ +//! Admin API for the web management panel (`/admin/api/*`) + SPA serving (`/admin/*`). +//! +//! All endpoints under `/admin/api` are protected by a Bearer admin token. Config changes +//! go through `AppState::reload` (validation → build `Live` → atomic TOML write +//! → swap), so they are applied without a restart. Secrets are never returned — +//! only their presence status. + +pub mod assets; + +use crate::config::{ + AdminCfg, ApiKeyCfg, BreakerCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg, RouteCfg, + RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets, +}; +use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective}; +use crate::state::SharedState; +use crate::stats::parse_duration_secs; +use axum::{ + extract::{Path, Query, Request, State}, + http::{header, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::Instant; + +// ───────────────────────────── common types / helpers ─────────────────────── + +type ApiResult = Result, ApiError>; + +pub struct ApiError { + status: StatusCode, + message: String, +} +impl ApiError { + fn bad(msg: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: msg.into(), + } + } + fn not_found(msg: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: msg.into(), + } + } +} +impl From for ApiError { + fn from(e: anyhow::Error) -> Self { + Self::bad(e.to_string()) + } +} +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(json!({"error": {"message": self.message, "type": "admin_error"}})), + ) + .into_response() + } +} + +fn ok(v: Value) -> ApiResult { + Ok(Json(v)) +} + +fn current_cfg(state: &SharedState) -> Config { + state.live().cfg.clone() +} + +/// Random hex token from `n_bytes` bytes of cryptographic randomness. +pub fn random_token(n_bytes: usize) -> String { + let mut buf = vec![0u8; n_bytes]; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut buf); + } else { + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for (i, b) in buf.iter_mut().enumerate() { + *b = ((t >> ((i % 16) * 8)) as u8) ^ (i as u8).wrapping_mul(31); + } + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +fn mask_key(key: &str) -> String { + let n = key.chars().count(); + if n <= 8 { + return "•".repeat(n.max(3)); + } + let head: String = key.chars().take(6).collect(); + let tail: String = key.chars().skip(n - 4).collect(); + format!("{head}…{tail}") +} + +fn tiers_to_map(routing: &RoutingCfg) -> HashMap> { + routing + .tiers + .iter() + .map(|(k, v)| { + let list = match v { + TierTargets::List(l) => l.clone(), + TierTargets::One(s) => vec![s.clone()], + }; + (k.clone(), list) + }) + .collect() +} + +fn parse_routing(model: &str) -> RoutingDirective { + if let Some(rest) = model.strip_prefix("cortiq-auto") { + let profile = rest.strip_prefix(':').map(|p| p.to_string()); + RoutingDirective::Auto { profile } + } else { + RoutingDirective::Pinned { + model_id: model.to_string(), + } + } +} + +// ───────────────────────────── router ─────────────────────────────────────── + +/// Admin API routes (token-protected). Mounted into the main router. +/// The token is captured in a middleware closure so it does not depend on State. +pub fn api_routes(admin_token: String) -> Router { + Router::new() + .route("/admin/api/health", get(health)) + .route("/admin/api/meta", get(meta)) + .route("/admin/api/config", get(get_config).put(put_config)) + .route("/admin/api/models", get(list_models).post(create_model)) + .route( + "/admin/api/models/:id", + axum::routing::put(update_model).delete(delete_model), + ) + .route("/admin/api/models/:id/probe", post(probe_model)) + .route("/admin/api/routing", get(get_routing).put(put_routing)) + .route( + "/admin/api/protocols", + get(get_protocols).put(put_protocols), + ) + .route("/admin/api/settings", get(get_settings).put(put_settings)) + .route("/admin/api/keys", get(list_keys).post(create_key)) + .route("/admin/api/keys/:key", axum::routing::delete(delete_key)) + .route( + "/admin/api/secrets", + get(list_secrets).put(set_secret).delete(clear_secret), + ) + .route("/admin/api/stats", get(get_stats)) + .route("/admin/api/requests", get(get_requests)) + .route("/admin/api/test", post(run_test)) + .route_layer(middleware::from_fn(move |req: Request, next: Next| { + let expected = admin_token.clone(); + async move { auth(expected, req, next).await } + })) +} + +/// Extract Bearer admin token from the `Authorization` header. +fn bearer_token(req: &Request) -> String { + req.headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|v| { + v.strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + .unwrap_or(v) + .trim() + }) + .unwrap_or("") + .to_string() +} + +/// Admin authentication middleware (constant-time comparison). +async fn auth(expected: String, req: Request, next: Next) -> Response { + let token = bearer_token(&req); + if !expected.is_empty() && constant_time_eq(token.as_bytes(), expected.as_bytes()) { + next.run(req).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": "invalid or missing admin token", "type": "authentication_error", "code": "unauthorized"}})), + ) + .into_response() + } +} + +// ───────────────────────────── metrics ────────────────────────────────────── + +/// `GET /metrics` (Prometheus). Exposed when `telemetry.metrics = true`. +pub async fn metrics(State(state): State) -> Response { + let live = state.live(); + if !live.cfg.telemetry.metrics { + return (StatusCode::NOT_FOUND, "metrics disabled").into_response(); + } + let body = state.stats.prometheus(); + ([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body).into_response() +} + +// ───────────────────────────── health / meta ─────────────────────────────── + +async fn probe_router(url: &str) -> (bool, u64) { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(1500)) + .danger_accept_invalid_certs(true) + .build() + { + Ok(c) => c, + Err(_) => return (false, 0), + }; + let started = Instant::now(); + let target = format!("{}/healthz", url.trim_end_matches('/')); + let ok = client + .get(&target) + .send() + .await + .map(|r| r.status().as_u16() < 500) + .unwrap_or(false); + (ok, started.elapsed().as_millis() as u64) +} + +async fn health(State(state): State) -> ApiResult { + let live = state.live(); + let (router_ok, router_ms) = probe_router(&live.cfg.router.url).await; + let models: Vec = live + .cfg + .models + .iter() + .map(|m| { + let key = match &m.api_key_env { + Some(env) => state.secrets.source(env), + None => "none", + }; + json!({ + "id": m.id, + "provider": m.provider, + "model": m.model, + "kind": m.kind, + "cost_tier": m.cost_tier, + "key_source": key, + "in_registry": live.registry.get(&m.id).is_some(), + }) + }) + .collect(); + ok(json!({ + "ok": true, + "version": env!("CARGO_PKG_VERSION"), + "listen": live.cfg.listen, + "router": { "url": live.cfg.router.url, "reachable": router_ok, "latency_ms": router_ms }, + "models": models, + })) +} + +async fn meta() -> ApiResult { + ok(json!({ + "version": env!("CARGO_PKG_VERSION"), + "providers": ["openai", "anthropic", "ollama", "http"], + "provider_impl": { "openai": true, "ollama": true, "http": true, "anthropic": false }, + "cost_tiers": ["cheap", "mid", "expensive"], + "kinds": ["chat", "embedding"], + "profiles": ["cost-saver", "balanced", "quality-first"], + "text_strategies": ["last_user", "last_user_plus_system", "concat_all"], + "policy_modes": ["fixed_table", "cost_aware"], + "tiers": ["low", "medium", "high"], + "caps": ["tools", "vision"], + "protocols_impl": { + "openai_chat": true, "openai_completions": false, "openai_embeddings": false, + "openai_models": false, "anthropic_messages": false, "mcp": false, "native_passthrough": false + }, + "languages": ["en", "ru", "de", "fr", "es", "zh", "tr"], + })) +} + +// ───────────────────────────── config ────────────────────────────────────── + +async fn get_config(State(state): State) -> ApiResult { + let cfg = current_cfg(&state); + ok(serde_json::to_value(&cfg).map_err(|e| ApiError::bad(e.to_string()))?) +} + +async fn put_config(State(state): State, Json(cfg): Json) -> ApiResult { + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +// ───────────────────────────── models ────────────────────────────────────── + +async fn list_models(State(state): State) -> ApiResult { + let live = state.live(); + let models: Vec = live + .cfg + .models + .iter() + .map(|m| { + let mut v = serde_json::to_value(m).unwrap_or(json!({})); + let key_source = match &m.api_key_env { + Some(env) => state.secrets.source(env), + None => "none", + }; + v["key_source"] = json!(key_source); + v + }) + .collect(); + ok(json!({ "models": models })) +} + +async fn create_model(State(state): State, Json(m): Json) -> ApiResult { + let mut cfg = current_cfg(&state); + if cfg.models.iter().any(|x| x.id == m.id) { + return Err(ApiError::bad(format!("model id '{}' already exists", m.id))); + } + cfg.models.push(m); + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +async fn update_model( + State(state): State, + Path(id): Path, + Json(m): Json, +) -> ApiResult { + let mut cfg = current_cfg(&state); + let idx = cfg + .models + .iter() + .position(|x| x.id == id) + .ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?; + // if the id was renamed — must not conflict with an existing one + if m.id != id && cfg.models.iter().any(|x| x.id == m.id) { + return Err(ApiError::bad(format!("model id '{}' already exists", m.id))); + } + cfg.models[idx] = m; + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +async fn delete_model(State(state): State, Path(id): Path) -> ApiResult { + let mut cfg = current_cfg(&state); + let before = cfg.models.len(); + cfg.models.retain(|x| x.id != id); + if cfg.models.len() == before { + return Err(ApiError::not_found(format!("model '{id}' not found"))); + } + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +async fn probe_model(State(state): State, Path(id): Path) -> ApiResult { + let live = state.live(); + let mcfg = live + .cfg + .models + .iter() + .find(|m| m.id == id) + .ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))? + .clone(); + + if mcfg.provider == "anthropic" { + return ok(json!({ + "ok": false, + "error": "anthropic provider not yet implemented (planned v0.2)", + })); + } + let provider = live + .registry + .get(&id) + .ok_or_else(|| ApiError::bad(format!("model '{id}' has no runtime provider")))?; + + let req = ChatRequest { + routing: RoutingDirective::Pinned { + model_id: id.clone(), + }, + messages: vec![Message { + role: "user".into(), + content: "ping".into(), + tool_calls: vec![], + }], + tools: vec![], + params: GenParams { + temperature: Some(0.0), + max_tokens: Some(1), + ..Default::default() + }, + stream: false, + meta: RequestMeta { + protocol: "probe".into(), + ..Default::default() + }, + }; + let started = Instant::now(); + let res = provider.chat(req).await; + let latency_ms = started.elapsed().as_millis() as u64; + match res { + Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })), + Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })), + } +} + +// ───────────────────────────── routing ───────────────────────────────────── + +async fn get_routing(State(state): State) -> ApiResult { + let live = state.live(); + ok(json!({ + "tiers": tiers_to_map(&live.cfg.routing), + "default": live.cfg.routing.default, + "policy": live.cfg.routing.policy, + })) +} + +#[derive(Deserialize)] +struct RoutingBody { + #[serde(default)] + tiers: HashMap>, + default: String, + #[serde(default)] + policy: RoutingPolicy, +} + +async fn put_routing(State(state): State, Json(body): Json) -> ApiResult { + let mut cfg = current_cfg(&state); + cfg.routing = RoutingCfg { + tiers: body + .tiers + .into_iter() + .map(|(k, v)| (k, TierTargets::List(v))) + .collect(), + default: body.default, + policy: body.policy, + }; + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +// ───────────────────────────── protocols ─────────────────────────────────── + +async fn get_protocols(State(state): State) -> ApiResult { + let live = state.live(); + ok(serde_json::to_value(&live.cfg.protocols).unwrap_or(json!({}))) +} + +async fn put_protocols(State(state): State, Json(p): Json) -> ApiResult { + let mut cfg = current_cfg(&state); + cfg.protocols = p; + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +// ───────────────────────────── settings ──────────────────────────────────── + +async fn get_settings(State(state): State) -> ApiResult { + let live = state.live(); + let c = &live.cfg; + // admin section without token value (not stored here — only the env name) + ok(json!({ + "listen": c.listen, + "router": c.router, + "route": c.route, + "breaker": c.breaker, + "log": c.log, + "telemetry": c.telemetry, + "cortiq": c.cortiq, + "stats": c.stats, + "admin": c.admin, + })) +} + +#[derive(Deserialize)] +struct SettingsBody { + listen: Option, + router: Option, + route: Option, + breaker: Option, + log: Option, + telemetry: Option, + cortiq: Option, + stats: Option, + admin: Option, +} + +async fn put_settings(State(state): State, Json(b): Json) -> ApiResult { + let mut cfg = current_cfg(&state); + let mut needs_restart = false; + if let Some(v) = b.listen { + if v != cfg.listen { + needs_restart = true; + } + cfg.listen = v; + } + if let Some(v) = b.router { + cfg.router = v; + } + if let Some(v) = b.route { + cfg.route = v; + } + if let Some(v) = b.breaker { + cfg.breaker = v; + } + if let Some(v) = b.log { + cfg.log = v; + } + if let Some(v) = b.telemetry { + cfg.telemetry = v; + } + if let Some(v) = b.cortiq { + cfg.cortiq = v; + } + if let Some(v) = b.stats { + cfg.stats = v; + } + if let Some(v) = b.admin { + if v.listen != cfg.admin.listen { + needs_restart = true; + } + cfg.admin = v; + } + state.reload(cfg)?; + ok(json!({ "ok": true, "needs_restart": needs_restart })) +} + +// ───────────────────────────── keys ──────────────────────────────────────── + +async fn list_keys(State(state): State) -> ApiResult { + let live = state.live(); + let keys: Vec = live + .cfg + .api_keys + .iter() + .map(|k| { + json!({ + "key": k.key, + "key_masked": mask_key(&k.key), + "account": k.account, + "rate_per_min": k.rate_per_min, + "allow_models": k.allow_models, + }) + }) + .collect(); + ok(json!({ "keys": keys, "open_mode": live.cfg.api_keys.is_empty() })) +} + +#[derive(Deserialize)] +struct NewKey { + #[serde(default)] + key: String, + account: String, + #[serde(default)] + rate_per_min: u32, + #[serde(default)] + allow_models: Vec, +} + +async fn create_key(State(state): State, Json(b): Json) -> ApiResult { + let key = if b.key.trim().is_empty() { + format!("sk-gw-{}", random_token(18)) + } else { + b.key.trim().to_string() + }; + let mut cfg = current_cfg(&state); + if cfg.api_keys.iter().any(|k| k.key == key) { + return Err(ApiError::bad("key already exists")); + } + cfg.api_keys.push(ApiKeyCfg { + key: key.clone(), + account: b.account, + rate_per_min: b.rate_per_min, + allow_models: b.allow_models, + }); + state.reload(cfg)?; + // show the full key only once + ok(json!({ "ok": true, "key": key })) +} + +async fn delete_key(State(state): State, Path(key): Path) -> ApiResult { + let mut cfg = current_cfg(&state); + let before = cfg.api_keys.len(); + cfg.api_keys.retain(|k| k.key != key); + if cfg.api_keys.len() == before { + return Err(ApiError::not_found("key not found")); + } + state.reload(cfg)?; + ok(json!({ "ok": true })) +} + +// ───────────────────────────── secrets ───────────────────────────────────── + +async fn list_secrets(State(state): State) -> ApiResult { + let live = state.live(); + // collect all env names referenced in the config + let mut names: Vec = Vec::new(); + for m in &live.cfg.models { + if let Some(env) = &m.api_key_env { + if !names.contains(env) { + names.push(env.clone()); + } + } + } + if let Some(env) = &live.cfg.router.api_key_env { + if !names.contains(env) { + names.push(env.clone()); + } + } + let secrets: Vec = names + .iter() + .map(|n| json!({ "name": n, "source": state.secrets.source(n) })) + .collect(); + ok(json!({ "secrets": secrets })) +} + +#[derive(Deserialize)] +struct SecretBody { + name: String, + value: String, +} + +async fn set_secret(State(state): State, Json(b): Json) -> ApiResult { + if b.name.trim().is_empty() { + return Err(ApiError::bad("secret name is required")); + } + state.secrets.set(b.name.trim(), &b.value)?; + // rebuild providers with the new key (without writing TOML) + state.rebuild()?; + ok(json!({ "ok": true })) +} + +#[derive(Deserialize)] +struct NameQuery { + name: String, +} + +async fn clear_secret(State(state): State, Query(q): Query) -> ApiResult { + state.secrets.clear(q.name.trim())?; + state.rebuild()?; + ok(json!({ "ok": true })) +} + +// ───────────────────────────── stats / requests ──────────────────────────── + +#[derive(Deserialize)] +struct StatsQuery { + #[serde(default)] + range: Option, + #[serde(default)] + groupby: Option, +} + +async fn get_stats(State(state): State, Query(q): Query) -> ApiResult { + let range_secs = q + .range + .as_deref() + .and_then(parse_duration_secs) + .unwrap_or(24 * 3600); + let groupby = q.groupby.as_deref().unwrap_or("model"); + ok(state.stats.snapshot(range_secs, groupby)) +} + +#[derive(Deserialize)] +struct RecentQuery { + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, +} + +async fn get_requests(State(state): State, Query(q): Query) -> ApiResult { + let limit = q.limit.unwrap_or(50).min(500); + let offset = q.offset.unwrap_or(0); + let items = state.stats.recent(limit, offset); + ok(json!({ "requests": items })) +} + +// ───────────────────────────── playground (test) ─────────────────────────── + +#[derive(Deserialize)] +struct TestBody { + #[serde(default = "default_test_model")] + model: String, + messages: Vec, + #[serde(default)] + temperature: Option, + #[serde(default)] + max_tokens: Option, +} +fn default_test_model() -> String { + "cortiq-auto".into() +} + +async fn run_test(State(state): State, Json(b): Json) -> ApiResult { + if b.messages.is_empty() { + return Err(ApiError::bad("messages must not be empty")); + } + let req = ChatRequest { + routing: parse_routing(&b.model), + messages: b.messages, + tools: vec![], + params: GenParams { + temperature: b.temperature, + max_tokens: b.max_tokens, + ..Default::default() + }, + stream: false, + meta: RequestMeta { + account: "playground".into(), + protocol: "playground".into(), + ..Default::default() + }, + }; + let started = Instant::now(); + let res = state.pipeline.run(req, &state).await; + let latency_ms = started.elapsed().as_millis() as u64; + match res { + Ok(resp) => { + let answer = resp + .choices + .first() + .map(|c| c.message.content.clone()) + .unwrap_or_default(); + ok(json!({ + "ok": true, + "latency_ms": latency_ms, + "answer": answer, + "model_used": resp.model_used, + "usage": { + "prompt_tokens": resp.usage.prompt_tokens, + "completion_tokens": resp.usage.completion_tokens, + "total_tokens": resp.usage.total_tokens, + }, + "cortiq": { + "task_label": resp.cortiq.task_label, + "complexity": { "score": resp.cortiq.complexity_score, "tier": resp.cortiq.complexity_tier }, + "selected_model": resp.cortiq.selected_model, + "route_source": resp.cortiq.route_source, + "router_request_id": resp.cortiq.router_request_id, + "cost_usd": resp.cortiq.cost_usd, + "failover": resp.cortiq.failover, + }, + })) + } + Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })), + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a10ff34 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,474 @@ +//! Configuration loading from TOML. Structs mirror config/gateway.example.toml. +//! Secrets are read from environment variables by the names given in `*_env` fields. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + pub listen: String, + pub router: RouterCfg, + #[serde(default)] + pub route: RouteCfg, + #[serde(default)] + pub models: Vec, + pub routing: RoutingCfg, + #[serde(default)] + pub breaker: BreakerCfg, + #[serde(default)] + pub protocols: ProtocolsCfg, + #[serde(default)] + pub cortiq: CortiqCfg, + #[serde(default)] + pub api_keys: Vec, + #[serde(default)] + pub idempotency: IdempotencyCfg, + #[serde(default)] + pub log: LogCfg, + #[serde(default)] + pub telemetry: TelemetryCfg, + #[serde(default)] + pub admin: AdminCfg, + #[serde(default)] + pub stats: StatsCfg, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RouterCfg { + pub url: String, + #[serde(default = "default_router_timeout")] + pub timeout_ms: u64, + #[serde(default = "default_verify_tls")] + pub verify_tls: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taxonomy_id: Option, +} +fn default_router_timeout() -> u64 { + 800 +} +fn default_verify_tls() -> bool { + true +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RouteCfg { + #[serde(default = "default_text_strategy")] + pub text_strategy: String, + #[serde(default = "default_max_chars")] + pub max_chars: usize, + #[serde(default = "default_cache_ttl")] + pub cache_ttl: String, + #[serde(default = "default_profile")] + pub profile: String, +} +impl Default for RouteCfg { + fn default() -> Self { + Self { + text_strategy: default_text_strategy(), + max_chars: default_max_chars(), + cache_ttl: default_cache_ttl(), + profile: default_profile(), + } + } +} +fn default_text_strategy() -> String { + "last_user".into() +} +fn default_max_chars() -> usize { + 4000 +} +fn default_cache_ttl() -> String { + "60s".into() +} +fn default_profile() -> String { + "balanced".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ModelCfg { + pub id: String, + pub provider: String, // openai | anthropic | ollama | http + pub base_url: String, + pub model: String, + #[serde(default)] + pub cost_tier: String, + #[serde(default)] + pub price_in: f64, + #[serde(default)] + pub price_out: f64, + #[serde(default = "default_kind")] + pub kind: String, // chat | embedding + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub caps: Vec, +} +fn default_kind() -> String { + "chat".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RoutingCfg { + #[serde(flatten)] + pub tiers: HashMap, + pub default: String, + #[serde(default)] + pub policy: RoutingPolicy, +} + +/// A routing tier is either a list of model_ids or the string `default`. +/// (`default` is handled by a separate field; only tier lists appear here.) +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TierTargets { + List(Vec), + One(String), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RoutingPolicy { + #[serde(default = "default_mode")] + pub mode: String, // fixed_table | cost_aware + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_usd_per_request: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub min_class: HashMap, +} +impl Default for RoutingPolicy { + fn default() -> Self { + Self { + mode: default_mode(), + min_class: HashMap::new(), + max_cost_usd_per_request: None, + } + } +} +fn default_mode() -> String { + "fixed_table".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BreakerCfg { + #[serde(default = "default_breaker_threshold")] + pub threshold: u32, + #[serde(default = "default_breaker_cooldown")] + pub cooldown: String, +} +impl Default for BreakerCfg { + fn default() -> Self { + Self { + threshold: default_breaker_threshold(), + cooldown: default_breaker_cooldown(), + } + } +} +fn default_breaker_threshold() -> u32 { + 5 +} +fn default_breaker_cooldown() -> String { + "30s".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ProtocolsCfg { + #[serde(default)] + pub openai_chat: bool, + #[serde(default)] + pub openai_completions: bool, + #[serde(default)] + pub openai_embeddings: bool, + #[serde(default)] + pub openai_models: bool, + #[serde(default)] + pub anthropic_messages: bool, + #[serde(default)] + pub mcp: bool, + #[serde(default)] + pub native_passthrough: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CortiqCfg { + #[serde(default)] + pub echo: bool, +} +impl Default for CortiqCfg { + fn default() -> Self { + Self { echo: true } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiKeyCfg { + pub key: String, + pub account: String, + #[serde(default)] + pub rate_per_min: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_models: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct IdempotencyCfg { + #[serde(default = "default_idem_ttl")] + pub ttl: String, +} +impl Default for IdempotencyCfg { + fn default() -> Self { + Self { + ttl: default_idem_ttl(), + } + } +} +fn default_idem_ttl() -> String { + "10m".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LogCfg { + #[serde(default)] + pub bodies: bool, + #[serde(default = "default_log_level")] + pub level: String, +} +impl Default for LogCfg { + fn default() -> Self { + Self { + bodies: false, + level: default_log_level(), + } + } +} +fn default_log_level() -> String { + "info".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct TelemetryCfg { + #[serde(default)] + pub metrics: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub otlp_endpoint_env: Option, +} + +/// Access to the web management panel and admin API. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AdminCfg { + /// Whether the admin panel and admin API (`/admin`, `/admin/api/*`) are enabled. + #[serde(default = "default_admin_enabled")] + pub enabled: bool, + /// Name of the env variable holding the admin token. If unset (or empty), + /// a token is generated at startup and printed to the log. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_env: Option, + /// Optional separate bind address for the admin surface (e.g. `127.0.0.1:9001`). + /// If unset, the admin interface lives on the main `listen` address under `/admin`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, +} +impl Default for AdminCfg { + fn default() -> Self { + Self { + enabled: default_admin_enabled(), + token_env: None, + listen: None, + } + } +} +fn default_admin_enabled() -> bool { + true +} + +/// Request statistics tracking (for the dashboard and `/metrics`). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StatsCfg { + /// Whether to track request statistics. + #[serde(default = "default_stats_enabled")] + pub enabled: bool, + /// Append-only request log file (JSONL). Empty means in-memory only. + #[serde(default = "default_stats_file")] + pub file: String, + /// How many recent requests to keep in memory (the "recent" ring buffer). + #[serde(default = "default_stats_ring")] + pub ring_size: usize, + /// Retention window for aggregates/time-series replayed on startup, e.g. `7d`. + #[serde(default = "default_stats_retention")] + pub retention: String, +} +impl Default for StatsCfg { + fn default() -> Self { + Self { + enabled: default_stats_enabled(), + file: default_stats_file(), + ring_size: default_stats_ring(), + retention: default_stats_retention(), + } + } +} +fn default_stats_enabled() -> bool { + true +} +fn default_stats_file() -> String { + "config/stats.jsonl".into() +} +fn default_stats_ring() -> usize { + 500 +} +fn default_stats_retention() -> String { + "7d".into() +} + +impl Config { + /// Load and validate the config from a file. + pub fn load(path: &str) -> anyhow::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?; + let cfg: Config = toml::from_str(&raw)?; + cfg.validate()?; + Ok(cfg) + } + + /// Integrity validation: non-empty model pool, valid routing targets. + pub fn validate(&self) -> anyhow::Result<()> { + if self.models.is_empty() { + anyhow::bail!("config: at least one [[models]] entry is required"); + } + // uniqueness of model ids + let mut seen = std::collections::HashSet::new(); + for m in &self.models { + if !seen.insert(m.id.as_str()) { + anyhow::bail!("config: duplicate model id '{}'", m.id); + } + } + // every target in [routing] must exist in the model pool + let ids: std::collections::HashSet<&str> = + self.models.iter().map(|m| m.id.as_str()).collect(); + if !ids.contains(self.routing.default.as_str()) { + anyhow::bail!( + "config: routing.default '{}' is not a known model id", + self.routing.default + ); + } + for (tier, targets) in &self.routing.tiers { + let list = match targets { + TierTargets::List(v) => v.clone(), + TierTargets::One(s) => vec![s.clone()], + }; + for id in &list { + if !ids.contains(id.as_str()) { + anyhow::bail!( + "config: routing.{} references unknown model id '{}'", + tier, + id + ); + } + } + } + Ok(()) + } + + /// Serialize the config to a TOML string (no secrets — they are intentionally absent here). + pub fn to_toml_string(&self) -> anyhow::Result { + let header = "# Cortiq Gateway — configuration (managed via the admin panel).\n\ + # This file is rewritten by the panel; manual edits are preserved\n\ + # only until the next change made through the UI.\n\n"; + let body = toml::to_string_pretty(self)?; + Ok(format!("{header}{body}")) + } + + /// Atomically save the config to a file (write to a temporary file then rename). + pub fn save(&self, path: &str) -> anyhow::Result<()> { + self.validate()?; + let data = self.to_toml_string()?; + let p = std::path::Path::new(path); + let dir = p.parent().unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(dir).ok(); + let tmp = p.with_extension("toml.tmp"); + std::fs::write(&tmp, data.as_bytes()) + .map_err(|e| anyhow::anyhow!("cannot write config tmp {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, p) + .map_err(|e| anyhow::anyhow!("cannot replace config {path}: {e}"))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +listen = "0.0.0.0:9000" + +[router] +url = "http://localhost:8080" +api_key_env = "CORTIQ_ROUTER_KEY" + +[[models]] +id = "local-qwen" +provider = "openai" +base_url = "http://localhost:8000/v1" +model = "qwen2.5-7b-instruct" +cost_tier = "cheap" +caps = ["tools"] + +[[models]] +id = "gpt-4o-mini" +provider = "openai" +base_url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key_env = "OPENAI_API_KEY" +cost_tier = "mid" +price_in = 0.15 +price_out = 0.6 + +[routing] +low = ["local-qwen"] +medium = ["gpt-4o-mini", "local-qwen"] +default = "gpt-4o-mini" + +[routing.policy] +mode = "cost_aware" +max_cost_usd_per_request = 0.5 +min_class = { low = "cheap", medium = "mid" } +"#; + + #[test] + fn roundtrip_preserves_routing_and_models() { + let cfg: Config = toml::from_str(SAMPLE).expect("parse sample"); + cfg.validate().expect("sample valid"); + + // key risk: serialization of flattened [routing] + nested policy + let serialized = cfg.to_toml_string().expect("serialize"); + let back: Config = toml::from_str(&serialized).expect("re-parse serialized"); + back.validate().expect("roundtrip valid"); + + assert_eq!(back.listen, "0.0.0.0:9000"); + assert_eq!(back.models.len(), 2); + assert_eq!(back.routing.default, "gpt-4o-mini"); + assert_eq!(back.routing.policy.mode, "cost_aware"); + assert_eq!(back.routing.policy.max_cost_usd_per_request, Some(0.5)); + assert_eq!( + back.routing.policy.min_class.get("low").map(String::as_str), + Some("cheap") + ); + + let low = back.routing.tiers.get("low").expect("low tier present"); + match low { + TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]), + TierTargets::One(s) => assert_eq!(s, "local-qwen"), + } + + // admin/stats sections receive defaults + assert!(back.admin.enabled); + assert!(back.stats.enabled); + } + + #[test] + fn validate_rejects_unknown_routing_target() { + let mut cfg: Config = toml::from_str(SAMPLE).unwrap(); + cfg.routing.default = "does-not-exist".into(); + assert!(cfg.validate().is_err()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..5a11244 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,74 @@ +//! Unified gateway error type + rendering into an OpenAI-compatible envelope +//! `{"error": {message, type, code, param}}` that SDK clients parse natively. +//! See docs/PROTOCOLS.md §8.5. + +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum GatewayError { + #[error("{0}")] + InvalidRequest(String), + #[error("authentication failed: {0}")] + Unauthorized(String), + #[error("rate limit exceeded")] + RateLimited { retry_after_secs: u64 }, + #[error("upstream unavailable: {0}")] + UpstreamUnavailable(String), + #[error("internal error: {0}")] + Internal(String), +} + +impl GatewayError { + fn parts(&self) -> (StatusCode, &'static str, &'static str) { + match self { + Self::InvalidRequest(_) => ( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "invalid_request", + ), + Self::Unauthorized(_) => ( + StatusCode::UNAUTHORIZED, + "authentication_error", + "unauthorized", + ), + Self::RateLimited { .. } => ( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + "rate_limited", + ), + Self::UpstreamUnavailable(_) => ( + StatusCode::BAD_GATEWAY, + "upstream_unavailable", + "no_healthy_model", + ), + Self::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "internal", + ), + } + } +} + +impl IntoResponse for GatewayError { + fn into_response(self) -> axum::response::Response { + let (status, typ, code) = self.parts(); + let body = Json(json!({ + "error": { + "message": self.to_string(), + "type": typ, + "code": code, + "param": serde_json::Value::Null, + } + })); + let mut resp = (status, body).into_response(); + if let Self::RateLimited { retry_after_secs } = self { + resp.headers_mut() + .insert("retry-after", retry_after_secs.to_string().parse().unwrap()); + } + resp + } +} + +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9b2ceaa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,107 @@ +//! Cortiq Gateway — entry point. +//! +//! Loads the config, builds the shared state, assembles routes from protocols and +//! the admin panel, and starts the HTTP server. See docs/ARCHITECTURE.md. + +// Part of the canonical model / error API surface (auth, rate-limit, idempotency, +// traceparent, vision/streaming caps) is intentionally reserved for upcoming phases; +// allow the unused items until those features land. +#![allow(dead_code)] + +mod admin; +mod config; +mod error; +mod model; +mod pipeline; +mod protocols; +mod providers; +mod registry; +mod router_client; +mod routing; +mod secrets; +mod state; +mod stats; + +use axum::{routing::get, Router}; +use clap::Parser; +use config::Config; +use state::SharedState; + +#[derive(Parser)] +#[command( + name = "cortiq-gateway", + version, + about = "Universal LLM gateway with intelligent routing" +)] +struct Args { + /// Path to the TOML config file. + #[arg(long, default_value = "config/gateway.toml")] + config: String, + /// Admin token for the web panel (overrides `[admin].token_env`). + #[arg(long)] + admin_token: Option, +} + +/// Resolve the admin token: env from `[admin].token_env` → `--admin-token` → +/// generate a random one. Returns `(token, generated)`. +fn resolve_admin_token(cfg: &Config, args: &Args) -> (String, bool) { + if let Some(env) = &cfg.admin.token_env { + if let Ok(v) = std::env::var(env) { + if !v.is_empty() { + return (v, false); + } + } + } + if let Some(t) = &args.admin_token { + if !t.is_empty() { + return (t.clone(), false); + } + } + (admin::random_token(24), true) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + let cfg = Config::load(&args.config)?; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| cfg.log.level.clone().into()), + ) + .init(); + + let listen = cfg.listen.clone(); + let admin_enabled = cfg.admin.enabled; + let (admin_token, generated) = resolve_admin_token(&cfg, &args); + + let state = SharedState::build(cfg, args.config.clone())?; + + let mut app = Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/readyz", get(|| async { "ready" })) + .route("/metrics", get(admin::metrics)) + .merge(protocols::build_router()); + + if admin_enabled { + app = app + .merge(admin::api_routes(admin_token.clone())) + .fallback(admin::assets::fallback); + } + + let app = app.with_state(state); + + let listener = tokio::net::TcpListener::bind(&listen).await?; + tracing::info!("cortiq-gateway listening on {listen}"); + if admin_enabled { + tracing::info!("admin console: http://{listen}/admin"); + if generated { + tracing::warn!( + "admin token (auto-generated, set [admin].token_env to persist): {admin_token}" + ); + } + } + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..ec9efe6 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,102 @@ +//! Canonical (protocol-neutral) request/response model. +//! +//! All incoming protocols are translated into these types, and providers translate +//! from them. This way N protocols × M providers do not become N×M translations: +//! each adapter only knows its own protocol ↔ canonical mapping. See docs/ARCHITECTURE.md §3. + +use serde::{Deserialize, Serialize}; + +/// How to handle routing for a specific request. +#[derive(Clone, Debug)] +pub enum RoutingDirective { + /// `model = "cortiq-auto"` — ask cortiq-router and pick a model from the pool. + Auto { profile: Option }, + /// `model = ""` — call a specific model directly, bypassing routing. + Pinned { model_id: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Message { + pub role: String, // system | user | assistant | tool + #[serde(default)] + pub content: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct GenParams { + pub temperature: Option, + pub max_tokens: Option, + pub top_p: Option, + pub stop: Vec, + /// Fields we do not interpret but must proxy through to the provider. + pub passthrough: serde_json::Map, +} + +#[derive(Clone, Debug, Default)] +pub struct RequestMeta { + pub account: String, + pub protocol: String, + pub idempotency_key: Option, + pub traceparent: Option, +} + +/// Canonical form of a generation request. +#[derive(Clone, Debug)] +pub struct ChatRequest { + pub routing: RoutingDirective, + pub messages: Vec, + pub tools: Vec, + pub params: GenParams, + pub stream: bool, + pub meta: RequestMeta, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +/// Routing metadata attached to the response (X-Cortiq-* headers and the `cortiq` field). +#[derive(Clone, Debug, Serialize)] +pub struct RouteInfo { + pub task_label: String, + pub complexity_score: f32, + pub complexity_tier: String, + pub selected_model: String, + pub route_source: String, // router | cache | fallback | pinned + pub router_request_id: Option, + pub cost_usd: f64, + #[serde(default)] + pub failover: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Choice { + pub index: u32, + pub message: Message, + pub finish_reason: String, +} + +/// Canonical form of a response. +#[derive(Clone, Debug)] +pub struct ChatResponse { + pub id: String, + pub model_used: String, + pub choices: Vec, + pub usage: Usage, + pub cortiq: RouteInfo, +} + +/// Decision from cortiq-router (the subset of its contract needed by the gateway). +#[derive(Clone, Debug)] +pub struct RouteDecision { + pub task_label: String, + pub complexity_score: f32, + pub complexity_tier: String, + pub router_request_id: Option, + pub source: String, // router | cache | fallback +} diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..8cd96db --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,251 @@ +//! Canonical request processing pipeline (Pipeline). +//! Coordinates stages: Extract -> Route -> Select -> Translate & Call, +//! and also measures latency and records per-request statistics. + +use crate::error::{GatewayError, Result}; +use crate::model::{ + ChatRequest, ChatResponse, Message, RouteDecision, RouteInfo, RoutingDirective, +}; +use crate::state::SharedState; +use crate::stats::RequestRecord; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +pub struct Pipeline; + +impl Pipeline { + pub fn new() -> Self { + Self + } + + /// Run the routing and generation pipeline. + pub async fn run(&self, req: ChatRequest, state: &SharedState) -> Result { + let started = Instant::now(); + let account = req.meta.account.clone(); + let protocol = req.meta.protocol.clone(); + let directive = match &req.routing { + RoutingDirective::Pinned { .. } => "pinned", + RoutingDirective::Auto { .. } => "auto", + } + .to_string(); + + let result = self.run_inner(req, state).await; + + // record statistics (for both success and error) + let latency_ms = started.elapsed().as_millis() as u64; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let rec = match &result { + Ok(resp) => RequestRecord { + ts, + account, + protocol, + directive, + task_label: resp.cortiq.task_label.clone(), + tier: resp.cortiq.complexity_tier.clone(), + score: resp.cortiq.complexity_score, + model_id: resp.cortiq.selected_model.clone(), + route_source: resp.cortiq.route_source.clone(), + prompt_tokens: resp.usage.prompt_tokens, + completion_tokens: resp.usage.completion_tokens, + cost_usd: resp.cortiq.cost_usd, + latency_ms, + outcome: "ok".to_string(), + failover: resp.cortiq.failover, + error: None, + }, + Err(err) => RequestRecord { + ts, + account, + protocol, + directive, + task_label: String::new(), + tier: String::new(), + score: 0.0, + model_id: String::new(), + route_source: String::new(), + prompt_tokens: 0, + completion_tokens: 0, + cost_usd: 0.0, + latency_ms, + outcome: "error".to_string(), + failover: false, + error: Some(err.to_string()), + }, + }; + state.stats.record(rec); + + result + } + + async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result { + let live = state.live(); + + // 1. Extract text for classification + let text = self.extract_text( + &req.messages, + &live.cfg.route.text_strategy, + live.cfg.route.max_chars, + ); + + // 2. Obtain the router decision (or use the pinned model) + let (decision, route_source) = match &req.routing { + RoutingDirective::Pinned { model_id: _ } => { + let dec = RouteDecision { + task_label: "pinned".to_string(), + complexity_score: 0.0, + complexity_tier: "pinned".to_string(), + router_request_id: None, + source: "pinned".to_string(), + }; + (dec, "pinned".to_string()) + } + RoutingDirective::Auto { profile } => { + let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile); + match live.router.route(&text, prof).await { + Ok(Some(dec)) => { + let src = dec.source.clone(); + (dec, src) + } + Ok(None) | Err(_) => { + tracing::warn!( + "Router is unavailable. Gracefully degrading to default model." + ); + let dec = RouteDecision { + task_label: "degraded".to_string(), + complexity_score: 0.5, + complexity_tier: "degraded".to_string(), + router_request_id: None, + source: "fallback".to_string(), + }; + (dec, "fallback".to_string()) + } + } + } + }; + + // 3. Select candidates by complexity tier / pinned model + let candidates = match &req.routing { + RoutingDirective::Pinned { model_id } => vec![model_id.clone()], + RoutingDirective::Auto { .. } => { + if route_source == "fallback" { + vec![live.routing.default_model().to_string()] + } else { + live.routing.candidates(&decision.complexity_tier) + } + } + }; + + // Filter candidates by capabilities (e.g. tools support) + let needs_tools = !req.tools.is_empty(); + let mut final_candidates = Vec::new(); + for model_id in candidates { + if let Some(prov) = live.registry.get(&model_id) { + if needs_tools && !prov.caps().tools { + tracing::debug!( + "Candidate '{}' skipped: lacks support for requested tools", + model_id + ); + continue; + } + final_candidates.push(model_id); + } + } + + if final_candidates.is_empty() { + return Err(GatewayError::UpstreamUnavailable( + "No healthy model available that satisfies required capabilities (e.g. tools)" + .to_string(), + )); + } + + // 4. Attempt provider call with failover (fall back to alternatives on error) + let mut last_err = None; + let mut attempt = 0usize; + for model_id in final_candidates { + let provider = match live.registry.get(&model_id) { + Some(p) => p, + None => continue, + }; + + tracing::info!( + "Routing request (label: {}, tier: {}) to model '{}' via source '{}'", + decision.task_label, + decision.complexity_tier, + model_id, + route_source + ); + + match provider.chat(req.clone()).await { + Ok(mut resp) => { + let cost = + provider.price(resp.usage.prompt_tokens, resp.usage.completion_tokens); + resp.cortiq = RouteInfo { + task_label: decision.task_label.clone(), + complexity_score: decision.complexity_score, + complexity_tier: decision.complexity_tier.clone(), + selected_model: model_id, + route_source: route_source.clone(), + router_request_id: decision.router_request_id.clone(), + cost_usd: cost, + failover: attempt > 0, + }; + return Ok(resp); + } + Err(err) => { + tracing::warn!( + "Provider '{}' call failed: {:?}. Attempting failover...", + model_id, + err + ); + last_err = Some(err); + attempt += 1; + } + } + } + + Err(last_err.unwrap_or_else(|| { + GatewayError::UpstreamUnavailable("All candidate models failed".to_string()) + })) + } + + fn extract_text(&self, messages: &[Message], strategy: &str, max_chars: usize) -> String { + let raw_text = match strategy { + "last_user" => messages + .iter() + .rfind(|m| m.role == "user") + .map(|m| m.content.clone()) + .unwrap_or_default(), + "last_user_plus_system" => { + let system_msgs: Vec = messages + .iter() + .filter(|m| m.role == "system") + .map(|m| m.content.clone()) + .collect(); + let last_user = messages + .iter() + .rfind(|m| m.role == "user") + .map(|m| m.content.as_str()) + .unwrap_or(""); + if system_msgs.is_empty() { + last_user.to_string() + } else { + format!("{}\n{}", system_msgs.join("\n"), last_user) + } + } + // "concat_all" and any unknown strategy fall back to concatenating all turns + _ => messages + .iter() + .map(|m| format!("{}: {}", m.role, m.content)) + .collect::>() + .join("\n"), + }; + + if raw_text.chars().count() > max_chars { + raw_text.chars().take(max_chars).collect() + } else { + raw_text + } + } +} diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs new file mode 100644 index 0000000..273bccd --- /dev/null +++ b/src/protocols/mod.rs @@ -0,0 +1,17 @@ +//! Incoming protocol adapters. Each one builds its own axum routes and translates +//! its protocol to/from the canonical model. Enabled via flags in `[protocols]`. + +pub mod openai_chat; + +use crate::state::SharedState; +use axum::Router; + +/// Build the protocols router. Implemented adapters are **always** mounted; +/// each handler checks the live `protocols.*` flag from config itself, so +/// toggles in the admin panel take effect without a restart (hot switching). +pub fn build_router() -> Router { + Router::new().merge(openai_chat::routes()) + // TODO: openai_completions, openai_embeddings, openai_models, + // anthropic_messages, mcp, native_passthrough — add here as well, + // with a check of the corresponding flag inside the handler. +} diff --git a/src/protocols/openai_chat.rs b/src/protocols/openai_chat.rs new file mode 100644 index 0000000..1f65119 --- /dev/null +++ b/src/protocols/openai_chat.rs @@ -0,0 +1,168 @@ +//! Incoming adapter for OpenAI Chat Completions: `POST /v1/chat/completions`. +//! Translates the OpenAI request body into a canonical [`ChatRequest`], passes it +//! to the pipeline, then converts the canonical [`ChatResponse`] back to OpenAI format. + +use crate::error::{GatewayError, Result}; +use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective}; +use crate::state::SharedState; +use axum::{extract::State, routing::post, Json, Router}; +use serde::Deserialize; + +pub fn routes() -> Router { + Router::new().route("/v1/chat/completions", post(handler)) +} + +#[derive(Deserialize)] +struct OpenAiChatRequest { + model: String, + messages: Vec, + #[serde(default)] + temperature: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + top_p: Option, + #[serde(default)] + stream: bool, + #[serde(default)] + tools: Vec, + #[serde(flatten)] + rest: serde_json::Map, +} + +/// Parse the `model` field value into a routing directive. +/// `cortiq-auto[:profile]` → Auto; otherwise → Pinned(real id). +fn parse_routing(model: &str) -> RoutingDirective { + if let Some(rest) = model.strip_prefix("cortiq-auto") { + let profile = rest.strip_prefix(':').map(|p| p.to_string()); + RoutingDirective::Auto { profile } + } else { + RoutingDirective::Pinned { + model_id: model.to_string(), + } + } +} + +async fn handler( + State(state): State, + Json(req): Json, +) -> Result { + // hot protocol toggle: if the adapter is disabled in config — return 404 + if !state.live().cfg.protocols.openai_chat { + return Err(GatewayError::InvalidRequest( + "openai_chat protocol is disabled".into(), + )); + } + if req.messages.is_empty() { + return Err(GatewayError::InvalidRequest( + "messages must not be empty".into(), + )); + } + + let canonical = ChatRequest { + routing: parse_routing(&req.model), + messages: req.messages, + tools: req.tools, + params: GenParams { + temperature: req.temperature, + max_tokens: req.max_tokens, + top_p: req.top_p, + stop: Vec::new(), + passthrough: req.rest, + }, + stream: req.stream, + meta: RequestMeta { + protocol: "openai_chat".into(), + ..Default::default() + }, + }; + + if req.stream { + return Err(GatewayError::InvalidRequest( + "Streaming is not yet supported in this version".into(), + )); + } + + let resp = state.pipeline.run(canonical, &state).await?; + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "X-Cortiq-Task-Label", + axum::http::HeaderValue::from_str(&resp.cortiq.task_label) + .unwrap_or(axum::http::HeaderValue::from_static("")), + ); + headers.insert( + "X-Cortiq-Complexity-Score", + axum::http::HeaderValue::from_str(&resp.cortiq.complexity_score.to_string()) + .unwrap_or(axum::http::HeaderValue::from_static("0.0")), + ); + headers.insert( + "X-Cortiq-Complexity-Tier", + axum::http::HeaderValue::from_str(&resp.cortiq.complexity_tier) + .unwrap_or(axum::http::HeaderValue::from_static("")), + ); + headers.insert( + "X-Cortiq-Selected-Model", + axum::http::HeaderValue::from_str(&resp.cortiq.selected_model) + .unwrap_or(axum::http::HeaderValue::from_static("")), + ); + headers.insert( + "X-Cortiq-Route-Source", + axum::http::HeaderValue::from_str(&resp.cortiq.route_source) + .unwrap_or(axum::http::HeaderValue::from_static("")), + ); + headers.insert( + "X-Cortiq-Cost-Usd", + axum::http::HeaderValue::from_str(&resp.cortiq.cost_usd.to_string()) + .unwrap_or(axum::http::HeaderValue::from_static("0.0")), + ); + if let Some(req_id) = &resp.cortiq.router_request_id { + headers.insert( + "X-Cortiq-Request-Id", + axum::http::HeaderValue::from_str(req_id) + .unwrap_or(axum::http::HeaderValue::from_static("")), + ); + } + + let mut body = serde_json::json!({ + "id": resp.id, + "object": "chat.completion", + "created": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + "model": resp.model_used, + "choices": resp.choices.iter().map(|c| { + serde_json::json!({ + "index": c.index, + "message": { + "role": c.message.role, + "content": c.message.content, + "tool_calls": if c.message.tool_calls.is_empty() { serde_json::Value::Null } else { serde_json::Value::Array(c.message.tool_calls.clone()) }, + }, + "finish_reason": c.finish_reason, + }) + }).collect::>(), + "usage": { + "prompt_tokens": resp.usage.prompt_tokens, + "completion_tokens": resp.usage.completion_tokens, + "total_tokens": resp.usage.total_tokens, + } + }); + + if state.live().cfg.cortiq.echo { + body["cortiq"] = serde_json::json!({ + "task_label": resp.cortiq.task_label, + "complexity": { + "score": resp.cortiq.complexity_score, + "tier": resp.cortiq.complexity_tier, + }, + "selected_model": resp.cortiq.selected_model, + "route_source": resp.cortiq.route_source, + "router_request_id": resp.cortiq.router_request_id, + "cost_usd": resp.cortiq.cost_usd, + }); + } + + Ok((headers, Json(body))) +} diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs new file mode 100644 index 0000000..0af8d20 --- /dev/null +++ b/src/providers/anthropic.rs @@ -0,0 +1,57 @@ +//! Anthropic Messages provider (v0.2). Translates the canonical ChatRequest to +//! `POST /v1/messages` and back. The `system` message is extracted into a separate field. + +use super::{Caps, Provider}; +use crate::config::ModelCfg; +use crate::error::{GatewayError, Result}; +use crate::model::{ChatRequest, ChatResponse}; +use async_trait::async_trait; + +pub struct AnthropicProvider { + id: String, + #[allow(dead_code)] + base_url: String, + #[allow(dead_code)] + model: String, + #[allow(dead_code)] + api_key: Option, + caps: Caps, + price_in: f64, + price_out: f64, +} + +impl AnthropicProvider { + pub fn new(cfg: &ModelCfg, api_key: Option) -> anyhow::Result { + Ok(Self { + id: cfg.id.clone(), + base_url: cfg.base_url.trim_end_matches('/').to_string(), + model: cfg.model.clone(), + api_key, + caps: Caps::from_list(&cfg.caps), + price_in: cfg.price_in, + price_out: cfg.price_out, + }) + } +} + +#[async_trait] +impl Provider for AnthropicProvider { + fn id(&self) -> &str { + &self.id + } + fn caps(&self) -> &Caps { + &self.caps + } + fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64 { + (prompt_tokens as f64 * self.price_in + completion_tokens as f64 * self.price_out) + / 1_000_000.0 + } + async fn chat(&self, _req: ChatRequest) -> Result { + // TODO(v0.2): extract system from messages → "system" field; messages → content blocks; + // add x-api-key + anthropic-version headers; map response → canonical form. + // Returns an error (not a panic) so that failover/playground continue to work normally. + Err(GatewayError::UpstreamUnavailable( + "anthropic provider not yet implemented (planned v0.2)".to_string(), + )) + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..e7daf0d --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,46 @@ +//! Outgoing provider adapters. The [`Provider`] trait is the single call site for LLMs; +//! adapters translate the canonical [`ChatRequest`] to and from each provider's API format. +//! +//! Most local servers (vLLM, llama.cpp, LM Studio, Ollama `/v1`) are OpenAI-compatible +//! → [`openai::OpenAiProvider`] is sufficient for all of them. + +pub mod anthropic; +pub mod openai; + +use crate::model::{ChatRequest, ChatResponse}; +use async_trait::async_trait; + +/// Model capabilities — checked at selection time (whether tools/vision are needed). +#[derive(Clone, Debug, Default)] +pub struct Caps { + pub tools: bool, + pub vision: bool, + pub streaming: bool, +} + +impl Caps { + pub fn from_list(list: &[String]) -> Self { + Self { + tools: list.iter().any(|c| c == "tools"), + vision: list.iter().any(|c| c == "vision"), + streaming: true, + } + } +} + +#[async_trait] +pub trait Provider: Send + Sync { + /// The model_id from config (not the provider's model name). + fn id(&self) -> &str; + + fn caps(&self) -> &Caps; + + /// Cost of a request in USD — computed from price_in/price_out × tokens (for billing). + fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64; + + /// Non-streaming chat call. + async fn chat(&self, req: ChatRequest) -> crate::error::Result; + + // TODO(v0.1): async fn chat_stream(...) -> Result (SSE). + // TODO(v0.1): async fn embed(...) -> Result. +} diff --git a/src/providers/openai.rs b/src/providers/openai.rs new file mode 100644 index 0000000..8012624 --- /dev/null +++ b/src/providers/openai.rs @@ -0,0 +1,157 @@ +//! OpenAI-compatible provider. Covers OpenAI, OpenRouter, Together, Groq, +//! and local servers such as vLLM / llama.cpp / LM Studio / Ollama (`/v1`). + +use super::{Caps, Provider}; +use crate::config::ModelCfg; +use crate::error::{GatewayError, Result}; +use crate::model::{ChatRequest, ChatResponse}; +use async_trait::async_trait; + +pub struct OpenAiProvider { + id: String, + base_url: String, + model: String, + api_key: Option, + caps: Caps, + price_in: f64, + price_out: f64, + http: reqwest::Client, +} + +impl OpenAiProvider { + pub fn new(cfg: &ModelCfg, api_key: Option) -> anyhow::Result { + Ok(Self { + id: cfg.id.clone(), + base_url: cfg.base_url.trim_end_matches('/').to_string(), + model: cfg.model.clone(), + api_key, + caps: Caps::from_list(&cfg.caps), + price_in: cfg.price_in, + price_out: cfg.price_out, + http: reqwest::Client::new(), + }) + } + + /// Canonical request → OpenAI Chat Completions request body. + fn to_wire(&self, req: &ChatRequest) -> serde_json::Value { + let mut body = serde_json::json!({ + "model": self.model, + "messages": req.messages, + "stream": req.stream, + }); + if let Some(t) = req.params.temperature { + body["temperature"] = t.into(); + } + if let Some(m) = req.params.max_tokens { + body["max_tokens"] = m.into(); + } + if !req.tools.is_empty() { + body["tools"] = serde_json::Value::Array(req.tools.clone()); + } + // uninterpreted fields are proxied as-is + for (k, v) in &req.params.passthrough { + body[k] = v.clone(); + } + body + } +} + +#[async_trait] +impl Provider for OpenAiProvider { + fn id(&self) -> &str { + &self.id + } + fn caps(&self) -> &Caps { + &self.caps + } + fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64 { + (prompt_tokens as f64 * self.price_in + completion_tokens as f64 * self.price_out) + / 1_000_000.0 + } + + async fn chat(&self, req: ChatRequest) -> Result { + let mut r = self + .http + .post(format!("{}/chat/completions", self.base_url)) + .json(&self.to_wire(&req)); + if let Some(key) = &self.api_key { + r = r.bearer_auth(key); + } + let resp = r + .send() + .await + .map_err(|e| GatewayError::UpstreamUnavailable(e.to_string()))?; + if !resp.status().is_success() { + let status = resp.status(); + let err_body = resp.text().await.unwrap_or_default(); + return Err(GatewayError::UpstreamUnavailable(format!( + "provider {} returned HTTP {}: {}", + self.id, status, err_body + ))); + } + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| GatewayError::Internal(format!("Failed to parse JSON response: {}", e)))?; + + let id = v["id"].as_str().unwrap_or("").to_string(); + let model_used = v["model"].as_str().unwrap_or(&self.model).to_string(); + + let choices_arr = v["choices"].as_array().ok_or_else(|| { + GatewayError::Internal( + "invalid openai response: choices is missing or not an array".to_string(), + ) + })?; + + let mut choices = Vec::new(); + for (i, c) in choices_arr.iter().enumerate() { + let msg_val = &c["message"]; + let role = msg_val["role"].as_str().unwrap_or("assistant").to_string(); + let content = msg_val["content"].as_str().unwrap_or("").to_string(); + + let tool_calls = msg_val["tool_calls"] + .as_array() + .cloned() + .unwrap_or_default(); + + choices.push(crate::model::Choice { + index: c["index"].as_u64().unwrap_or(i as u64) as u32, + message: crate::model::Message { + role, + content, + tool_calls, + }, + finish_reason: c["finish_reason"].as_str().unwrap_or("stop").to_string(), + }); + } + + let usage_val = &v["usage"]; + let prompt_tokens = usage_val["prompt_tokens"].as_u64().unwrap_or(0) as u32; + let completion_tokens = usage_val["completion_tokens"].as_u64().unwrap_or(0) as u32; + let total_tokens = usage_val["total_tokens"] + .as_u64() + .unwrap_or((prompt_tokens + completion_tokens) as u64) + as u32; + + Ok(ChatResponse { + id, + model_used, + choices, + usage: crate::model::Usage { + prompt_tokens, + completion_tokens, + total_tokens, + }, + cortiq: crate::model::RouteInfo { + task_label: String::new(), + complexity_score: 0.0, + complexity_tier: String::new(), + selected_model: self.id.clone(), + route_source: String::new(), + router_request_id: None, + cost_usd: 0.0, + failover: false, + }, + }) + } +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..9961770 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,43 @@ +//! Model pool registry: builds providers from `[[models]]` and resolves +//! `model_id → Arc`. + +use crate::config::Config; +use crate::providers::{anthropic::AnthropicProvider, openai::OpenAiProvider, Provider}; +use crate::secrets::SecretStore; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct Registry { + by_id: HashMap>, +} + +impl Registry { + pub fn from_config(cfg: &Config, secrets: &SecretStore) -> anyhow::Result { + let mut by_id: HashMap> = HashMap::new(); + for m in &cfg.models { + if m.kind == "embedding" { + continue; // embedding models are handled by a separate branch (v0.1) + } + // provider key: secret store → environment variable + let api_key = m + .api_key_env + .as_ref() + .and_then(|name| secrets.resolve(name)); + let provider: Arc = match m.provider.as_str() { + "openai" | "ollama" | "http" => Arc::new(OpenAiProvider::new(m, api_key)?), + "anthropic" => Arc::new(AnthropicProvider::new(m, api_key)?), + other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id), + }; + by_id.insert(m.id.clone(), provider); + } + Ok(Self { by_id }) + } + + pub fn get(&self, model_id: &str) -> Option> { + self.by_id.get(model_id).cloned() + } + + pub fn ids(&self) -> impl Iterator { + self.by_id.keys() + } +} diff --git a/src/router_client.rs b/src/router_client.rs new file mode 100644 index 0000000..09febea --- /dev/null +++ b/src/router_client.rs @@ -0,0 +1,78 @@ +//! Client for cortiq-router. Issues `POST /v1/route` and (optionally) `POST /v1/feedback`. +//! Returns the decision as a [`RouteDecision`]. When the router is unavailable the caller +//! falls back to `routing.default` (graceful degradation). + +use crate::config::RouterCfg; +use crate::model::RouteDecision; + +pub struct RouterClient { + http: reqwest::Client, + url: String, + api_key: Option, + taxonomy_id: Option, +} + +impl RouterClient { + pub fn new(cfg: &RouterCfg) -> anyhow::Result { + let mut builder = + reqwest::Client::builder().timeout(std::time::Duration::from_millis(cfg.timeout_ms)); + + if !cfg.verify_tls { + builder = builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + let http = builder.build()?; + let api_key = cfg + .api_key_env + .as_ref() + .and_then(|name| std::env::var(name).ok()); + Ok(Self { + http, + url: cfg.url.trim_end_matches('/').to_string(), + api_key, + taxonomy_id: cfg.taxonomy_id.clone(), + }) + } + + /// Classify text. Returns `Ok(None)` on network error/timeout — + /// the caller treats this as "router unavailable" and uses the default tier. + pub async fn route(&self, text: &str, profile: &str) -> anyhow::Result> { + let mut body = serde_json::json!({ + "input": { "text": text }, + "options": { "policy_profile": profile, "allow_oracle": true } + }); + if let Some(tax) = &self.taxonomy_id { + body["taxonomy_id"] = serde_json::Value::String(tax.clone()); + } + + let mut req = self.http.post(format!("{}/v1/route", self.url)).json(&body); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + + let resp = match req.send().await { + Ok(r) => r, + Err(_) => return Ok(None), // degradation + }; + if !resp.status().is_success() { + return Ok(None); + } + let v: serde_json::Value = resp.json().await?; + let d = &v["decision"]; + Ok(Some(RouteDecision { + task_label: d["task_label"] + .as_str() + .unwrap_or("__unknown__") + .to_string(), + complexity_score: d["complexity"]["score"].as_f64().unwrap_or(0.5) as f32, + complexity_tier: d["complexity"]["tier"] + .as_str() + .unwrap_or("medium") + .to_string(), + router_request_id: v["request_id"].as_str().map(|s| s.to_string()), + source: "router".into(), + })) + } +} diff --git a/src/routing.rs b/src/routing.rs new file mode 100644 index 0000000..a49a637 --- /dev/null +++ b/src/routing.rs @@ -0,0 +1,48 @@ +//! Model selection: `(task_label, complexity_tier) → ordered list of model_ids`. +//! The list serves as both a priority order and a fallback chain. See docs/ROUTING.md. + +use crate::config::{Config, TierTargets}; + +pub struct RoutingTable { + tiers: std::collections::HashMap>, + default: String, +} + +impl RoutingTable { + pub fn from_config(cfg: &Config) -> Self { + let tiers = cfg + .routing + .tiers + .iter() + .map(|(tier, targets)| { + let list = match targets { + TierTargets::List(v) => v.clone(), + TierTargets::One(s) => vec![s.clone()], + }; + (tier.clone(), list) + }) + .collect(); + Self { + tiers, + default: cfg.routing.default.clone(), + } + } + + /// Candidates for the given complexity tier, in preference order. + /// If the tier is not defined, the sole candidate is `default`. + pub fn candidates(&self, tier: &str) -> Vec { + self.tiers + .get(tier) + .cloned() + .unwrap_or_else(|| vec![self.default.clone()]) + } + + /// Default model (used when the router is unavailable). + pub fn default_model(&self) -> &str { + &self.default + } +} + +// TODO(v0.2): cost_aware mode and circuit breaker — pick the cheapest available +// model no lower than min_class[tier], respecting max_cost_usd_per_request, +// and skip models whose circuit breaker is open. diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..612a04f --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,96 @@ +//! Secret store (provider API key values) kept outside the main config. +//! +//! The file `config/secrets.toml` (in `.gitignore`, mode `0600`) is a map of +//! `env_name -> value`. Key resolution order: store entry > environment variable. +//! This allows setting keys directly from the admin panel without a restart, +//! without writing secrets into `gateway.toml`. Values are never exposed via the +//! API — only their presence status is returned (see `source`). + +use std::collections::BTreeMap; +use std::sync::RwLock; + +pub struct SecretStore { + path: String, + values: RwLock>, +} + +impl SecretStore { + /// Load the store from a file (missing file or parse error → empty store). + pub fn load(path: &str) -> Self { + let values = std::fs::read_to_string(path) + .ok() + .and_then(|raw| toml::from_str::>(&raw).ok()) + .unwrap_or_default(); + Self { + path: path.to_string(), + values: RwLock::new(values), + } + } + + /// Secret value by env name: store first, then environment variable. + pub fn resolve(&self, env_name: &str) -> Option { + if let Some(v) = self.values.read().unwrap().get(env_name) { + if !v.is_empty() { + return Some(v.clone()); + } + } + std::env::var(env_name).ok().filter(|v| !v.is_empty()) + } + + /// Value source for display in the UI: `store` | `env` | `missing`. + pub fn source(&self, env_name: &str) -> &'static str { + let in_store = self + .values + .read() + .unwrap() + .get(env_name) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if in_store { + "store" + } else if std::env::var(env_name) + .ok() + .filter(|v| !v.is_empty()) + .is_some() + { + "env" + } else { + "missing" + } + } + + /// Set a secret (persisted to file with mode 0600). + pub fn set(&self, env_name: &str, value: &str) -> anyhow::Result<()> { + self.values + .write() + .unwrap() + .insert(env_name.to_string(), value.to_string()); + self.persist() + } + + /// Remove a secret from the store (an environment variable value, if present, remains). + pub fn clear(&self, env_name: &str) -> anyhow::Result<()> { + self.values.write().unwrap().remove(env_name); + self.persist() + } + + fn persist(&self) -> anyhow::Result<()> { + let data = { + let map = self.values.read().unwrap(); + toml::to_string(&*map)? + }; + let p = std::path::Path::new(&self.path); + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).ok(); + } + let tmp = p.with_extension("toml.tmp"); + std::fs::write(&tmp, data.as_bytes())?; + std::fs::rename(&tmp, p)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..37b8efb --- /dev/null +++ b/src/state.rs @@ -0,0 +1,107 @@ +//! Shared gateway state accessible across all request handlers. +//! +//! The config-derived part (`Live`) lives behind an `ArcSwap`, which allows +//! hot-reloading the model pool, routing table, and router connection without +//! a restart: the admin API validates the new config, builds a new `Live`, +//! atomically writes the TOML, and swaps the pointer. + +use crate::config::Config; +use crate::registry::Registry; +use crate::router_client::RouterClient; +use crate::routing::RoutingTable; +use crate::secrets::SecretStore; +use crate::stats::Stats; +use arc_swap::ArcSwap; +use std::sync::Arc; + +#[derive(Clone)] +pub struct SharedState(pub Arc); + +/// The portion of state that is rebuilt whenever the config changes. +pub struct Live { + pub cfg: Config, + pub registry: Registry, + pub routing: RoutingTable, + pub router: RouterClient, +} + +impl Live { + pub fn build(cfg: Config, secrets: &SecretStore) -> anyhow::Result { + let router = RouterClient::new(&cfg.router)?; + let registry = Registry::from_config(&cfg, secrets)?; + let routing = RoutingTable::from_config(&cfg); + Ok(Self { + cfg, + registry, + routing, + router, + }) + } +} + +pub struct AppState { + pub live: ArcSwap, + pub stats: Arc, + pub secrets: SecretStore, + pub config_path: String, + pub pipeline: crate::pipeline::Pipeline, +} + +impl std::ops::Deref for SharedState { + type Target = AppState; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl SharedState { + pub fn build(cfg: Config, config_path: String) -> anyhow::Result { + let secrets_path = sibling(&config_path, "secrets.toml"); + let secrets = SecretStore::load(&secrets_path); + let stats = Stats::new(&cfg.stats); + let live = Live::build(cfg, &secrets)?; + Ok(SharedState(Arc::new(AppState { + live: ArcSwap::from_pointee(live), + stats, + secrets, + config_path, + pipeline: crate::pipeline::Pipeline::new(), + }))) + } + + /// Current snapshot of the rebuildable state (safe to hold across await points). + pub fn live(&self) -> Arc { + self.0.live.load_full() + } +} + +impl AppState { + /// Replace the config entirely: validate → build → write to disk → swap. + /// If any step fails, the active state and file are left unchanged. + pub fn reload(&self, new_cfg: Config) -> anyhow::Result<()> { + new_cfg.validate()?; + let live = Live::build(new_cfg.clone(), &self.secrets)?; + new_cfg.save(&self.config_path)?; + self.live.store(Arc::new(live)); + Ok(()) + } + + /// Rebuild `Live` from the current config without writing to disk + /// (e.g. after a secret change — only provider keys changed). + pub fn rebuild(&self) -> anyhow::Result<()> { + let cfg = self.live.load_full().cfg.clone(); + let live = Live::build(cfg, &self.secrets)?; + self.live.store(Arc::new(live)); + Ok(()) + } +} + +/// Path to file `name` next to `path` (in the same directory). +fn sibling(path: &str, name: &str) -> String { + std::path::Path::new(path) + .parent() + .map(|d| d.join(name)) + .unwrap_or_else(|| std::path::PathBuf::from(name)) + .to_string_lossy() + .into_owned() +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..d8dc75a --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,395 @@ +//! Request statistics tracking for the dashboard and `GET /metrics`. +//! +//! Everything is held in memory (aggregates + per-minute buckets for the retention +//! window + a ring buffer of recent requests) and optionally appended to a JSONL +//! file that is replayed on startup — so statistics survive a restart with no +//! database dependency. + +use crate::config::StatsCfg; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::io::Write; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// A single record for a completed request (one JSONL line and one "recent" ring entry). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RequestRecord { + pub ts: u64, + #[serde(default)] + pub account: String, + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub directive: String, // auto | pinned + #[serde(default)] + pub task_label: String, + #[serde(default)] + pub tier: String, + #[serde(default)] + pub score: f32, + #[serde(default)] + pub model_id: String, + #[serde(default)] + pub route_source: String, + #[serde(default)] + pub prompt_tokens: u32, + #[serde(default)] + pub completion_tokens: u32, + #[serde(default)] + pub cost_usd: f64, + #[serde(default)] + pub latency_ms: u64, + #[serde(default)] + pub outcome: String, // ok | error + #[serde(default)] + pub failover: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Totals { + pub requests: u64, + pub ok: u64, + pub errors: u64, + pub failovers: u64, + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub cost_usd: f64, + pub latency_ms_sum: u64, +} + +impl Totals { + fn apply(&mut self, r: &RequestRecord) { + self.requests += 1; + if r.outcome == "ok" { + self.ok += 1; + } else { + self.errors += 1; + } + if r.failover { + self.failovers += 1; + } + self.prompt_tokens += r.prompt_tokens as u64; + self.completion_tokens += r.completion_tokens as u64; + self.cost_usd += r.cost_usd; + self.latency_ms_sum += r.latency_ms; + } +} + +/// Per-minute bucket for time-series charts. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Bucket { + pub minute: u64, // unix seconds, rounded down to the minute + pub requests: u64, + pub errors: u64, + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub cost_usd: f64, + pub latency_ms_sum: u64, +} + +#[derive(Default)] +struct Inner { + total: Totals, + by_model: HashMap, + by_account: HashMap, + by_tier: HashMap, + by_label: HashMap, + buckets: VecDeque, + recent: VecDeque, +} + +pub struct Stats { + enabled: bool, + file: Option, + ring_size: usize, + retention_secs: u64, + inner: Mutex, +} + +impl Stats { + pub fn new(cfg: &StatsCfg) -> std::sync::Arc { + let file = if cfg.file.trim().is_empty() { + None + } else { + Some(cfg.file.clone()) + }; + let s = Stats { + enabled: cfg.enabled, + file, + ring_size: cfg.ring_size.max(1), + retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400), + inner: Mutex::new(Inner::default()), + }; + s.replay(); + std::sync::Arc::new(s) + } + + /// Replay the JSONL file into aggregates (within the retention window only). + fn replay(&self) { + let Some(path) = &self.file else { return }; + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let cutoff = now_secs().saturating_sub(self.retention_secs); + let mut inner = self.inner.lock().unwrap(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(rec) = serde_json::from_str::(line) { + // push everything to the "recent" ring (it self-limits by size), + // but apply to aggregates/series only entries within the window. + Self::push_recent(&mut inner, &rec, self.ring_size); + if rec.ts >= cutoff { + Self::apply_aggregates(&mut inner, &rec, self.retention_secs); + } + } + } + } + + fn push_recent(inner: &mut Inner, rec: &RequestRecord, ring_size: usize) { + inner.recent.push_back(rec.clone()); + while inner.recent.len() > ring_size { + inner.recent.pop_front(); + } + } + + fn apply_aggregates(inner: &mut Inner, rec: &RequestRecord, retention_secs: u64) { + inner.total.apply(rec); + inner + .by_model + .entry(rec.model_id.clone()) + .or_default() + .apply(rec); + inner + .by_account + .entry(if rec.account.is_empty() { + "anonymous".to_string() + } else { + rec.account.clone() + }) + .or_default() + .apply(rec); + inner + .by_tier + .entry(rec.tier.clone()) + .or_default() + .apply(rec); + inner + .by_label + .entry(rec.task_label.clone()) + .or_default() + .apply(rec); + + let minute = rec.ts - (rec.ts % 60); + match inner.buckets.back_mut() { + Some(b) if b.minute == minute => fill_bucket(b, rec), + Some(b) if b.minute > minute => { /* arrived older than the tail — ignore for series */ + } + _ => { + let mut b = Bucket { + minute, + ..Default::default() + }; + fill_bucket(&mut b, rec); + inner.buckets.push_back(b); + } + } + // trim old buckets outside the retention window + let cutoff = now_secs().saturating_sub(retention_secs); + while let Some(front) = inner.buckets.front() { + if front.minute < cutoff { + inner.buckets.pop_front(); + } else { + break; + } + } + } + + /// Record a new request event. + pub fn record(&self, rec: RequestRecord) { + if !self.enabled { + return; + } + { + let mut inner = self.inner.lock().unwrap(); + Self::apply_aggregates(&mut inner, &rec, self.retention_secs); + Self::push_recent(&mut inner, &rec, self.ring_size); + } + if let Some(path) = &self.file { + if let Ok(line) = serde_json::to_string(&rec) { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(f, "{line}"); + } + } + } + } + + /// Snapshot for the admin API: aggregates + time series for `range_secs` + breakdown by `groupby`. + pub fn snapshot(&self, range_secs: u64, groupby: &str) -> serde_json::Value { + let inner = self.inner.lock().unwrap(); + let cutoff = now_secs().saturating_sub(range_secs); + let series: Vec<&Bucket> = inner + .buckets + .iter() + .filter(|b| b.minute >= cutoff) + .collect(); + + let breakdown_src = match groupby { + "account" => &inner.by_account, + "tier" => &inner.by_tier, + "label" => &inner.by_label, + _ => &inner.by_model, + }; + let mut breakdown: Vec = breakdown_src + .iter() + .map(|(k, t)| { + serde_json::json!({ + "key": k, + "requests": t.requests, + "ok": t.ok, + "errors": t.errors, + "prompt_tokens": t.prompt_tokens, + "completion_tokens": t.completion_tokens, + "cost_usd": t.cost_usd, + "avg_latency_ms": avg(t.latency_ms_sum, t.requests), + }) + }) + .collect(); + breakdown.sort_by(|a, b| { + b["requests"] + .as_u64() + .unwrap_or(0) + .cmp(&a["requests"].as_u64().unwrap_or(0)) + }); + + serde_json::json!({ + "totals": { + "requests": inner.total.requests, + "ok": inner.total.ok, + "errors": inner.total.errors, + "failovers": inner.total.failovers, + "prompt_tokens": inner.total.prompt_tokens, + "completion_tokens": inner.total.completion_tokens, + "total_tokens": inner.total.prompt_tokens + inner.total.completion_tokens, + "cost_usd": inner.total.cost_usd, + "avg_latency_ms": avg(inner.total.latency_ms_sum, inner.total.requests), + "success_rate": if inner.total.requests > 0 { + inner.total.ok as f64 / inner.total.requests as f64 + } else { 0.0 }, + }, + "groupby": groupby, + "breakdown": breakdown, + "series": series, + }) + } + + /// Recent requests (newest first), with pagination. + pub fn recent(&self, limit: usize, offset: usize) -> Vec { + let inner = self.inner.lock().unwrap(); + inner + .recent + .iter() + .rev() + .skip(offset) + .take(limit) + .cloned() + .collect() + } + + /// Metrics text in Prometheus format. + pub fn prometheus(&self) -> String { + let inner = self.inner.lock().unwrap(); + let mut out = String::new(); + let t = &inner.total; + out.push_str("# HELP gw_requests_total Total gateway requests.\n"); + out.push_str("# TYPE gw_requests_total counter\n"); + out.push_str(&format!("gw_requests_total {}\n", t.requests)); + out.push_str("# HELP gw_failovers_total Total provider failovers.\n"); + out.push_str("# TYPE gw_failovers_total counter\n"); + out.push_str(&format!("gw_failovers_total {}\n", t.failovers)); + out.push_str("# HELP gw_tokens_total Total tokens by direction.\n"); + out.push_str("# TYPE gw_tokens_total counter\n"); + out.push_str(&format!( + "gw_tokens_total{{direction=\"in\"}} {}\n", + t.prompt_tokens + )); + out.push_str(&format!( + "gw_tokens_total{{direction=\"out\"}} {}\n", + t.completion_tokens + )); + out.push_str("# HELP gw_cost_usd_total Total estimated cost in USD.\n"); + out.push_str("# TYPE gw_cost_usd_total counter\n"); + out.push_str(&format!("gw_cost_usd_total {:.6}\n", t.cost_usd)); + out.push_str("# HELP gw_provider_calls_total Calls by model and outcome.\n"); + out.push_str("# TYPE gw_provider_calls_total counter\n"); + for (model, mt) in &inner.by_model { + let model = escape_label(model); + out.push_str(&format!( + "gw_provider_calls_total{{model_id=\"{model}\",outcome=\"ok\"}} {}\n", + mt.ok + )); + out.push_str(&format!( + "gw_provider_calls_total{{model_id=\"{model}\",outcome=\"error\"}} {}\n", + mt.errors + )); + } + out + } +} + +fn fill_bucket(b: &mut Bucket, r: &RequestRecord) { + b.requests += 1; + if r.outcome != "ok" { + b.errors += 1; + } + b.prompt_tokens += r.prompt_tokens as u64; + b.completion_tokens += r.completion_tokens as u64; + b.cost_usd += r.cost_usd; + b.latency_ms_sum += r.latency_ms; +} + +fn avg(sum: u64, n: u64) -> u64 { + sum.checked_div(n).unwrap_or(0) +} + +fn escape_label(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', " ") +} + +/// Parse a duration string of the form `7d` / `24h` / `90m` / `3600s` into seconds. +pub fn parse_duration_secs(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (num, mult) = if let Some(n) = s.strip_suffix('d') { + (n, 86_400) + } else if let Some(n) = s.strip_suffix('h') { + (n, 3_600) + } else if let Some(n) = s.strip_suffix('m') { + (n, 60) + } else if let Some(n) = s.strip_suffix('s') { + (n, 1) + } else { + (s, 1) + }; + num.trim().parse::().ok().map(|v| v * mult) +} diff --git a/web/css/styles.css b/web/css/styles.css new file mode 100644 index 0000000..1845488 --- /dev/null +++ b/web/css/styles.css @@ -0,0 +1,309 @@ +/* =========================================================================== + allaigate Gateway Console — design system (build-free, no framework) + Ported from cortiq-console, extended with light theme, charts, KPIs, modal. + =========================================================================== */ +:root, +:root[data-theme="dark"] { + --bg: #0a0e1a; + --bg-2: #0e1424; + --panel: #131a2c; + --card: #151d31; + --card-2: #121a2c; + --border: #243049; + --border-soft: #1d2740; + --text: #e6ecf7; + --muted: #8b97b0; + --faint: #5e6b85; + --accent: #6366f1; + --accent-2: #5eead4; + --gold: #f59e0b; + --good: #34d399; + --warn: #fbbf24; + --bad: #f87171; + --grid: #ffffff14; + --shadow: 0 8px 30px rgba(0, 0, 0, 0.35); + --hover: #ffffff0a; + --bg-grad: radial-gradient(1200px 700px at 80% -10%, #16203a 0%, var(--bg) 55%); + --radius: 14px; + --radius-sm: 9px; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +:root[data-theme="light"] { + --bg: #f5f7fb; + --bg-2: #ffffff; + --panel: #ffffff; + --card: #ffffff; + --card-2: #f7f9fc; + --border: #d8e0ee; + --border-soft: #e6ebf4; + --text: #0f172a; + --muted: #5a6781; + --faint: #8a96ad; + --accent: #5457e6; + --accent-2: #0ea5a4; + --gold: #d97706; + --good: #10b981; + --warn: #d97706; + --bad: #ef4444; + --grid: #0f172a14; + --shadow: 0 8px 24px rgba(15, 23, 42, 0.08); + --hover: #0f172a08; + --bg-grad: radial-gradient(1200px 700px at 80% -10%, #e7edf9 0%, var(--bg) 55%); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: var(--sans); + background: var(--bg-grad); + color: var(--text); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} +a { color: var(--accent-2); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3 { font-weight: 700; letter-spacing: -0.01em; } +h2 { font-size: 20px; margin: 0 0 4px; } +h3 { font-size: 15px; margin: 0 0 10px; } +code, .mono { font-family: var(--mono); } +.boot { padding: 60px; text-align: center; color: var(--faint); } + +/* ---- layout -------------------------------------------------------------- */ +.app { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; } +.sidebar { + background: linear-gradient(180deg, var(--bg-2), var(--bg)); + border-right: 1px solid var(--border-soft); + padding: 18px 14px; + display: flex; flex-direction: column; gap: 4px; + position: sticky; top: 0; height: 100vh; +} +.main { display: flex; flex-direction: column; min-width: 0; } +.content { padding: 24px 32px 64px; max-width: 1220px; width: 100%; } + +/* ---- brand + nav --------------------------------------------------------- */ +.brand { display: flex; align-items: center; gap: 10px; padding: 6px 8px 16px; } +.brand img { width: 30px; height: 30px; } +.brand b { font-size: 16px; } +.brand .sub { color: var(--muted); font-weight: 500; font-size: 13px; } +.nav-item { + display: flex; align-items: center; gap: 11px; + padding: 10px 12px; border-radius: var(--radius-sm); + color: var(--muted); cursor: pointer; font-weight: 500; + border: 1px solid transparent; user-select: none; +} +.nav-item:hover { background: var(--hover); color: var(--text); } +.nav-item.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 35%, transparent); } +.nav-item .ico { width: 18px; text-align: center; opacity: 0.95; font-size: 15px; } +.nav-spacer { flex: 1; } +.nav-foot { font-size: 11px; color: var(--faint); padding: 8px; } + +/* ---- topbar -------------------------------------------------------------- */ +.topbar { + display: flex; align-items: center; gap: 14px; + padding: 13px 32px; border-bottom: 1px solid var(--border-soft); + position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent); + backdrop-filter: blur(8px); z-index: 5; +} +.topbar .title { font-weight: 600; font-size: 15px; } +.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.icon-btn { + background: var(--panel); border: 1px solid var(--border); color: var(--muted); + width: 34px; height: 32px; border-radius: 8px; cursor: pointer; font-size: 15px; + display: inline-flex; align-items: center; justify-content: center; +} +.icon-btn:hover { color: var(--text); border-color: var(--accent); } +.lang-select { + background: var(--panel); border: 1px solid var(--border); color: var(--text); + border-radius: 8px; padding: 6px 8px; font-size: 12px; font-weight: 600; width: auto; +} + +/* ---- status pill --------------------------------------------------------- */ +.pill { + display: inline-flex; align-items: center; gap: 7px; + padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; + border: 1px solid var(--border); background: var(--panel); +} +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); } +.dot.ok { background: var(--good); box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 18%, transparent); } +.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); } +.dot.warn { background: var(--warn); } + +/* ---- cards --------------------------------------------------------------- */ +.card { + background: linear-gradient(180deg, var(--card), var(--card-2)); + border: 1px solid var(--border-soft); border-radius: var(--radius); + padding: 20px; box-shadow: var(--shadow); margin-bottom: 18px; +} +.card .card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.card .card-head .sub { color: var(--muted); font-weight: 400; font-size: 13px; } +.card .card-head .right { margin-left: auto; display: flex; gap: 8px; align-items: center; } +.grid { display: grid; gap: 18px; } +.grid.cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +@media (max-width: 980px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; } .sidebar { display: none; } } + +.page-head { margin-bottom: 20px; } +.page-head .flex { justify-content: space-between; align-items: flex-start; } +.page-head p { color: var(--muted); margin: 6px 0 0; max-width: 74ch; } + +/* ---- KPI ----------------------------------------------------------------- */ +.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 18px; } +@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 620px) { .kpis { grid-template-columns: repeat(2, 1fr); } } +.kpi { + background: linear-gradient(180deg, var(--card), var(--card-2)); + border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 14px 16px; +} +.kpi .big { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; } +.kpi .lbl { color: var(--muted); font-size: 12px; margin-top: 3px; } + +/* ---- forms --------------------------------------------------------------- */ +label.field { display: block; margin-bottom: 14px; } +label.field > span { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; font-weight: 600; } +input, select, textarea { + width: 100%; background: var(--bg-2); border: 1px solid var(--border); + color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; + font-family: inherit; font-size: 14px; outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +textarea { resize: vertical; min-height: 64px; } +.row { display: flex; gap: 12px; flex-wrap: wrap; } +.row > * { flex: 1; min-width: 150px; } +.inline { display: flex; align-items: center; gap: 8px; } +.check { display: flex; align-items: center; gap: 8px; cursor: pointer; margin-bottom: 12px; } +.check input { width: auto; } +.hint { color: var(--faint); font-size: 12px; margin-top: 4px; } + +/* ---- buttons ------------------------------------------------------------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + background: var(--panel); color: var(--text); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 10px 16px; cursor: pointer; + font-weight: 600; font-size: 14px; font-family: inherit; transition: 0.12s; +} +.btn:hover { border-color: var(--accent); background: var(--hover); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn.primary { background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff), var(--accent)); border-color: var(--accent); color: #fff; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.gold { background: linear-gradient(180deg, color-mix(in srgb, var(--gold) 85%, #fff), var(--gold)); border-color: var(--gold); color: #1a1206; } +.btn.ghost { background: transparent; } +.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 35%, transparent); } +.btn.danger:hover { background: color-mix(in srgb, var(--bad) 10%, transparent); } +.btn.sm { padding: 6px 11px; font-size: 12.5px; } + +/* ---- badges -------------------------------------------------------------- */ +.badge { + display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; + border-radius: 999px; font-size: 11.5px; font-weight: 700; border: 1px solid var(--border); + background: var(--bg-2); color: var(--muted); +} +.badge.tier-low, .badge.cheap, .badge.ok { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, transparent); background: color-mix(in srgb, var(--good) 8%, transparent); } +.badge.tier-medium, .badge.mid, .badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 8%, transparent); } +.badge.tier-high, .badge.expensive, .badge.bad, .badge.error { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); background: color-mix(in srgb, var(--bad) 8%, transparent); } +.badge.router, .badge.store { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: color-mix(in srgb, var(--accent-2) 8%, transparent); } +.badge.env { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); } + +/* ---- tables -------------------------------------------------------------- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--border-soft); white-space: nowrap; } +th { color: var(--faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; } +tr:last-child td { border-bottom: 0; } +td .mono { font-size: 12.5px; color: var(--muted); } +tbody tr:hover td { background: var(--hover); } + +/* ---- chips (routing) ----------------------------------------------------- */ +.tierrow { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-soft); } +.tierrow:last-child { border-bottom: 0; } +.tierrow .tname { width: 110px; font-weight: 700; } +.chips { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; align-items: center; } +.chip { display: inline-flex; align-items: center; gap: 7px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 999px; padding: 5px 6px 5px 11px; font-weight: 600; font-size: 13px; } +.chip .ord { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 10px; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; } +.chip .arrow { cursor: pointer; color: var(--faint); padding: 0 1px; } +.chip .arrow:hover { color: var(--text); } +.chip .x { cursor: pointer; color: var(--faint); border-radius: 50%; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; } +.chip .x:hover { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); } + +/* ---- toggle list (protocols) --------------------------------------------- */ +.togglerow { display: flex; align-items: center; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--border-soft); } +.togglerow:last-child { border-bottom: 0; } +.togglerow .meta { flex: 1; } +.togglerow .meta .name { font-weight: 600; display: flex; gap: 8px; align-items: center; } +.togglerow .meta .desc { color: var(--muted); font-size: 12.5px; margin-top: 3px; } +.switch { position: relative; width: 44px; height: 24px; flex: none; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch .slider { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: 0.15s; cursor: pointer; } +.switch .slider::before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.15s; } +.switch input:checked + .slider { background: var(--accent); } +.switch input:checked + .slider::before { transform: translateX(20px); } +.switch input:disabled + .slider { opacity: 0.5; cursor: not-allowed; } + +/* ---- charts -------------------------------------------------------------- */ +.chart { width: 100%; height: 180px; display: block; } +.chart .axis { stroke: var(--grid); stroke-width: 1; } +.chart .area { fill: color-mix(in srgb, var(--accent) 16%, transparent); } +.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; } +.chart .bar { fill: var(--accent); } +.barlist { display: flex; flex-direction: column; gap: 10px; } +.barlist .b { display: grid; grid-template-columns: 130px 1fr auto; gap: 10px; align-items: center; font-size: 13px; } +.barlist .b .track { background: var(--bg-2); border-radius: 5px; height: 10px; overflow: hidden; } +.barlist .b .track > i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); } +.barlist .b .val { color: var(--muted); font-weight: 600; min-width: 56px; text-align: right; } + +/* ---- playground ---------------------------------------------------------- */ +.pg { display: grid; grid-template-columns: 1fr 340px; gap: 18px; align-items: start; } +@media (max-width: 1024px) { .pg { grid-template-columns: 1fr; } } +.answer { white-space: pre-wrap; word-break: break-word; line-height: 1.55; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; padding: 14px 16px; min-height: 120px; } +.routecard .kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-soft); } +.routecard .kv:last-child { border-bottom: 0; } +.routecard .kv .k { color: var(--muted); } +.routecard .kv .v { font-weight: 600; text-align: right; } +.scorebar { height: 7px; background: var(--bg-2); border-radius: 4px; overflow: hidden; margin-top: 6px; } +.scorebar > i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); } + +/* ---- modal --------------------------------------------------------------- */ +.modal-backdrop { position: fixed; inset: 0; background: rgba(3, 7, 18, 0.6); display: flex; align-items: flex-start; justify-content: center; padding: 48px 16px; z-index: 40; overflow: auto; } +.modal { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 560px; padding: 22px; } +.modal h3 { font-size: 17px; margin-bottom: 16px; } +.modal .modal-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } + +/* ---- login --------------------------------------------------------------- */ +.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } +.login-card { width: 100%; max-width: 400px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 30px; } +.login-card .brand { justify-content: center; padding-top: 0; } +.login-card h2 { text-align: center; } +.login-card p.sub { color: var(--muted); text-align: center; margin: 4px 0 22px; } + +/* ---- misc ---------------------------------------------------------------- */ +.empty { text-align: center; color: var(--faint); padding: 40px 20px; } +.spinner { width: 16px; height: 16px; border: 2px solid color-mix(in srgb, var(--text) 25%, transparent); border-top-color: var(--text); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } +.divider { height: 1px; background: var(--border-soft); margin: 18px 0; } +.muted { color: var(--muted); } +.right-align { text-align: right; } +.flex { display: flex; gap: 10px; align-items: center; } +.flex.wrap { flex-wrap: wrap; } +.grow { flex: 1; } +.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.seg button { background: transparent; border: 0; color: var(--muted); padding: 6px 12px; cursor: pointer; font-size: 12.5px; font-weight: 600; } +.seg button.active { background: var(--accent); color: #fff; } + +/* ---- toasts -------------------------------------------------------------- */ +.toasts { position: fixed; bottom: 22px; right: 22px; display: flex; flex-direction: column; gap: 10px; z-index: 60; } +.toast { + background: var(--panel); border: 1px solid var(--border); border-left: 3px solid var(--accent); + padding: 12px 16px; border-radius: 10px; box-shadow: var(--shadow); min-width: 220px; + animation: slidein 0.18s ease; font-size: 13px; +} +.toast.good { border-left-color: var(--good); } +.toast.bad { border-left-color: var(--bad); } +.toast.warn { border-left-color: var(--warn); } +@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } } + +/* ---- callout ------------------------------------------------------------- */ +.callout { border: 1px solid var(--border); border-left: 3px solid var(--warn); background: color-mix(in srgb, var(--warn) 6%, transparent); border-radius: 10px; padding: 13px 15px; color: var(--muted); margin-bottom: 16px; } +.callout.info { border-left-color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 6%, transparent); } +.callout b { color: var(--text); } diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..80d2659 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0b17335 --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + allaigate · Gateway Console + + + + + + + + +
Loading…
+
+ + + diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..2e7fc9f --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,93 @@ +// Thin client over the gateway's /admin/api surface. Adds the Bearer admin token, +// parses JSON, and throws Error(message) on non-2xx so views can try/catch. +// A 401 sets an "unauthorized" flag the app uses to show the login screen. + +const BASE = "/admin/api"; +const TOKEN_KEY = "allaigate_token"; + +let onUnauthorized = null; +export function setUnauthorizedHandler(fn) { + onUnauthorized = fn; +} + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ""; +} +export function setToken(tok) { + if (tok) localStorage.setItem(TOKEN_KEY, tok); + else localStorage.removeItem(TOKEN_KEY); +} + +async function j(method, path, body) { + const opt = { method, headers: { Authorization: "Bearer " + getToken() } }; + if (body !== undefined) { + opt.headers["content-type"] = "application/json"; + opt.body = JSON.stringify(body); + } + const r = await fetch(BASE + path, opt); + if (r.status === 401) { + if (onUnauthorized) onUnauthorized(); + throw new Error("unauthorized"); + } + const text = await r.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!r.ok) { + const msg = + (data && data.error && (data.error.message || data.error)) || + (data && (data.detail || data.message)) || + r.statusText; + throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); + } + return data; +} + +export const api = { + // meta / health + meta: () => j("GET", "/meta"), + health: () => j("GET", "/health"), + + // config + getConfig: () => j("GET", "/config"), + putConfig: (b) => j("PUT", "/config", b), + + // models + listModels: () => j("GET", "/models"), + createModel: (b) => j("POST", "/models", b), + updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b), + deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)), + probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"), + + // routing + getRouting: () => j("GET", "/routing"), + putRouting: (b) => j("PUT", "/routing", b), + + // protocols + getProtocols: () => j("GET", "/protocols"), + putProtocols: (b) => j("PUT", "/protocols", b), + + // settings + getSettings: () => j("GET", "/settings"), + putSettings: (b) => j("PUT", "/settings", b), + + // keys + listKeys: () => j("GET", "/keys"), + createKey: (b) => j("POST", "/keys", b), + deleteKey: (k) => j("DELETE", "/keys/" + encodeURIComponent(k)), + + // secrets + listSecrets: () => j("GET", "/secrets"), + setSecret: (name, value) => j("PUT", "/secrets", { name, value }), + clearSecret: (name) => j("DELETE", "/secrets?name=" + encodeURIComponent(name)), + + // stats + stats: (range, groupby) => j("GET", `/stats?range=${range}&groupby=${groupby}`), + requests: (limit = 50, offset = 0) => j("GET", `/requests?limit=${limit}&offset=${offset}`), + + // playground + test: (b) => j("POST", "/test", b), +}; diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..7720e32 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,228 @@ +// App bootstrap: login gate, sidebar, topbar, hash router, language + theme. +import { h, mount, toast } from "./ui.js"; +import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js"; +import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js"; +import { renderLogin } from "./views/login.js"; +import { renderDashboard } from "./views/dashboard.js"; +import { renderModels } from "./views/models.js"; +import { renderRouting } from "./views/routing.js"; +import { renderProtocols } from "./views/protocols.js"; +import { renderKeys } from "./views/keys.js"; +import { renderPlayground } from "./views/playground.js"; +import { renderSettings } from "./views/settings.js"; + +const ROUTES = [ + { id: "dashboard", icon: "▤", view: renderDashboard }, + { id: "models", icon: "▦", view: renderModels }, + { id: "routing", icon: "⇄", view: renderRouting }, + { id: "protocols", icon: "⇆", view: renderProtocols }, + { id: "keys", icon: "🔑", view: renderKeys }, + { id: "playground", icon: "✦", view: renderPlayground }, + { id: "settings", icon: "⚙", view: renderSettings }, +]; + +export const appState = { meta: null, health: null, version: "" }; + +function currentRoute() { + const id = location.hash.replace(/^#\/?/, "") || "dashboard"; + return ROUTES.find((r) => r.id === id) || ROUTES[0]; +} + +const FAVICON = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '' + ); + +function sidebar(activeId) { + return h( + "aside", + { class: "sidebar", id: "sidebar" }, + h( + "div", + { class: "brand" }, + h("img", { src: FAVICON, alt: "" }), + h("div", {}, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))) + ), + ...ROUTES.map((r) => + h( + "a", + { class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id }, + h("span", { class: "ico" }, r.icon), + t("nav." + r.id) + ) + ), + h("div", { class: "nav-spacer" }), + h("div", { class: "nav-foot" }, t("nav.foot", { v: appState.version || "—" })) + ); +} + +function statusPills() { + const h_ = appState.health; + const gwOk = !!appState.meta; + const routerOk = h_?.router?.reachable; + return h( + "div", + { class: "flex" }, + h( + "span", + { class: "pill" }, + h("span", { class: "dot " + (gwOk ? "ok" : "bad") }), + t("status.gateway") + ": " + (gwOk ? t("status.online") : t("status.offline")) + ), + h( + "span", + { class: "pill", title: h_?.router?.url || "" }, + h("span", { class: "dot " + (h_ ? (routerOk ? "ok" : "bad") : "warn") }), + t("status.router") + ": " + (h_ ? (routerOk ? t("status.online") : t("status.offline")) : t("status.checking")) + ) + ); +} + +function langSelect() { + const sel = h( + "select", + { + class: "lang-select", + title: "Language", + onChange: (e) => setLang(e.target.value), + }, + ...LANGS.map((l) => h("option", { value: l.code, selected: l.code === getLang() }, l.label)) + ); + return sel; +} + +function themeToggle() { + const cur = document.documentElement.getAttribute("data-theme") || "dark"; + return h( + "button", + { + class: "icon-btn", + title: t("theme.toggle"), + onClick: () => { + const next = (document.documentElement.getAttribute("data-theme") || "dark") === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("allaigate_theme", next); + renderShell(); + }, + }, + cur === "dark" ? "☀" : "☾" + ); +} + +function topbar(activeId) { + return h( + "header", + { class: "topbar" }, + h("div", { class: "title" }, t("nav." + activeId)), + h( + "div", + { class: "right" }, + statusPills(), + themeToggle(), + langSelect(), + h( + "button", + { + class: "icon-btn", + title: t("logout"), + onClick: () => { + setToken(""); + boot(); + }, + }, + "⎋" + ) + ) + ); +} + +async function renderView() { + const route = currentRoute(); + const view = document.getElementById("view"); + if (!view) return; + mount(view, h("div", { class: "empty" }, h("span", { class: "spinner" }), " ", t("common.loading"))); + try { + const node = await route.view(); + mount(view, node); + } catch (e) { + if (String(e.message) === "unauthorized") return; + mount(view, h("div", { class: "callout" }, String(e.message || e))); + } +} + +function renderShell() { + const route = currentRoute(); + mount( + document.getElementById("app"), + h( + "div", + { class: "app" }, + sidebar(route.id), + h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" })) + ) + ); + renderView(); +} + +function showLogin() { + mount(document.getElementById("app"), renderLogin(boot)); +} + +async function refreshHealth() { + try { + appState.health = await api.health(); + } catch { + appState.health = null; + } + // re-render topbar only if shell present + const route = currentRoute(); + const main = document.querySelector(".main"); + if (main) { + const old = main.querySelector(".topbar"); + if (old) old.replaceWith(topbar(route.id)); + } +} + +export async function boot() { + // convenience: /admin?token=… logs in once, then is stripped from the URL + const urlTok = new URLSearchParams(location.search).get("token"); + if (urlTok) { + setToken(urlTok); + history.replaceState({}, "", location.pathname + location.hash); + } + if (!getToken()) { + showLogin(); + return; + } + try { + appState.meta = await api.meta(); + appState.version = appState.meta.version || ""; + } catch (e) { + if (String(e.message) === "unauthorized") { + showLogin(); + return; + } + // gateway reachable but error — still show shell + } + if (!location.hash) location.hash = "#/dashboard"; + renderShell(); + refreshHealth(); +} + +setUnauthorizedHandler(() => { + toast(t("login.failed"), "bad"); + setToken(""); + showLogin(); +}); +onLangChange(() => { + if (document.querySelector(".app")) renderShell(); +}); +window.addEventListener("hashchange", () => { + if (document.querySelector(".app")) renderShell(); +}); +setInterval(() => { + if (document.querySelector(".app")) refreshHealth(); +}, 20000); + +boot(); diff --git a/web/js/charts.js b/web/js/charts.js new file mode 100644 index 0000000..0819e8d --- /dev/null +++ b/web/js/charts.js @@ -0,0 +1,56 @@ +// Dependency-free SVG charts. Build-free, theme-aware via CSS classes. +import { h } from "./ui.js"; + +const SVGNS = "http://www.w3.org/2000/svg"; +function s(tag, attrs = {}, ...children) { + const el = document.createElementNS(SVGNS, tag); + for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v); + for (const c of children.flat()) if (c != null) el.appendChild(c); + return el; +} + +// Area+line chart from time buckets. `field` is a key on each bucket. +export function lineChart(buckets, field) { + const W = 600, H = 180, padL = 8, padR = 8, padT = 12, padB = 14; + const data = (buckets || []).map((b) => Number(b[field] || 0)); + const svg = s("svg", { class: "chart", viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none" }); + // baseline + mid gridlines + for (let i = 0; i <= 2; i++) { + const y = padT + ((H - padT - padB) * i) / 2; + svg.appendChild(s("line", { class: "axis", x1: padL, y1: y, x2: W - padR, y2: y })); + } + if (data.length < 2) return svg; + const max = Math.max(1, ...data); + const innerW = W - padL - padR, innerH = H - padT - padB; + const x = (i) => padL + (innerW * i) / (data.length - 1); + const y = (v) => padT + innerH - (innerH * v) / max; + let line = "", area = `M ${x(0)} ${y(0)}`; + data.forEach((v, i) => { + const cmd = i === 0 ? "M" : "L"; + line += `${cmd} ${x(i).toFixed(1)} ${y(v).toFixed(1)} `; + area += ` L ${x(i).toFixed(1)} ${y(v).toFixed(1)}`; + }); + area += ` L ${x(data.length - 1)} ${y(0)} Z`; + svg.appendChild(s("path", { class: "area", d: area })); + svg.appendChild(s("path", { class: "line", d: line })); + return svg; +} + +// Horizontal bar list. items: [{key, value, sub}] +export function barList(items, fmt) { + const max = Math.max(1, ...items.map((i) => i.value)); + return h( + "div", + { class: "barlist" }, + items.length === 0 ? h("div", { class: "empty" }, "—") : null, + ...items.map((it) => + h( + "div", + { class: "b" }, + h("div", { class: "mono", title: it.key }, it.key || "—"), + h("div", { class: "track" }, h("i", { style: `width:${(it.value / max) * 100}%` })), + h("div", { class: "val" }, fmt ? fmt(it.value) : it.value) + ) + ) + ); +} diff --git a/web/js/i18n.js b/web/js/i18n.js new file mode 100644 index 0000000..f94ca39 --- /dev/null +++ b/web/js/i18n.js @@ -0,0 +1,56 @@ +// i18n engine. English is the source of truth; other languages override per key +// and fall back to English for any missing key. +import en from "./i18n/en.js"; +import ru from "./i18n/ru.js"; +import de from "./i18n/de.js"; +import fr from "./i18n/fr.js"; +import es from "./i18n/es.js"; +import zh from "./i18n/zh.js"; +import tr from "./i18n/tr.js"; + +const DICTS = { en, ru, de, fr, es, zh, tr }; +export const LANGS = [ + { code: "en", label: "English" }, + { code: "ru", label: "Русский" }, + { code: "de", label: "Deutsch" }, + { code: "fr", label: "Français" }, + { code: "es", label: "Español" }, + { code: "zh", label: "中文" }, + { code: "tr", label: "Türkçe" }, +]; + +const listeners = new Set(); + +function detect() { + const saved = localStorage.getItem("allaigate_lang"); + if (saved && DICTS[saved]) return saved; + const url = new URLSearchParams(location.search).get("lang"); + if (url && DICTS[url]) return url; + const nav = (navigator.language || "en").slice(0, 2).toLowerCase(); + return DICTS[nav] ? nav : "en"; +} + +let lang = detect(); + +export function getLang() { + return lang; +} + +export function setLang(l) { + lang = DICTS[l] ? l : "en"; + localStorage.setItem("allaigate_lang", lang); + document.documentElement.setAttribute("lang", lang); + listeners.forEach((fn) => fn(lang)); +} + +export function onLangChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function t(key, vars) { + const d = DICTS[lang] || en; + let s = d[key] ?? en[key] ?? key; + if (vars) for (const k of Object.keys(vars)) s = s.split("{" + k + "}").join(vars[k]); + return s; +} diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js new file mode 100644 index 0000000..ee1409c --- /dev/null +++ b/web/js/i18n/de.js @@ -0,0 +1,256 @@ +// Deutsch — German translation for allaigate admin console. +export default { + // brand / common + "brand.sub": "Gateway", + "common.loading": "Laden…", + "common.save": "Speichern", + "common.saving": "Wird gespeichert…", + "common.cancel": "Abbrechen", + "common.add": "Hinzufügen", + "common.edit": "Bearbeiten", + "common.delete": "Löschen", + "common.close": "Schließen", + "common.test": "Testen", + "common.copy": "Kopieren", + "common.copied": "Kopiert", + "common.confirm": "Bestätigen", + "common.none": "keine", + "common.saved": "Gespeichert", + "common.error": "Fehler", + "common.ok": "OK", + "common.refresh": "Aktualisieren", + "common.actions": "Aktionen", + "common.optional": "optional", + "common.search": "Suchen", + "common.all": "alle", + + // nav + "nav.dashboard": "Dashboard", + "nav.models": "Modelle", + "nav.routing": "Routing", + "nav.protocols": "Protokolle", + "nav.keys": "API-Schlüssel", + "nav.playground": "Playground", + "nav.settings": "Einstellungen", + "nav.foot": "Gateway · v{v}", + + // topbar / status + "status.router": "Router", + "status.gateway": "Gateway", + "status.online": "online", + "status.offline": "offline", + "status.checking": "Prüfe…", + "theme.toggle": "Design", + "logout": "Abmelden", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Admin-Token eingeben, um dieses Gateway zu verwalten.", + "login.token": "Admin-Token", + "login.tokenPlaceholder": "Admin-Token einfügen", + "login.submit": "Konsole öffnen", + "login.hint": "Das Token wird beim Start im Gateway-Log ausgegeben oder über [admin].token_env gesetzt.", + "login.failed": "Ungültiger Admin-Token.", + + // dashboard + "dash.title": "Dashboard", + "dash.subtitle": "Live-Traffic, Kosten und Routing-Analyse für Ihren Modell-Pool.", + "dash.range": "Zeitraum", + "dash.range.1h": "1h", + "dash.range.24h": "24h", + "dash.range.7d": "7d", + "dash.kpi.requests": "Anfragen", + "dash.kpi.tokens": "Tokens", + "dash.kpi.cost": "Kosten", + "dash.kpi.latency": "Ø Latenz", + "dash.kpi.success": "Erfolgsrate", + "dash.kpi.failovers": "Failovers", + "dash.series.title": "Anfragen im Zeitverlauf", + "dash.series.cost": "Kosten im Zeitverlauf", + "dash.series.empty": "Kein Traffic in diesem Zeitraum.", + "dash.breakdown.title": "Aufschlüsselung", + "dash.groupby.model": "nach Modell", + "dash.groupby.tier": "nach Tier", + "dash.groupby.label": "nach Aufgabe", + "dash.groupby.account": "nach Account", + "dash.recent.title": "Letzte Anfragen", + "dash.recent.empty": "Noch keine Anfragen aufgezeichnet. Playground ausprobieren.", + "dash.col.time": "Zeit", + "dash.col.model": "Modell", + "dash.col.tier": "Tier", + "dash.col.task": "Aufgabe", + "dash.col.tokens": "Tokens", + "dash.col.cost": "Kosten", + "dash.col.latency": "Latenz", + "dash.col.outcome": "Ergebnis", + "dash.col.requests": "Anfragen", + "dash.col.errors": "Fehler", + "dash.health.title": "Status", + "dash.health.reachable": "erreichbar", + "dash.health.unreachable": "nicht erreichbar", + + // models + "models.title": "Modelle", + "models.subtitle": "Ihr Modell-Pool — lokale Modelle und Cloud-APIs kombinieren.", + "models.add": "Modell hinzufügen", + "models.empty": "Noch keine Modelle. Erstes Modell hinzufügen.", + "models.col.id": "ID", + "models.col.provider": "Anbieter", + "models.col.model": "Modell", + "models.col.tier": "Kostenstufe", + "models.col.key": "Schlüssel", + "models.col.caps": "Fähigkeiten", + "models.col.price": "Preis (ein/aus)", + "models.probe": "Prüfen", + "models.probe.ok": "Erreichbar ({ms} ms)", + "models.probe.fail": "Fehlgeschlagen: {err}", + "models.form.addTitle": "Modell hinzufügen", + "models.form.editTitle": "Modell bearbeiten", + "models.form.id": "ID", + "models.form.idHint": "Eindeutige ID für das Routing, z. B. local-qwen", + "models.form.provider": "Anbieter", + "models.form.baseUrl": "Basis-URL", + "models.form.model": "Modellname", + "models.form.kind": "Typ", + "models.form.costTier": "Kostenstufe", + "models.form.priceIn": "Preis ein ($/1M)", + "models.form.priceOut": "Preis aus ($/1M)", + "models.form.apiKeyEnv": "API-Schlüssel Umgebungsvariable", + "models.form.apiKeyEnvHint": "Name der Umgebungsvariable / des gespeicherten Geheimnisses für den Anbieterschlüssel.", + "models.form.caps": "Fähigkeiten", + "models.form.secret": "Geheimwert", + "models.form.secretStored": "gespeichert", + "models.form.secretEnv": "aus Umgebung", + "models.form.secretMissing": "nicht gesetzt", + "models.form.secretPlaceholder": "Schlüssel setzen oder ersetzen (nur Schreiben)", + "models.deleteConfirm": "Modell \"{id}\" löschen?", + + // routing + "routing.title": "Routing", + "routing.subtitle": "Komplexitätsstufen einer priorisierten Modellliste zuordnen (Priorität = Fallback).", + "routing.default": "Standardmodell", + "routing.defaultHint": "Wird verwendet, wenn der Router nicht erreichbar ist.", + "routing.tiers": "Stufen", + "routing.tierHint": "Chips ziehen zum Sortieren; erstes bevorzugt, die übrigen sind Fallbacks.", + "routing.addModel": "+ hinzufügen", + "routing.addTier": "Stufe hinzufügen", + "routing.tierName": "Stufenname", + "routing.policy": "Richtlinie", + "routing.policy.mode": "Modus", + "routing.policy.fixed": "Feste Tabelle", + "routing.policy.costAware": "Kostenoptimiert", + "routing.policy.maxCost": "Max. Kosten pro Anfrage ($)", + "routing.policy.minClass": "Mindestklasse je Stufe", + "routing.empty": "Keine Modelle verfügbar — zuerst Modelle hinzufügen.", + + // protocols + "protocols.title": "Protokolle", + "protocols.subtitle": "Nur benötigte eingehende API-Oberflächen aktivieren. Änderungen wirken sofort.", + "protocols.implemented": "implementiert", + "protocols.planned": "geplant", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — kompatibel mit OpenAI SDK, LangChain, crewAI.", + "protocols.openai_completions": "OpenAI Completions (legacy)", + "protocols.openai_completions.d": "POST /v1/completions — Legacy-Integrationen.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — RAG-Pipelines.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — automatische Client-Erkennung.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — Claude-native Agenten.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — MCP-native Orchestratoren.", + "protocols.native_passthrough": "Nativer Passthrough", + "protocols.native_passthrough.d": "POST /route — direkter Zugriff auf die Routing-Entscheidung.", + + // keys + "keys.title": "API-Schlüssel", + "keys.subtitle": "Virtuelle Gateway-Schlüssel für Ihre Agenten. Anbieterschlüssel verlassen das Gateway nicht.", + "keys.add": "Neuer Schlüssel", + "keys.empty": "Keine Schlüssel konfiguriert — Gateway im offenen Modus (keine Authentifizierung).", + "keys.openMode": "Offener Modus: Alle Anfragen werden akzeptiert (kein Schlüssel erforderlich).", + "keys.col.key": "Schlüssel", + "keys.col.account": "Account", + "keys.col.rate": "Rate / Min.", + "keys.col.models": "Erlaubte Modelle", + "keys.form.title": "Neuer API-Schlüssel", + "keys.form.account": "Account", + "keys.form.rate": "Ratenlimit / Min.", + "keys.form.allow": "Erlaubte Modelle", + "keys.form.allowHint": "Leer lassen, um alle Modelle zu erlauben.", + "keys.form.custom": "Eigener Schlüssel (optional)", + "keys.form.customHint": "Leer lassen für automatische Generierung sk-gw-…", + "keys.created": "Schlüssel erstellt — jetzt kopieren, er wird nicht mehr angezeigt:", + "keys.revoke": "Widerrufen", + "keys.revokeConfirm": "Diesen Schlüssel widerrufen?", + "keys.allowAll": "alle", + + // playground + "pg.title": "Playground", + "pg.subtitle": "Prompt durch die Live-Pipeline senden und Routing-Entscheidung prüfen.", + "pg.system": "System-Prompt", + "pg.systemPlaceholder": "optionale Systemnachricht", + "pg.prompt": "Prompt", + "pg.promptPlaceholder": "Etwas fragen…", + "pg.mode": "Modus", + "pg.mode.auto": "Auto (Route)", + "pg.mode.pinned": "Fixiertes Modell", + "pg.profile": "Profil", + "pg.model": "Modell", + "pg.temp": "Temperatur", + "pg.maxTokens": "Max. Tokens", + "pg.send": "Senden", + "pg.sending": "Wird gesendet…", + "pg.answer": "Antwort", + "pg.empty": "Antwort und Routing-Entscheidung erscheinen hier.", + "pg.decision": "Routing-Entscheidung", + "pg.task": "Aufgabe", + "pg.tier": "Tier", + "pg.score": "Komplexität", + "pg.selected": "Gewähltes Modell", + "pg.source": "Route-Quelle", + "pg.cost": "Kosten", + "pg.latency": "Latenz", + "pg.tokens": "Tokens (ein/aus)", + "pg.failover": "Failover", + "pg.error": "Anfrage fehlgeschlagen", + + // settings + "settings.title": "Einstellungen", + "settings.subtitle": "Gateway-Verbindung, Routing-Eingabe, Zuverlässigkeit und Beobachtbarkeit.", + "settings.router": "Router-Verbindung", + "settings.router.url": "Router-URL", + "settings.router.keyEnv": "Router-Schlüssel Umgebungsvariable", + "settings.router.timeout": "Timeout (ms)", + "settings.router.verifyTls": "TLS-Zertifikat prüfen", + "settings.router.taxonomy": "Taxonomie-ID", + "settings.route": "Routing-Eingabe", + "settings.route.strategy": "Textstrategie", + "settings.route.maxChars": "Max. Zeichen", + "settings.route.cacheTtl": "Cache-TTL", + "settings.route.profile": "Standardprofil", + "settings.breaker": "Circuit Breaker", + "settings.breaker.threshold": "Fehlerschwelle", + "settings.breaker.cooldown": "Cooldown", + "settings.log": "Protokollierung", + "settings.log.level": "Log-Ebene", + "settings.log.bodies": "Nachrichtentexte protokollieren (Datenschutz aus)", + "settings.telemetry": "Telemetrie", + "settings.telemetry.metrics": "Prometheus /metrics bereitstellen", + "settings.cortiq": "Antwort-Metadaten", + "settings.cortiq.echo": "cortiq-Routing-Block in Antworten einbetten", + "settings.stats": "Statistiken", + "settings.stats.enabled": "Anfrage-Statistiken aufzeichnen", + "settings.stats.file": "Statistikdatei (JSONL)", + "settings.stats.retention": "Aufbewahrung", + "settings.listen": "Lausch-Adresse", + "settings.export": "Konfiguration exportieren", + "settings.import": "Konfiguration importieren", + "settings.needsRestart": "Gespeichert. Einige Änderungen (Lausch-Adresse) erfordern einen Neustart.", + + // toasts / misc + "toast.saved": "Änderungen gespeichert", + "toast.deleted": "Gelöscht", + "toast.probing": "Prüfe…", + "toast.imported": "Konfiguration importiert", +}; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js new file mode 100644 index 0000000..eb2d6b7 --- /dev/null +++ b/web/js/i18n/en.js @@ -0,0 +1,257 @@ +// English — source of truth. Other languages override per key; missing keys fall +// back to English. Keep keys identical across all language files. +export default { + // brand / common + "brand.sub": "Gateway", + "common.loading": "Loading…", + "common.save": "Save", + "common.saving": "Saving…", + "common.cancel": "Cancel", + "common.add": "Add", + "common.edit": "Edit", + "common.delete": "Delete", + "common.close": "Close", + "common.test": "Test", + "common.copy": "Copy", + "common.copied": "Copied", + "common.confirm": "Confirm", + "common.none": "none", + "common.saved": "Saved", + "common.error": "Error", + "common.ok": "OK", + "common.refresh": "Refresh", + "common.actions": "Actions", + "common.optional": "optional", + "common.search": "Search", + "common.all": "all", + + // nav + "nav.dashboard": "Dashboard", + "nav.models": "Models", + "nav.routing": "Routing", + "nav.protocols": "Protocols", + "nav.keys": "API keys", + "nav.playground": "Playground", + "nav.settings": "Settings", + "nav.foot": "Gateway · v{v}", + + // topbar / status + "status.router": "Router", + "status.gateway": "Gateway", + "status.online": "online", + "status.offline": "offline", + "status.checking": "checking…", + "theme.toggle": "Theme", + "logout": "Log out", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Enter the admin token to manage this gateway.", + "login.token": "Admin token", + "login.tokenPlaceholder": "paste admin token", + "login.submit": "Open console", + "login.hint": "The token is printed in the gateway log on startup, or set via [admin].token_env.", + "login.failed": "Invalid admin token.", + + // dashboard + "dash.title": "Dashboard", + "dash.subtitle": "Live traffic, cost and routing analytics for your model pool.", + "dash.range": "Range", + "dash.range.1h": "1h", + "dash.range.24h": "24h", + "dash.range.7d": "7d", + "dash.kpi.requests": "Requests", + "dash.kpi.tokens": "Tokens", + "dash.kpi.cost": "Cost", + "dash.kpi.latency": "Avg latency", + "dash.kpi.success": "Success rate", + "dash.kpi.failovers": "Failovers", + "dash.series.title": "Requests over time", + "dash.series.cost": "Cost over time", + "dash.series.empty": "No traffic yet in this range.", + "dash.breakdown.title": "Breakdown", + "dash.groupby.model": "by model", + "dash.groupby.tier": "by tier", + "dash.groupby.label": "by task", + "dash.groupby.account": "by account", + "dash.recent.title": "Recent requests", + "dash.recent.empty": "No requests recorded yet. Try the Playground.", + "dash.col.time": "Time", + "dash.col.model": "Model", + "dash.col.tier": "Tier", + "dash.col.task": "Task", + "dash.col.tokens": "Tokens", + "dash.col.cost": "Cost", + "dash.col.latency": "Latency", + "dash.col.outcome": "Outcome", + "dash.col.requests": "Requests", + "dash.col.errors": "Errors", + "dash.health.title": "Health", + "dash.health.reachable": "reachable", + "dash.health.unreachable": "unreachable", + + // models + "models.title": "Models", + "models.subtitle": "Your model pool — mix cheap local models with cloud APIs.", + "models.add": "Add model", + "models.empty": "No models yet. Add your first model to get started.", + "models.col.id": "ID", + "models.col.provider": "Provider", + "models.col.model": "Model", + "models.col.tier": "Cost tier", + "models.col.key": "Key", + "models.col.caps": "Capabilities", + "models.col.price": "Price (in/out)", + "models.probe": "Probe", + "models.probe.ok": "Reachable ({ms} ms)", + "models.probe.fail": "Failed: {err}", + "models.form.addTitle": "Add model", + "models.form.editTitle": "Edit model", + "models.form.id": "ID", + "models.form.idHint": "Unique id used in routing, e.g. local-qwen", + "models.form.provider": "Provider", + "models.form.baseUrl": "Base URL", + "models.form.model": "Model name", + "models.form.kind": "Kind", + "models.form.costTier": "Cost tier", + "models.form.priceIn": "Price in ($/1M)", + "models.form.priceOut": "Price out ($/1M)", + "models.form.apiKeyEnv": "API key env name", + "models.form.apiKeyEnvHint": "Name of the env var / stored secret holding this provider's key.", + "models.form.caps": "Capabilities", + "models.form.secret": "Secret value", + "models.form.secretStored": "stored", + "models.form.secretEnv": "from env", + "models.form.secretMissing": "not set", + "models.form.secretPlaceholder": "set or replace key (write-only)", + "models.deleteConfirm": "Delete model \"{id}\"?", + + // routing + "routing.title": "Routing", + "routing.subtitle": "Map complexity tiers to an ordered list of models (priority = fallback).", + "routing.default": "Default model", + "routing.defaultHint": "Used when the router is unreachable.", + "routing.tiers": "Tiers", + "routing.tierHint": "Drag chips to reorder; first is preferred, the rest are fallbacks.", + "routing.addModel": "+ add", + "routing.addTier": "Add tier", + "routing.tierName": "Tier name", + "routing.policy": "Policy", + "routing.policy.mode": "Mode", + "routing.policy.fixed": "Fixed table", + "routing.policy.costAware": "Cost-aware", + "routing.policy.maxCost": "Max cost per request ($)", + "routing.policy.minClass": "Minimum class per tier", + "routing.empty": "No models available — add models first.", + + // protocols + "protocols.title": "Protocols", + "protocols.subtitle": "Enable only the inbound API surfaces you need. Toggles apply live.", + "protocols.implemented": "implemented", + "protocols.planned": "planned", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — works with OpenAI SDK, LangChain, crewAI.", + "protocols.openai_completions": "OpenAI Completions (legacy)", + "protocols.openai_completions.d": "POST /v1/completions — legacy integrations.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — RAG pipelines.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — client auto-discovery.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — Claude-native agents.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — MCP-native orchestrators.", + "protocols.native_passthrough": "Native passthrough", + "protocols.native_passthrough.d": "POST /route — direct access to the routing decision.", + + // keys + "keys.title": "API keys", + "keys.subtitle": "Virtual gateway keys for your agents. Provider keys never leave the gateway.", + "keys.add": "New key", + "keys.empty": "No keys configured — the gateway is in open mode (no auth).", + "keys.openMode": "Open mode: any request is accepted (no key required).", + "keys.col.key": "Key", + "keys.col.account": "Account", + "keys.col.rate": "Rate / min", + "keys.col.models": "Allowed models", + "keys.form.title": "New API key", + "keys.form.account": "Account", + "keys.form.rate": "Rate limit / min", + "keys.form.allow": "Allowed models", + "keys.form.allowHint": "Leave empty to allow all models.", + "keys.form.custom": "Custom key (optional)", + "keys.form.customHint": "Leave empty to auto-generate sk-gw-…", + "keys.created": "Key created — copy it now, it won't be shown again:", + "keys.revoke": "Revoke", + "keys.revokeConfirm": "Revoke this key?", + "keys.allowAll": "all", + + // playground + "pg.title": "Playground", + "pg.subtitle": "Send a prompt through the live pipeline and inspect the routing decision.", + "pg.system": "System prompt", + "pg.systemPlaceholder": "optional system message", + "pg.prompt": "Prompt", + "pg.promptPlaceholder": "Ask something…", + "pg.mode": "Mode", + "pg.mode.auto": "Auto (route)", + "pg.mode.pinned": "Pinned model", + "pg.profile": "Profile", + "pg.model": "Model", + "pg.temp": "Temperature", + "pg.maxTokens": "Max tokens", + "pg.send": "Send", + "pg.sending": "Sending…", + "pg.answer": "Answer", + "pg.empty": "The answer and routing decision will appear here.", + "pg.decision": "Routing decision", + "pg.task": "Task", + "pg.tier": "Tier", + "pg.score": "Complexity", + "pg.selected": "Selected model", + "pg.source": "Route source", + "pg.cost": "Cost", + "pg.latency": "Latency", + "pg.tokens": "Tokens (in/out)", + "pg.failover": "Failover", + "pg.error": "Request failed", + + // settings + "settings.title": "Settings", + "settings.subtitle": "Gateway connection, routing input, reliability and observability.", + "settings.router": "Router connection", + "settings.router.url": "Router URL", + "settings.router.keyEnv": "Router key env name", + "settings.router.timeout": "Timeout (ms)", + "settings.router.verifyTls": "Verify TLS certificate", + "settings.router.taxonomy": "Taxonomy id", + "settings.route": "Routing input", + "settings.route.strategy": "Text strategy", + "settings.route.maxChars": "Max chars", + "settings.route.cacheTtl": "Cache TTL", + "settings.route.profile": "Default profile", + "settings.breaker": "Circuit breaker", + "settings.breaker.threshold": "Error threshold", + "settings.breaker.cooldown": "Cooldown", + "settings.log": "Logging", + "settings.log.level": "Log level", + "settings.log.bodies": "Log message bodies (privacy off)", + "settings.telemetry": "Telemetry", + "settings.telemetry.metrics": "Expose Prometheus /metrics", + "settings.cortiq": "Response metadata", + "settings.cortiq.echo": "Echo cortiq routing block in responses", + "settings.stats": "Statistics", + "settings.stats.enabled": "Record request statistics", + "settings.stats.file": "Stats file (JSONL)", + "settings.stats.retention": "Retention", + "settings.listen": "Listen address", + "settings.export": "Export config", + "settings.import": "Import config", + "settings.needsRestart": "Saved. Some changes (listen address) require a restart.", + + // toasts / misc + "toast.saved": "Changes saved", + "toast.deleted": "Deleted", + "toast.probing": "Probing…", + "toast.imported": "Config imported", +}; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js new file mode 100644 index 0000000..4b1a763 --- /dev/null +++ b/web/js/i18n/es.js @@ -0,0 +1,256 @@ +// Spanish — traducciones para la consola de administración de allaigate Gateway. +export default { + // brand / common + "brand.sub": "Gateway", + "common.loading": "Cargando…", + "common.save": "Guardar", + "common.saving": "Guardando…", + "common.cancel": "Cancelar", + "common.add": "Agregar", + "common.edit": "Editar", + "common.delete": "Eliminar", + "common.close": "Cerrar", + "common.test": "Probar", + "common.copy": "Copiar", + "common.copied": "Copiado", + "common.confirm": "Confirmar", + "common.none": "ninguno", + "common.saved": "Guardado", + "common.error": "Error", + "common.ok": "OK", + "common.refresh": "Actualizar", + "common.actions": "Acciones", + "common.optional": "opcional", + "common.search": "Buscar", + "common.all": "todos", + + // nav + "nav.dashboard": "Panel", + "nav.models": "Modelos", + "nav.routing": "Enrutamiento", + "nav.protocols": "Protocolos", + "nav.keys": "Claves API", + "nav.playground": "Playground", + "nav.settings": "Configuración", + "nav.foot": "Gateway · v{v}", + + // topbar / status + "status.router": "Router", + "status.gateway": "Gateway", + "status.online": "en línea", + "status.offline": "sin conexión", + "status.checking": "verificando…", + "theme.toggle": "Tema", + "logout": "Cerrar sesión", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Ingresa el token de administrador para gestionar este gateway.", + "login.token": "Token de administrador", + "login.tokenPlaceholder": "pega el token de administrador", + "login.submit": "Abrir consola", + "login.hint": "El token se imprime en el log del gateway al iniciar, o se configura mediante [admin].token_env.", + "login.failed": "Token de administrador inválido.", + + // dashboard + "dash.title": "Panel", + "dash.subtitle": "Tráfico en vivo, costos y análisis de enrutamiento de tu pool de modelos.", + "dash.range": "Rango", + "dash.range.1h": "1h", + "dash.range.24h": "24h", + "dash.range.7d": "7d", + "dash.kpi.requests": "Solicitudes", + "dash.kpi.tokens": "Tokens", + "dash.kpi.cost": "Costo", + "dash.kpi.latency": "Latencia promedio", + "dash.kpi.success": "Tasa de éxito", + "dash.kpi.failovers": "Failovers", + "dash.series.title": "Solicitudes en el tiempo", + "dash.series.cost": "Costo en el tiempo", + "dash.series.empty": "Sin tráfico en este rango.", + "dash.breakdown.title": "Desglose", + "dash.groupby.model": "por modelo", + "dash.groupby.tier": "por nivel", + "dash.groupby.label": "por tarea", + "dash.groupby.account": "por cuenta", + "dash.recent.title": "Solicitudes recientes", + "dash.recent.empty": "Sin solicitudes registradas. Prueba el Playground.", + "dash.col.time": "Hora", + "dash.col.model": "Modelo", + "dash.col.tier": "Nivel", + "dash.col.task": "Tarea", + "dash.col.tokens": "Tokens", + "dash.col.cost": "Costo", + "dash.col.latency": "Latencia", + "dash.col.outcome": "Resultado", + "dash.col.requests": "Solicitudes", + "dash.col.errors": "Errores", + "dash.health.title": "Estado", + "dash.health.reachable": "accesible", + "dash.health.unreachable": "inaccesible", + + // models + "models.title": "Modelos", + "models.subtitle": "Tu pool de modelos — combina modelos locales económicos con APIs en la nube.", + "models.add": "Agregar modelo", + "models.empty": "Sin modelos aún. Agrega tu primer modelo para comenzar.", + "models.col.id": "ID", + "models.col.provider": "Proveedor", + "models.col.model": "Modelo", + "models.col.tier": "Nivel de costo", + "models.col.key": "Clave", + "models.col.caps": "Capacidades", + "models.col.price": "Precio (entrada/salida)", + "models.probe": "Sondear", + "models.probe.ok": "Accesible ({ms} ms)", + "models.probe.fail": "Error: {err}", + "models.form.addTitle": "Agregar modelo", + "models.form.editTitle": "Editar modelo", + "models.form.id": "ID", + "models.form.idHint": "ID único usado en enrutamiento, ej. local-qwen", + "models.form.provider": "Proveedor", + "models.form.baseUrl": "URL base", + "models.form.model": "Nombre del modelo", + "models.form.kind": "Tipo", + "models.form.costTier": "Nivel de costo", + "models.form.priceIn": "Precio entrada ($/1M)", + "models.form.priceOut": "Precio salida ($/1M)", + "models.form.apiKeyEnv": "Nombre de variable env para clave API", + "models.form.apiKeyEnvHint": "Nombre de la variable de entorno o secreto que contiene la clave del proveedor.", + "models.form.caps": "Capacidades", + "models.form.secret": "Valor secreto", + "models.form.secretStored": "almacenado", + "models.form.secretEnv": "desde env", + "models.form.secretMissing": "no configurado", + "models.form.secretPlaceholder": "establecer o reemplazar clave (solo escritura)", + "models.deleteConfirm": "¿Eliminar modelo \"{id}\"?", + + // routing + "routing.title": "Enrutamiento", + "routing.subtitle": "Asigna niveles de complejidad a una lista ordenada de modelos (prioridad = fallback).", + "routing.default": "Modelo predeterminado", + "routing.defaultHint": "Usado cuando el router no está disponible.", + "routing.tiers": "Niveles", + "routing.tierHint": "Arrastra los chips para reordenar; el primero es preferido, los demás son fallbacks.", + "routing.addModel": "+ agregar", + "routing.addTier": "Agregar nivel", + "routing.tierName": "Nombre del nivel", + "routing.policy": "Política", + "routing.policy.mode": "Modo", + "routing.policy.fixed": "Tabla fija", + "routing.policy.costAware": "Consciente del costo", + "routing.policy.maxCost": "Costo máx. por solicitud ($)", + "routing.policy.minClass": "Clase mínima por nivel", + "routing.empty": "Sin modelos disponibles — agrega modelos primero.", + + // protocols + "protocols.title": "Protocolos", + "protocols.subtitle": "Activa solo las APIs de entrada que necesitas. Los cambios se aplican en vivo.", + "protocols.implemented": "implementado", + "protocols.planned": "planificado", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — compatible con OpenAI SDK, LangChain, crewAI.", + "protocols.openai_completions": "OpenAI Completions (legacy)", + "protocols.openai_completions.d": "POST /v1/completions — integraciones heredadas.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — pipelines RAG.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — autodescubrimiento de clientes.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — agentes nativos Claude.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — orquestadores nativos MCP.", + "protocols.native_passthrough": "Passthrough nativo", + "protocols.native_passthrough.d": "POST /route — acceso directo a la decisión de enrutamiento.", + + // keys + "keys.title": "Claves API", + "keys.subtitle": "Claves de gateway virtuales para tus agentes. Las claves del proveedor nunca salen del gateway.", + "keys.add": "Nueva clave", + "keys.empty": "Sin claves configuradas — el gateway está en modo abierto (sin autenticación).", + "keys.openMode": "Modo abierto: cualquier solicitud es aceptada (no se requiere clave).", + "keys.col.key": "Clave", + "keys.col.account": "Cuenta", + "keys.col.rate": "Límite / min", + "keys.col.models": "Modelos permitidos", + "keys.form.title": "Nueva clave API", + "keys.form.account": "Cuenta", + "keys.form.rate": "Límite de tasa / min", + "keys.form.allow": "Modelos permitidos", + "keys.form.allowHint": "Deja vacío para permitir todos los modelos.", + "keys.form.custom": "Clave personalizada (opcional)", + "keys.form.customHint": "Deja vacío para generar automáticamente sk-gw-…", + "keys.created": "Clave creada — cópiala ahora, no se mostrará de nuevo:", + "keys.revoke": "Revocar", + "keys.revokeConfirm": "¿Revocar esta clave?", + "keys.allowAll": "todos", + + // playground + "pg.title": "Playground", + "pg.subtitle": "Envía un prompt por el pipeline en vivo e inspecciona la decisión de enrutamiento.", + "pg.system": "Prompt del sistema", + "pg.systemPlaceholder": "mensaje de sistema opcional", + "pg.prompt": "Prompt", + "pg.promptPlaceholder": "Escribe algo…", + "pg.mode": "Modo", + "pg.mode.auto": "Auto (enrutar)", + "pg.mode.pinned": "Modelo fijo", + "pg.profile": "Perfil", + "pg.model": "Modelo", + "pg.temp": "Temperatura", + "pg.maxTokens": "Tokens máx.", + "pg.send": "Enviar", + "pg.sending": "Enviando…", + "pg.answer": "Respuesta", + "pg.empty": "La respuesta y la decisión de enrutamiento aparecerán aquí.", + "pg.decision": "Decisión de enrutamiento", + "pg.task": "Tarea", + "pg.tier": "Nivel", + "pg.score": "Complejidad", + "pg.selected": "Modelo seleccionado", + "pg.source": "Fuente de ruta", + "pg.cost": "Costo", + "pg.latency": "Latencia", + "pg.tokens": "Tokens (entrada/salida)", + "pg.failover": "Failover", + "pg.error": "Solicitud fallida", + + // settings + "settings.title": "Configuración", + "settings.subtitle": "Conexión del gateway, entrada de enrutamiento, fiabilidad y observabilidad.", + "settings.router": "Conexión del router", + "settings.router.url": "URL del router", + "settings.router.keyEnv": "Nombre de variable env para clave del router", + "settings.router.timeout": "Tiempo de espera (ms)", + "settings.router.verifyTls": "Verificar certificado TLS", + "settings.router.taxonomy": "ID de taxonomía", + "settings.route": "Entrada de enrutamiento", + "settings.route.strategy": "Estrategia de texto", + "settings.route.maxChars": "Máx. caracteres", + "settings.route.cacheTtl": "TTL de caché", + "settings.route.profile": "Perfil predeterminado", + "settings.breaker": "Circuit breaker", + "settings.breaker.threshold": "Umbral de errores", + "settings.breaker.cooldown": "Cooldown", + "settings.log": "Registro", + "settings.log.level": "Nivel de log", + "settings.log.bodies": "Registrar cuerpos de mensajes (privacidad desactivada)", + "settings.telemetry": "Telemetría", + "settings.telemetry.metrics": "Exponer Prometheus /metrics", + "settings.cortiq": "Metadatos de respuesta", + "settings.cortiq.echo": "Incluir bloque de enrutamiento cortiq en respuestas", + "settings.stats": "Estadísticas", + "settings.stats.enabled": "Registrar estadísticas de solicitudes", + "settings.stats.file": "Archivo de estadísticas (JSONL)", + "settings.stats.retention": "Retención", + "settings.listen": "Dirección de escucha", + "settings.export": "Exportar configuración", + "settings.import": "Importar configuración", + "settings.needsRestart": "Guardado. Algunos cambios (dirección de escucha) requieren reinicio.", + + // toasts / misc + "toast.saved": "Cambios guardados", + "toast.deleted": "Eliminado", + "toast.probing": "Sondeando…", + "toast.imported": "Configuración importada", +}; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js new file mode 100644 index 0000000..e03b704 --- /dev/null +++ b/web/js/i18n/fr.js @@ -0,0 +1,256 @@ +// French (fr) — same keys as en.js; missing keys fall back to English. +export default { + // brand / common + "brand.sub": "Gateway", + "common.loading": "Chargement…", + "common.save": "Enregistrer", + "common.saving": "Enregistrement…", + "common.cancel": "Annuler", + "common.add": "Ajouter", + "common.edit": "Modifier", + "common.delete": "Supprimer", + "common.close": "Fermer", + "common.test": "Tester", + "common.copy": "Copier", + "common.copied": "Copié", + "common.confirm": "Confirmer", + "common.none": "aucun", + "common.saved": "Enregistré", + "common.error": "Erreur", + "common.ok": "OK", + "common.refresh": "Actualiser", + "common.actions": "Actions", + "common.optional": "optionnel", + "common.search": "Rechercher", + "common.all": "tous", + + // nav + "nav.dashboard": "Tableau de bord", + "nav.models": "Modèles", + "nav.routing": "Routage", + "nav.protocols": "Protocoles", + "nav.keys": "Clés API", + "nav.playground": "Playground", + "nav.settings": "Paramètres", + "nav.foot": "Gateway · v{v}", + + // topbar / status + "status.router": "Routeur", + "status.gateway": "Gateway", + "status.online": "en ligne", + "status.offline": "hors ligne", + "status.checking": "vérification…", + "theme.toggle": "Thème", + "logout": "Déconnexion", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Saisissez le jeton administrateur pour gérer cette passerelle.", + "login.token": "Jeton admin", + "login.tokenPlaceholder": "coller le jeton admin", + "login.submit": "Ouvrir la console", + "login.hint": "Le jeton est affiché dans le log de la passerelle au démarrage, ou défini via [admin].token_env.", + "login.failed": "Jeton admin invalide.", + + // dashboard + "dash.title": "Tableau de bord", + "dash.subtitle": "Trafic en direct, coûts et analyses de routage pour votre pool de modèles.", + "dash.range": "Période", + "dash.range.1h": "1h", + "dash.range.24h": "24h", + "dash.range.7d": "7j", + "dash.kpi.requests": "Requêtes", + "dash.kpi.tokens": "Tokens", + "dash.kpi.cost": "Coût", + "dash.kpi.latency": "Latence moy.", + "dash.kpi.success": "Taux de succès", + "dash.kpi.failovers": "Basculements", + "dash.series.title": "Requêtes dans le temps", + "dash.series.cost": "Coûts dans le temps", + "dash.series.empty": "Aucun trafic sur cette période.", + "dash.breakdown.title": "Répartition", + "dash.groupby.model": "par modèle", + "dash.groupby.tier": "par niveau", + "dash.groupby.label": "par tâche", + "dash.groupby.account": "par compte", + "dash.recent.title": "Requêtes récentes", + "dash.recent.empty": "Aucune requête enregistrée. Essayez le Playground.", + "dash.col.time": "Heure", + "dash.col.model": "Modèle", + "dash.col.tier": "Niveau", + "dash.col.task": "Tâche", + "dash.col.tokens": "Tokens", + "dash.col.cost": "Coût", + "dash.col.latency": "Latence", + "dash.col.outcome": "Résultat", + "dash.col.requests": "Requêtes", + "dash.col.errors": "Erreurs", + "dash.health.title": "Santé", + "dash.health.reachable": "accessible", + "dash.health.unreachable": "inaccessible", + + // models + "models.title": "Modèles", + "models.subtitle": "Votre pool de modèles — combinez modèles locaux économiques et API cloud.", + "models.add": "Ajouter un modèle", + "models.empty": "Aucun modèle. Ajoutez votre premier modèle pour commencer.", + "models.col.id": "ID", + "models.col.provider": "Fournisseur", + "models.col.model": "Modèle", + "models.col.tier": "Niveau de coût", + "models.col.key": "Clé", + "models.col.caps": "Capacités", + "models.col.price": "Prix (entrée/sortie)", + "models.probe": "Sonder", + "models.probe.ok": "Accessible ({ms} ms)", + "models.probe.fail": "Échec : {err}", + "models.form.addTitle": "Ajouter un modèle", + "models.form.editTitle": "Modifier le modèle", + "models.form.id": "ID", + "models.form.idHint": "Identifiant unique utilisé dans le routage, ex. local-qwen", + "models.form.provider": "Fournisseur", + "models.form.baseUrl": "URL de base", + "models.form.model": "Nom du modèle", + "models.form.kind": "Type", + "models.form.costTier": "Niveau de coût", + "models.form.priceIn": "Prix entrée ($/1M)", + "models.form.priceOut": "Prix sortie ($/1M)", + "models.form.apiKeyEnv": "Nom de la var. d'env. pour la clé", + "models.form.apiKeyEnvHint": "Nom de la variable d'env. ou du secret contenant la clé de ce fournisseur.", + "models.form.caps": "Capacités", + "models.form.secret": "Valeur secrète", + "models.form.secretStored": "stocké", + "models.form.secretEnv": "depuis l'env.", + "models.form.secretMissing": "non défini", + "models.form.secretPlaceholder": "définir ou remplacer la clé (écriture seule)", + "models.deleteConfirm": "Supprimer le modèle \"{id}\" ?", + + // routing + "routing.title": "Routage", + "routing.subtitle": "Associez les niveaux de complexité à une liste ordonnée de modèles (priorité = repli).", + "routing.default": "Modèle par défaut", + "routing.defaultHint": "Utilisé lorsque le routeur est inaccessible.", + "routing.tiers": "Niveaux", + "routing.tierHint": "Glissez les puces pour réordonner ; le premier est préféré, les autres sont des replis.", + "routing.addModel": "+ ajouter", + "routing.addTier": "Ajouter un niveau", + "routing.tierName": "Nom du niveau", + "routing.policy": "Politique", + "routing.policy.mode": "Mode", + "routing.policy.fixed": "Table fixe", + "routing.policy.costAware": "Sensible au coût", + "routing.policy.maxCost": "Coût max par requête ($)", + "routing.policy.minClass": "Classe minimale par niveau", + "routing.empty": "Aucun modèle disponible — ajoutez des modèles d'abord.", + + // protocols + "protocols.title": "Protocoles", + "protocols.subtitle": "Activez uniquement les surfaces API entrantes nécessaires. Les bascules s'appliquent en direct.", + "protocols.implemented": "implémenté", + "protocols.planned": "planifié", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — compatible avec OpenAI SDK, LangChain, crewAI.", + "protocols.openai_completions": "OpenAI Completions (héritage)", + "protocols.openai_completions.d": "POST /v1/completions — intégrations héritées.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — pipelines RAG.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — découverte automatique des clients.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — agents natifs Claude.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — orchestrateurs natifs MCP.", + "protocols.native_passthrough": "Passage natif", + "protocols.native_passthrough.d": "POST /route — accès direct à la décision de routage.", + + // keys + "keys.title": "Clés API", + "keys.subtitle": "Clés de passerelle virtuelles pour vos agents. Les clés fournisseurs ne quittent jamais la passerelle.", + "keys.add": "Nouvelle clé", + "keys.empty": "Aucune clé configurée — la passerelle est en mode ouvert (sans auth).", + "keys.openMode": "Mode ouvert : toute requête est acceptée (aucune clé requise).", + "keys.col.key": "Clé", + "keys.col.account": "Compte", + "keys.col.rate": "Débit / min", + "keys.col.models": "Modèles autorisés", + "keys.form.title": "Nouvelle clé API", + "keys.form.account": "Compte", + "keys.form.rate": "Limite de débit / min", + "keys.form.allow": "Modèles autorisés", + "keys.form.allowHint": "Laisser vide pour autoriser tous les modèles.", + "keys.form.custom": "Clé personnalisée (optionnel)", + "keys.form.customHint": "Laisser vide pour générer automatiquement sk-gw-…", + "keys.created": "Clé créée — copiez-la maintenant, elle ne sera plus affichée :", + "keys.revoke": "Révoquer", + "keys.revokeConfirm": "Révoquer cette clé ?", + "keys.allowAll": "tous", + + // playground + "pg.title": "Playground", + "pg.subtitle": "Envoyez une invite via le pipeline en direct et inspectez la décision de routage.", + "pg.system": "Invite système", + "pg.systemPlaceholder": "message système optionnel", + "pg.prompt": "Invite", + "pg.promptPlaceholder": "Posez une question…", + "pg.mode": "Mode", + "pg.mode.auto": "Auto (routage)", + "pg.mode.pinned": "Modèle épinglé", + "pg.profile": "Profil", + "pg.model": "Modèle", + "pg.temp": "Température", + "pg.maxTokens": "Tokens max", + "pg.send": "Envoyer", + "pg.sending": "Envoi…", + "pg.answer": "Réponse", + "pg.empty": "La réponse et la décision de routage s'afficheront ici.", + "pg.decision": "Décision de routage", + "pg.task": "Tâche", + "pg.tier": "Niveau", + "pg.score": "Complexité", + "pg.selected": "Modèle sélectionné", + "pg.source": "Source du routage", + "pg.cost": "Coût", + "pg.latency": "Latence", + "pg.tokens": "Tokens (entrée/sortie)", + "pg.failover": "Basculement", + "pg.error": "Requête échouée", + + // settings + "settings.title": "Paramètres", + "settings.subtitle": "Connexion passerelle, entrée de routage, fiabilité et observabilité.", + "settings.router": "Connexion routeur", + "settings.router.url": "URL du routeur", + "settings.router.keyEnv": "Nom de l'env. pour la clé du routeur", + "settings.router.timeout": "Délai d'expiration (ms)", + "settings.router.verifyTls": "Vérifier le certificat TLS", + "settings.router.taxonomy": "ID de taxonomie", + "settings.route": "Entrée de routage", + "settings.route.strategy": "Stratégie de texte", + "settings.route.maxChars": "Caractères max", + "settings.route.cacheTtl": "TTL du cache", + "settings.route.profile": "Profil par défaut", + "settings.breaker": "Disjoncteur", + "settings.breaker.threshold": "Seuil d'erreur", + "settings.breaker.cooldown": "Temporisation", + "settings.log": "Journalisation", + "settings.log.level": "Niveau de log", + "settings.log.bodies": "Journaliser les corps de messages (confidentialité désactivée)", + "settings.telemetry": "Télémétrie", + "settings.telemetry.metrics": "Exposer Prometheus /metrics", + "settings.cortiq": "Métadonnées de réponse", + "settings.cortiq.echo": "Inclure le bloc de routage cortiq dans les réponses", + "settings.stats": "Statistiques", + "settings.stats.enabled": "Enregistrer les statistiques de requêtes", + "settings.stats.file": "Fichier de stats (JSONL)", + "settings.stats.retention": "Rétention", + "settings.listen": "Adresse d'écoute", + "settings.export": "Exporter la config", + "settings.import": "Importer la config", + "settings.needsRestart": "Enregistré. Certains changements (adresse d'écoute) nécessitent un redémarrage.", + + // toasts / misc + "toast.saved": "Modifications enregistrées", + "toast.deleted": "Supprimé", + "toast.probing": "Sondage…", + "toast.imported": "Config importée", +}; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js new file mode 100644 index 0000000..7467bcb --- /dev/null +++ b/web/js/i18n/ru.js @@ -0,0 +1,256 @@ +// Russian — ru. Keys must match en.js exactly; missing keys fall back to English. +export default { + // brand / common + "brand.sub": "Шлюз", + "common.loading": "Загрузка…", + "common.save": "Сохранить", + "common.saving": "Сохранение…", + "common.cancel": "Отмена", + "common.add": "Добавить", + "common.edit": "Изменить", + "common.delete": "Удалить", + "common.close": "Закрыть", + "common.test": "Тест", + "common.copy": "Копировать", + "common.copied": "Скопировано", + "common.confirm": "Подтвердить", + "common.none": "нет", + "common.saved": "Сохранено", + "common.error": "Ошибка", + "common.ok": "OK", + "common.refresh": "Обновить", + "common.actions": "Действия", + "common.optional": "необязательно", + "common.search": "Поиск", + "common.all": "все", + + // nav + "nav.dashboard": "Дашборд", + "nav.models": "Модели", + "nav.routing": "Роутинг", + "nav.protocols": "Протоколы", + "nav.keys": "API-ключи", + "nav.playground": "Playground", + "nav.settings": "Настройки", + "nav.foot": "Шлюз · v{v}", + + // topbar / status + "status.router": "Роутер", + "status.gateway": "Шлюз", + "status.online": "в сети", + "status.offline": "недоступен", + "status.checking": "проверка…", + "theme.toggle": "Тема", + "logout": "Выйти", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Введите токен администратора для управления этим шлюзом.", + "login.token": "Токен администратора", + "login.tokenPlaceholder": "вставьте токен", + "login.submit": "Войти в консоль", + "login.hint": "Токен печатается в логе шлюза при запуске или задаётся через [admin].token_env.", + "login.failed": "Неверный токен администратора.", + + // dashboard + "dash.title": "Дашборд", + "dash.subtitle": "Трафик в реальном времени, расходы и аналитика роутинга по пулу моделей.", + "dash.range": "Период", + "dash.range.1h": "1ч", + "dash.range.24h": "24ч", + "dash.range.7d": "7д", + "dash.kpi.requests": "Запросы", + "dash.kpi.tokens": "Токены", + "dash.kpi.cost": "Стоимость", + "dash.kpi.latency": "Ср. задержка", + "dash.kpi.success": "Успешность", + "dash.kpi.failovers": "Переключения", + "dash.series.title": "Запросы по времени", + "dash.series.cost": "Расходы по времени", + "dash.series.empty": "Трафика за этот период нет.", + "dash.breakdown.title": "Разбивка", + "dash.groupby.model": "по модели", + "dash.groupby.tier": "по уровню", + "dash.groupby.label": "по задаче", + "dash.groupby.account": "по аккаунту", + "dash.recent.title": "Последние запросы", + "dash.recent.empty": "Запросов ещё нет. Попробуйте Playground.", + "dash.col.time": "Время", + "dash.col.model": "Модель", + "dash.col.tier": "Уровень", + "dash.col.task": "Задача", + "dash.col.tokens": "Токены", + "dash.col.cost": "Стоимость", + "dash.col.latency": "Задержка", + "dash.col.outcome": "Результат", + "dash.col.requests": "Запросы", + "dash.col.errors": "Ошибки", + "dash.health.title": "Состояние", + "dash.health.reachable": "доступен", + "dash.health.unreachable": "недоступен", + + // models + "models.title": "Модели", + "models.subtitle": "Пул моделей — сочетайте дешёвые локальные модели с облачными API.", + "models.add": "Добавить модель", + "models.empty": "Моделей нет. Добавьте первую модель.", + "models.col.id": "ID", + "models.col.provider": "Провайдер", + "models.col.model": "Модель", + "models.col.tier": "Уровень цены", + "models.col.key": "Ключ", + "models.col.caps": "Возможности", + "models.col.price": "Цена (вх/исх)", + "models.probe": "Проверить", + "models.probe.ok": "Доступна ({ms} мс)", + "models.probe.fail": "Ошибка: {err}", + "models.form.addTitle": "Добавить модель", + "models.form.editTitle": "Редактировать модель", + "models.form.id": "ID", + "models.form.idHint": "Уникальный ID для роутинга, например local-qwen", + "models.form.provider": "Провайдер", + "models.form.baseUrl": "Base URL", + "models.form.model": "Имя модели", + "models.form.kind": "Тип", + "models.form.costTier": "Уровень цены", + "models.form.priceIn": "Цена вх. ($/1M)", + "models.form.priceOut": "Цена исх. ($/1M)", + "models.form.apiKeyEnv": "Имя env-переменной с ключом", + "models.form.apiKeyEnvHint": "Имя переменной окружения или секрета с ключом провайдера.", + "models.form.caps": "Возможности", + "models.form.secret": "Значение секрета", + "models.form.secretStored": "сохранён", + "models.form.secretEnv": "из env", + "models.form.secretMissing": "не задан", + "models.form.secretPlaceholder": "задать или заменить ключ (только запись)", + "models.deleteConfirm": "Удалить модель \"{id}\"?", + + // routing + "routing.title": "Роутинг", + "routing.subtitle": "Сопоставьте уровни сложности с упорядоченным списком моделей (приоритет = fallback).", + "routing.default": "Модель по умолчанию", + "routing.defaultHint": "Используется, если роутер недоступен.", + "routing.tiers": "Уровни", + "routing.tierHint": "Перетащите чипы для изменения порядка; первый — предпочтительный, остальные — резервные.", + "routing.addModel": "+ добавить", + "routing.addTier": "Добавить уровень", + "routing.tierName": "Название уровня", + "routing.policy": "Политика", + "routing.policy.mode": "Режим", + "routing.policy.fixed": "Фиксированная таблица", + "routing.policy.costAware": "С учётом стоимости", + "routing.policy.maxCost": "Макс. стоимость запроса ($)", + "routing.policy.minClass": "Мин. класс на уровень", + "routing.empty": "Моделей нет — сначала добавьте модели.", + + // protocols + "protocols.title": "Протоколы", + "protocols.subtitle": "Включайте только нужные входящие API. Переключатели применяются мгновенно.", + "protocols.implemented": "реализован", + "protocols.planned": "планируется", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — работает с OpenAI SDK, LangChain, crewAI.", + "protocols.openai_completions": "OpenAI Completions (устаревший)", + "protocols.openai_completions.d": "POST /v1/completions — устаревшие интеграции.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — RAG-пайплайны.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — автообнаружение клиентами.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — Claude-нативные агенты.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — MCP-нативные оркестраторы.", + "protocols.native_passthrough": "Нативный проброс", + "protocols.native_passthrough.d": "POST /route — прямой доступ к решению роутера.", + + // keys + "keys.title": "API-ключи", + "keys.subtitle": "Виртуальные ключи шлюза для агентов. Ключи провайдеров не покидают шлюз.", + "keys.add": "Новый ключ", + "keys.empty": "Ключей нет — шлюз в открытом режиме (без аутентификации).", + "keys.openMode": "Открытый режим: любой запрос принимается (ключ не нужен).", + "keys.col.key": "Ключ", + "keys.col.account": "Аккаунт", + "keys.col.rate": "Лимит / мин", + "keys.col.models": "Разрешённые модели", + "keys.form.title": "Новый API-ключ", + "keys.form.account": "Аккаунт", + "keys.form.rate": "Лимит запросов / мин", + "keys.form.allow": "Разрешённые модели", + "keys.form.allowHint": "Оставьте пустым, чтобы разрешить все модели.", + "keys.form.custom": "Произвольный ключ (необязательно)", + "keys.form.customHint": "Оставьте пустым для автогенерации sk-gw-…", + "keys.created": "Ключ создан — скопируйте его сейчас, он больше не отобразится:", + "keys.revoke": "Отозвать", + "keys.revokeConfirm": "Отозвать этот ключ?", + "keys.allowAll": "все", + + // playground + "pg.title": "Playground", + "pg.subtitle": "Отправьте запрос через активный пайплайн и изучите решение роутера.", + "pg.system": "Системный промпт", + "pg.systemPlaceholder": "необязательное системное сообщение", + "pg.prompt": "Промпт", + "pg.promptPlaceholder": "Задайте вопрос…", + "pg.mode": "Режим", + "pg.mode.auto": "Авто (роутинг)", + "pg.mode.pinned": "Фиксированная модель", + "pg.profile": "Профиль", + "pg.model": "Модель", + "pg.temp": "Температура", + "pg.maxTokens": "Макс. токены", + "pg.send": "Отправить", + "pg.sending": "Отправка…", + "pg.answer": "Ответ", + "pg.empty": "Ответ и решение роутера появятся здесь.", + "pg.decision": "Решение роутера", + "pg.task": "Задача", + "pg.tier": "Уровень", + "pg.score": "Сложность", + "pg.selected": "Выбранная модель", + "pg.source": "Источник маршрута", + "pg.cost": "Стоимость", + "pg.latency": "Задержка", + "pg.tokens": "Токены (вх/исх)", + "pg.failover": "Failover", + "pg.error": "Ошибка запроса", + + // settings + "settings.title": "Настройки", + "settings.subtitle": "Подключение к шлюзу, входные данные роутинга, надёжность и наблюдаемость.", + "settings.router": "Подключение к роутеру", + "settings.router.url": "URL роутера", + "settings.router.keyEnv": "Имя env-переменной с ключом роутера", + "settings.router.timeout": "Таймаут (мс)", + "settings.router.verifyTls": "Проверять TLS-сертификат", + "settings.router.taxonomy": "ID таксономии", + "settings.route": "Входные данные роутинга", + "settings.route.strategy": "Стратегия текста", + "settings.route.maxChars": "Макс. символов", + "settings.route.cacheTtl": "TTL кэша", + "settings.route.profile": "Профиль по умолчанию", + "settings.breaker": "Автоматический выключатель", + "settings.breaker.threshold": "Порог ошибок", + "settings.breaker.cooldown": "Время восстановления", + "settings.log": "Логирование", + "settings.log.level": "Уровень логов", + "settings.log.bodies": "Логировать тела сообщений (приватность отключена)", + "settings.telemetry": "Телеметрия", + "settings.telemetry.metrics": "Включить Prometheus /metrics", + "settings.cortiq": "Метаданные ответа", + "settings.cortiq.echo": "Включать блок роутинга cortiq в ответы", + "settings.stats": "Статистика", + "settings.stats.enabled": "Записывать статистику запросов", + "settings.stats.file": "Файл статистики (JSONL)", + "settings.stats.retention": "Срок хранения", + "settings.listen": "Адрес прослушивания", + "settings.export": "Экспорт конфигурации", + "settings.import": "Импорт конфигурации", + "settings.needsRestart": "Сохранено. Некоторые изменения (адрес прослушивания) требуют перезапуска.", + + // toasts / misc + "toast.saved": "Изменения сохранены", + "toast.deleted": "Удалено", + "toast.probing": "Проверка…", + "toast.imported": "Конфигурация импортирована", +}; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js new file mode 100644 index 0000000..a07c17e --- /dev/null +++ b/web/js/i18n/tr.js @@ -0,0 +1,256 @@ +// Turkish — tam çeviri; eksik anahtarlar İngilizce'ye geri döner. +export default { + // brand / common + "brand.sub": "Gateway", + "common.loading": "Yükleniyor…", + "common.save": "Kaydet", + "common.saving": "Kaydediliyor…", + "common.cancel": "İptal", + "common.add": "Ekle", + "common.edit": "Düzenle", + "common.delete": "Sil", + "common.close": "Kapat", + "common.test": "Test", + "common.copy": "Kopyala", + "common.copied": "Kopyalandı", + "common.confirm": "Onayla", + "common.none": "yok", + "common.saved": "Kaydedildi", + "common.error": "Hata", + "common.ok": "Tamam", + "common.refresh": "Yenile", + "common.actions": "İşlemler", + "common.optional": "isteğe bağlı", + "common.search": "Ara", + "common.all": "tümü", + + // nav + "nav.dashboard": "Pano", + "nav.models": "Modeller", + "nav.routing": "Yönlendirme", + "nav.protocols": "Protokoller", + "nav.keys": "API anahtarları", + "nav.playground": "Oyun alanı", + "nav.settings": "Ayarlar", + "nav.foot": "Gateway · v{v}", + + // topbar / status + "status.router": "Yönlendirici", + "status.gateway": "Gateway", + "status.online": "çevrimiçi", + "status.offline": "çevrimdışı", + "status.checking": "kontrol ediliyor…", + "theme.toggle": "Tema", + "logout": "Çıkış yap", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "Bu gateway'i yönetmek için yönetici token'ını girin.", + "login.token": "Yönetici token'ı", + "login.tokenPlaceholder": "yönetici token'ını yapıştırın", + "login.submit": "Konsolu aç", + "login.hint": "Token, başlangıçta gateway günlüğüne yazdırılır veya [admin].token_env ile ayarlanır.", + "login.failed": "Geçersiz yönetici token'ı.", + + // dashboard + "dash.title": "Pano", + "dash.subtitle": "Model havuzunuz için canlı trafik, maliyet ve yönlendirme analitiği.", + "dash.range": "Aralık", + "dash.range.1h": "1s", + "dash.range.24h": "24s", + "dash.range.7d": "7g", + "dash.kpi.requests": "İstekler", + "dash.kpi.tokens": "Token'lar", + "dash.kpi.cost": "Maliyet", + "dash.kpi.latency": "Ort. gecikme", + "dash.kpi.success": "Başarı oranı", + "dash.kpi.failovers": "Yedeklemeler", + "dash.series.title": "Zamana göre istekler", + "dash.series.cost": "Zamana göre maliyet", + "dash.series.empty": "Bu aralıkta henüz trafik yok.", + "dash.breakdown.title": "Dağılım", + "dash.groupby.model": "modele göre", + "dash.groupby.tier": "kademeye göre", + "dash.groupby.label": "göreve göre", + "dash.groupby.account": "hesaba göre", + "dash.recent.title": "Son istekler", + "dash.recent.empty": "Henüz kayıtlı istek yok. Oyun alanını deneyin.", + "dash.col.time": "Zaman", + "dash.col.model": "Model", + "dash.col.tier": "Kademe", + "dash.col.task": "Görev", + "dash.col.tokens": "Token'lar", + "dash.col.cost": "Maliyet", + "dash.col.latency": "Gecikme", + "dash.col.outcome": "Sonuç", + "dash.col.requests": "İstekler", + "dash.col.errors": "Hatalar", + "dash.health.title": "Sağlık", + "dash.health.reachable": "erişilebilir", + "dash.health.unreachable": "erişilemiyor", + + // models + "models.title": "Modeller", + "models.subtitle": "Model havuzunuz — ucuz yerel modelleri bulut API'leriyle karıştırın.", + "models.add": "Model ekle", + "models.empty": "Henüz model yok. Başlamak için ilk modelinizi ekleyin.", + "models.col.id": "ID", + "models.col.provider": "Sağlayıcı", + "models.col.model": "Model", + "models.col.tier": "Maliyet kademesi", + "models.col.key": "Anahtar", + "models.col.caps": "Yetenekler", + "models.col.price": "Fiyat (giriş/çıkış)", + "models.probe": "Sına", + "models.probe.ok": "Erişilebilir ({ms} ms)", + "models.probe.fail": "Başarısız: {err}", + "models.form.addTitle": "Model ekle", + "models.form.editTitle": "Modeli düzenle", + "models.form.id": "ID", + "models.form.idHint": "Yönlendirmede kullanılan benzersiz id, örn. local-qwen", + "models.form.provider": "Sağlayıcı", + "models.form.baseUrl": "Temel URL", + "models.form.model": "Model adı", + "models.form.kind": "Tür", + "models.form.costTier": "Maliyet kademesi", + "models.form.priceIn": "Giriş fiyatı ($/1M)", + "models.form.priceOut": "Çıkış fiyatı ($/1M)", + "models.form.apiKeyEnv": "API anahtarı ortam değişkeni adı", + "models.form.apiKeyEnvHint": "Bu sağlayıcının anahtarını tutan ortam değişkeni / saklı gizli adı.", + "models.form.caps": "Yetenekler", + "models.form.secret": "Gizli değer", + "models.form.secretStored": "saklandı", + "models.form.secretEnv": "ortamdan", + "models.form.secretMissing": "ayarlanmadı", + "models.form.secretPlaceholder": "anahtarı ayarla veya değiştir (yalnızca yazma)", + "models.deleteConfirm": "\"{id}\" modelini sil?", + + // routing + "routing.title": "Yönlendirme", + "routing.subtitle": "Karmaşıklık kademelerini sıralı model listesiyle eşleştirin (öncelik = yedek).", + "routing.default": "Varsayılan model", + "routing.defaultHint": "Yönlendirici erişilemez olduğunda kullanılır.", + "routing.tiers": "Kademeler", + "routing.tierHint": "Sıralamak için çipleri sürükleyin; birincisi tercih edilir, diğerleri yedektir.", + "routing.addModel": "+ ekle", + "routing.addTier": "Kademe ekle", + "routing.tierName": "Kademe adı", + "routing.policy": "İlke", + "routing.policy.mode": "Mod", + "routing.policy.fixed": "Sabit tablo", + "routing.policy.costAware": "Maliyet bilinçli", + "routing.policy.maxCost": "İstek başına maks. maliyet ($)", + "routing.policy.minClass": "Kademe başına minimum sınıf", + "routing.empty": "Kullanılabilir model yok — önce model ekleyin.", + + // protocols + "protocols.title": "Protokoller", + "protocols.subtitle": "Yalnızca ihtiyaç duyduğunuz gelen API yüzeylerini etkinleştirin. Geçişler anında uygulanır.", + "protocols.implemented": "uygulandı", + "protocols.planned": "planlandı", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — OpenAI SDK, LangChain, crewAI ile çalışır.", + "protocols.openai_completions": "OpenAI Completions (eski)", + "protocols.openai_completions.d": "POST /v1/completions — eski entegrasyonlar.", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — RAG ardışık düzenleri.", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — istemci otomatik keşfi.", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — Claude yerel ajanları.", + "protocols.mcp": "MCP (Model Context Protocol)", + "protocols.mcp.d": "JSON-RPC / SSE — MCP yerel orkestratörler.", + "protocols.native_passthrough": "Yerel geçiş", + "protocols.native_passthrough.d": "POST /route — yönlendirme kararına doğrudan erişim.", + + // keys + "keys.title": "API anahtarları", + "keys.subtitle": "Ajanlarınız için sanal gateway anahtarları. Sağlayıcı anahtarları gateway'den hiç çıkmaz.", + "keys.add": "Yeni anahtar", + "keys.empty": "Anahtar yapılandırılmadı — gateway açık modda (kimlik doğrulama yok).", + "keys.openMode": "Açık mod: tüm istekler kabul edilir (anahtar gerekmez).", + "keys.col.key": "Anahtar", + "keys.col.account": "Hesap", + "keys.col.rate": "Oran / dak", + "keys.col.models": "İzin verilen modeller", + "keys.form.title": "Yeni API anahtarı", + "keys.form.account": "Hesap", + "keys.form.rate": "Oran sınırı / dak", + "keys.form.allow": "İzin verilen modeller", + "keys.form.allowHint": "Tüm modellere izin vermek için boş bırakın.", + "keys.form.custom": "Özel anahtar (isteğe bağlı)", + "keys.form.customHint": "sk-gw-… otomatik oluşturmak için boş bırakın.", + "keys.created": "Anahtar oluşturuldu — şimdi kopyalayın, bir daha gösterilmeyecek:", + "keys.revoke": "İptal et", + "keys.revokeConfirm": "Bu anahtarı iptal et?", + "keys.allowAll": "tümü", + + // playground + "pg.title": "Oyun alanı", + "pg.subtitle": "Canlı ardışık düzen üzerinden istem gönderin ve yönlendirme kararını inceleyin.", + "pg.system": "Sistem istemi", + "pg.systemPlaceholder": "isteğe bağlı sistem mesajı", + "pg.prompt": "İstem", + "pg.promptPlaceholder": "Bir şey sorun…", + "pg.mode": "Mod", + "pg.mode.auto": "Otomatik (yönlendir)", + "pg.mode.pinned": "Sabitlenmiş model", + "pg.profile": "Profil", + "pg.model": "Model", + "pg.temp": "Sıcaklık", + "pg.maxTokens": "Maks. token", + "pg.send": "Gönder", + "pg.sending": "Gönderiliyor…", + "pg.answer": "Yanıt", + "pg.empty": "Yanıt ve yönlendirme kararı burada görünecek.", + "pg.decision": "Yönlendirme kararı", + "pg.task": "Görev", + "pg.tier": "Kademe", + "pg.score": "Karmaşıklık", + "pg.selected": "Seçilen model", + "pg.source": "Rota kaynağı", + "pg.cost": "Maliyet", + "pg.latency": "Gecikme", + "pg.tokens": "Token'lar (giriş/çıkış)", + "pg.failover": "Yedekleme", + "pg.error": "İstek başarısız", + + // settings + "settings.title": "Ayarlar", + "settings.subtitle": "Gateway bağlantısı, yönlendirme girişi, güvenilirlik ve gözlemlenebilirlik.", + "settings.router": "Yönlendirici bağlantısı", + "settings.router.url": "Yönlendirici URL'si", + "settings.router.keyEnv": "Yönlendirici anahtarı ortam değişkeni adı", + "settings.router.timeout": "Zaman aşımı (ms)", + "settings.router.verifyTls": "TLS sertifikasını doğrula", + "settings.router.taxonomy": "Taksonomi id", + "settings.route": "Yönlendirme girişi", + "settings.route.strategy": "Metin stratejisi", + "settings.route.maxChars": "Maks. karakter", + "settings.route.cacheTtl": "Önbellek TTL", + "settings.route.profile": "Varsayılan profil", + "settings.breaker": "Devre kesici", + "settings.breaker.threshold": "Hata eşiği", + "settings.breaker.cooldown": "Bekleme süresi", + "settings.log": "Günlükleme", + "settings.log.level": "Günlük seviyesi", + "settings.log.bodies": "Mesaj gövdelerini günlükle (gizlilik kapalı)", + "settings.telemetry": "Telemetri", + "settings.telemetry.metrics": "Prometheus /metrics'i aç", + "settings.cortiq": "Yanıt meta verisi", + "settings.cortiq.echo": "Yanıtlarda cortiq yönlendirme bloğunu yansıt", + "settings.stats": "İstatistikler", + "settings.stats.enabled": "İstek istatistiklerini kaydet", + "settings.stats.file": "İstatistik dosyası (JSONL)", + "settings.stats.retention": "Saklama süresi", + "settings.listen": "Dinleme adresi", + "settings.export": "Yapılandırmayı dışa aktar", + "settings.import": "Yapılandırmayı içe aktar", + "settings.needsRestart": "Kaydedildi. Bazı değişiklikler (dinleme adresi) yeniden başlatma gerektirir.", + + // toasts / misc + "toast.saved": "Değişiklikler kaydedildi", + "toast.deleted": "Silindi", + "toast.probing": "Sınanıyor…", + "toast.imported": "Yapılandırma içe aktarıldı", +}; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js new file mode 100644 index 0000000..a249db7 --- /dev/null +++ b/web/js/i18n/zh.js @@ -0,0 +1,256 @@ +// 简体中文 — Simplified Chinese translation. Keys must match en.js exactly. +export default { + // brand / common + "brand.sub": "网关", + "common.loading": "加载中…", + "common.save": "保存", + "common.saving": "保存中…", + "common.cancel": "取消", + "common.add": "添加", + "common.edit": "编辑", + "common.delete": "删除", + "common.close": "关闭", + "common.test": "测试", + "common.copy": "复制", + "common.copied": "已复制", + "common.confirm": "确认", + "common.none": "无", + "common.saved": "已保存", + "common.error": "错误", + "common.ok": "确定", + "common.refresh": "刷新", + "common.actions": "操作", + "common.optional": "可选", + "common.search": "搜索", + "common.all": "全部", + + // nav + "nav.dashboard": "仪表盘", + "nav.models": "模型", + "nav.routing": "路由", + "nav.protocols": "协议", + "nav.keys": "API 密钥", + "nav.playground": "沙盒", + "nav.settings": "设置", + "nav.foot": "网关 · v{v}", + + // topbar / status + "status.router": "路由器", + "status.gateway": "网关", + "status.online": "在线", + "status.offline": "离线", + "status.checking": "检测中…", + "theme.toggle": "主题", + "logout": "退出", + + // login + "login.title": "allaigate Gateway", + "login.subtitle": "输入管理员令牌以管理此网关。", + "login.token": "管理员令牌", + "login.tokenPlaceholder": "粘贴管理员令牌", + "login.submit": "进入控制台", + "login.hint": "令牌将在网关启动日志中打印,或通过 [admin].token_env 设置。", + "login.failed": "无效的管理员令牌。", + + // dashboard + "dash.title": "仪表盘", + "dash.subtitle": "模型池的实时流量、费用与路由分析。", + "dash.range": "时间范围", + "dash.range.1h": "1小时", + "dash.range.24h": "24小时", + "dash.range.7d": "7天", + "dash.kpi.requests": "请求数", + "dash.kpi.tokens": "Token 数", + "dash.kpi.cost": "费用", + "dash.kpi.latency": "平均延迟", + "dash.kpi.success": "成功率", + "dash.kpi.failovers": "故障转移", + "dash.series.title": "请求趋势", + "dash.series.cost": "费用趋势", + "dash.series.empty": "此时间段内暂无流量。", + "dash.breakdown.title": "明细", + "dash.groupby.model": "按模型", + "dash.groupby.tier": "按层级", + "dash.groupby.label": "按任务", + "dash.groupby.account": "按账号", + "dash.recent.title": "最近请求", + "dash.recent.empty": "暂无请求记录,请前往沙盒测试。", + "dash.col.time": "时间", + "dash.col.model": "模型", + "dash.col.tier": "层级", + "dash.col.task": "任务", + "dash.col.tokens": "Token", + "dash.col.cost": "费用", + "dash.col.latency": "延迟", + "dash.col.outcome": "结果", + "dash.col.requests": "请求数", + "dash.col.errors": "错误数", + "dash.health.title": "健康状态", + "dash.health.reachable": "可达", + "dash.health.unreachable": "不可达", + + // models + "models.title": "模型", + "models.subtitle": "模型池——本地低成本模型与云端 API 混合部署。", + "models.add": "添加模型", + "models.empty": "暂无模型,请添加第一个模型。", + "models.col.id": "ID", + "models.col.provider": "提供商", + "models.col.model": "模型", + "models.col.tier": "费用层级", + "models.col.key": "密钥", + "models.col.caps": "能力", + "models.col.price": "价格(输入/输出)", + "models.probe": "探测", + "models.probe.ok": "可达({ms} ms)", + "models.probe.fail": "失败:{err}", + "models.form.addTitle": "添加模型", + "models.form.editTitle": "编辑模型", + "models.form.id": "ID", + "models.form.idHint": "路由中使用的唯一 ID,例如 local-qwen", + "models.form.provider": "提供商", + "models.form.baseUrl": "Base URL", + "models.form.model": "模型名称", + "models.form.kind": "类型", + "models.form.costTier": "费用层级", + "models.form.priceIn": "输入价格($/1M)", + "models.form.priceOut": "输出价格($/1M)", + "models.form.apiKeyEnv": "API 密钥环境变量名", + "models.form.apiKeyEnvHint": "持有该提供商密钥的环境变量或存储机密名称。", + "models.form.caps": "能力", + "models.form.secret": "密钥值", + "models.form.secretStored": "已存储", + "models.form.secretEnv": "来自环境变量", + "models.form.secretMissing": "未设置", + "models.form.secretPlaceholder": "设置或替换密钥(只写)", + "models.deleteConfirm": "删除模型 \"{id}\"?", + + // routing + "routing.title": "路由", + "routing.subtitle": "将复杂度层级映射到有序模型列表(优先级 = 回退顺序)。", + "routing.default": "默认模型", + "routing.defaultHint": "路由器不可达时使用。", + "routing.tiers": "层级", + "routing.tierHint": "拖动标签重新排序;第一个为首选,其余为回退。", + "routing.addModel": "+ 添加", + "routing.addTier": "添加层级", + "routing.tierName": "层级名称", + "routing.policy": "策略", + "routing.policy.mode": "模式", + "routing.policy.fixed": "固定表", + "routing.policy.costAware": "成本感知", + "routing.policy.maxCost": "单请求最大费用($)", + "routing.policy.minClass": "每层最低等级", + "routing.empty": "暂无可用模型,请先添加模型。", + + // protocols + "protocols.title": "协议", + "protocols.subtitle": "仅启用所需的入站 API 接口,切换立即生效。", + "protocols.implemented": "已实现", + "protocols.planned": "计划中", + "protocols.openai_chat": "OpenAI Chat Completions", + "protocols.openai_chat.d": "POST /v1/chat/completions — 兼容 OpenAI SDK、LangChain、crewAI。", + "protocols.openai_completions": "OpenAI Completions(旧版)", + "protocols.openai_completions.d": "POST /v1/completions — 旧版集成。", + "protocols.openai_embeddings": "OpenAI Embeddings", + "protocols.openai_embeddings.d": "POST /v1/embeddings — RAG 管道。", + "protocols.openai_models": "OpenAI Models", + "protocols.openai_models.d": "GET /v1/models — 客户端自动发现。", + "protocols.anthropic_messages": "Anthropic Messages", + "protocols.anthropic_messages.d": "POST /v1/messages — Claude 原生智能体。", + "protocols.mcp": "MCP(模型上下文协议)", + "protocols.mcp.d": "JSON-RPC / SSE — MCP 原生编排器。", + "protocols.native_passthrough": "原生透传", + "protocols.native_passthrough.d": "POST /route — 直接访问路由决策。", + + // keys + "keys.title": "API 密钥", + "keys.subtitle": "为您的智能体提供虚拟网关密钥,提供商密钥不离开网关。", + "keys.add": "新建密钥", + "keys.empty": "未配置密钥——网关处于开放模式(无需认证)。", + "keys.openMode": "开放模式:接受所有请求(无需密钥)。", + "keys.col.key": "密钥", + "keys.col.account": "账号", + "keys.col.rate": "速率/分钟", + "keys.col.models": "允许模型", + "keys.form.title": "新建 API 密钥", + "keys.form.account": "账号", + "keys.form.rate": "速率限制/分钟", + "keys.form.allow": "允许模型", + "keys.form.allowHint": "留空表示允许所有模型。", + "keys.form.custom": "自定义密钥(可选)", + "keys.form.customHint": "留空将自动生成 sk-gw-…", + "keys.created": "密钥已创建——请立即复制,之后将不再显示:", + "keys.revoke": "吊销", + "keys.revokeConfirm": "确认吊销此密钥?", + "keys.allowAll": "全部", + + // playground + "pg.title": "沙盒", + "pg.subtitle": "通过实时管道发送提示并查看路由决策。", + "pg.system": "系统提示", + "pg.systemPlaceholder": "可选系统消息", + "pg.prompt": "提示", + "pg.promptPlaceholder": "请输入问题…", + "pg.mode": "模式", + "pg.mode.auto": "自动(路由)", + "pg.mode.pinned": "固定模型", + "pg.profile": "配置文件", + "pg.model": "模型", + "pg.temp": "温度", + "pg.maxTokens": "最大 Token 数", + "pg.send": "发送", + "pg.sending": "发送中…", + "pg.answer": "回答", + "pg.empty": "回答和路由决策将在此显示。", + "pg.decision": "路由决策", + "pg.task": "任务", + "pg.tier": "层级", + "pg.score": "复杂度", + "pg.selected": "已选模型", + "pg.source": "路由来源", + "pg.cost": "费用", + "pg.latency": "延迟", + "pg.tokens": "Token(输入/输出)", + "pg.failover": "故障转移", + "pg.error": "请求失败", + + // settings + "settings.title": "设置", + "settings.subtitle": "网关连接、路由输入、可靠性与可观测性。", + "settings.router": "路由器连接", + "settings.router.url": "路由器 URL", + "settings.router.keyEnv": "路由器密钥环境变量名", + "settings.router.timeout": "超时(ms)", + "settings.router.verifyTls": "验证 TLS 证书", + "settings.router.taxonomy": "分类体系 ID", + "settings.route": "路由输入", + "settings.route.strategy": "文本策略", + "settings.route.maxChars": "最大字符数", + "settings.route.cacheTtl": "缓存 TTL", + "settings.route.profile": "默认配置文件", + "settings.breaker": "熔断器", + "settings.breaker.threshold": "错误阈值", + "settings.breaker.cooldown": "冷却时间", + "settings.log": "日志", + "settings.log.level": "日志级别", + "settings.log.bodies": "记录消息体(隐私关闭)", + "settings.telemetry": "遥测", + "settings.telemetry.metrics": "暴露 Prometheus /metrics", + "settings.cortiq": "响应元数据", + "settings.cortiq.echo": "在响应中回显 cortiq 路由块", + "settings.stats": "统计", + "settings.stats.enabled": "记录请求统计", + "settings.stats.file": "统计文件(JSONL)", + "settings.stats.retention": "保留期", + "settings.listen": "监听地址", + "settings.export": "导出配置", + "settings.import": "导入配置", + "settings.needsRestart": "已保存。部分更改(监听地址)需要重启生效。", + + // toasts / misc + "toast.saved": "更改已保存", + "toast.deleted": "已删除", + "toast.probing": "探测中…", + "toast.imported": "配置已导入", +}; diff --git a/web/js/ui.js b/web/js/ui.js new file mode 100644 index 0000000..b3592b0 --- /dev/null +++ b/web/js/ui.js @@ -0,0 +1,103 @@ +// Tiny DOM helpers — no framework. `h` builds elements; `mount` swaps children. +import { t } from "./i18n.js"; + +export function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === "class") el.className = v; + else if (k === "html") el.innerHTML = v; + else if (k === "dataset") Object.assign(el.dataset, v); + else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === true) el.setAttribute(k, ""); + else el.setAttribute(k, v); + } + for (const c of children.flat()) { + if (c == null || c === false) continue; + el.appendChild(typeof c === "object" ? c : document.createTextNode(String(c))); + } + return el; +} + +export function clear(node) { + while (node && node.firstChild) node.removeChild(node.firstChild); +} + +export function mount(node, ...children) { + clear(node); + for (const c of children.flat()) if (c != null && c !== false) node.appendChild(c); + return node; +} + +export function toast(msg, kind = "info", ms = 3200) { + const box = document.getElementById("toasts"); + if (!box) return; + const el = h("div", { class: `toast ${kind}` }, msg); + box.appendChild(el); + setTimeout(() => { + el.style.opacity = "0"; + el.style.transition = "opacity .2s"; + setTimeout(() => el.remove(), 220); + }, ms); +} + +// Modal dialog. `body` is a DOM node; `onSave` returns a Promise; returns nothing. +export function modal(title, body, onSave, saveLabel) { + const backdrop = h("div", { class: "modal-backdrop" }); + const close = () => backdrop.remove(); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(); + }); + const saveBtn = h("button", { class: "btn primary" }, saveLabel || t("common.save")); + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + try { + const keep = await onSave(); + if (keep !== false) close(); + } finally { + saveBtn.disabled = false; + } + }); + const card = h( + "div", + { class: "modal" }, + h("h3", {}, title), + body, + h( + "div", + { class: "modal-foot" }, + h("button", { class: "btn ghost", onClick: close }, t("common.cancel")), + onSave ? saveBtn : null + ) + ); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + return { close }; +} + +export function confirmDialog(message) { + return Promise.resolve(window.confirm(message)); +} + +// number formatting +export function money(n) { + if (n == null) return "—"; + if (n === 0) return "$0"; + if (Math.abs(n) < 0.01) return "$" + n.toFixed(6); + return "$" + n.toFixed(4); +} +export function ms(n) { + return n == null ? "—" : `${Math.round(Number(n))} ms`; +} +export function num(n) { + if (n == null) return "—"; + return Number(n).toLocaleString(); +} +export function pct(n) { + if (n == null) return "—"; + return (n * 100).toFixed(1) + "%"; +} +export function timeAgo(unixSecs) { + const d = new Date(unixSecs * 1000); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} diff --git a/web/js/views/dashboard.js b/web/js/views/dashboard.js new file mode 100644 index 0000000..50c14ce --- /dev/null +++ b/web/js/views/dashboard.js @@ -0,0 +1,218 @@ +// Dashboard — KPIs, time-series charts, breakdown, recent requests, health. +import { h, mount, money, num, ms, pct, timeAgo } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { lineChart, barList } from "../charts.js"; + +const RANGES = ["1h", "24h", "7d"]; +const GROUPS = ["model", "tier", "label", "account"]; + +function kpi(big, lbl) { + return h("div", { class: "kpi" }, h("div", { class: "big" }, big), h("div", { class: "lbl" }, lbl)); +} + +function seg(options, current, onPick, labelFn) { + return h( + "div", + { class: "seg" }, + ...options.map((o) => + h("button", { class: o === current ? "active" : "", onClick: () => onPick(o) }, labelFn ? labelFn(o) : o) + ) + ); +} + +export async function renderDashboard() { + let range = "24h"; + let groupby = "model"; + const root = h("div"); + + async function load() { + const rangeSecs = range; + let stats, recent, health; + try { + [stats, recent, health] = await Promise.all([ + api.stats(rangeSecs, groupby), + api.requests(40, 0), + api.health().catch(() => null), + ]); + } catch (e) { + mount(root, h("div", { class: "callout" }, String(e.message || e))); + return; + } + const tot = stats.totals || {}; + const series = stats.series || []; + const breakdown = (stats.breakdown || []).map((b) => ({ key: b.key || t("common.none"), value: b.requests })); + + mount( + root, + h( + "div", + { class: "page-head" }, + h( + "div", + { class: "flex" }, + h("div", { class: "grow" }, h("h2", {}, t("dash.title")), h("p", {}, t("dash.subtitle"))), + seg(RANGES, range, (r) => { + range = r; + load(); + }, (r) => t("dash.range." + r)) + ) + ), + + // KPIs + h( + "div", + { class: "kpis" }, + kpi(num(tot.requests), t("dash.kpi.requests")), + kpi(num(tot.total_tokens), t("dash.kpi.tokens")), + kpi(money(tot.cost_usd), t("dash.kpi.cost")), + kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")), + kpi(pct(tot.success_rate), t("dash.kpi.success")), + kpi(num(tot.failovers), t("dash.kpi.failovers")) + ), + + h( + "div", + { class: "grid cols-2" }, + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("dash.series.title"))), + series.length < 2 ? h("div", { class: "empty" }, t("dash.series.empty")) : lineChart(series, "requests") + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("dash.series.cost"))), + series.length < 2 ? h("div", { class: "empty" }, t("dash.series.empty")) : lineChart(series, "cost_usd") + ) + ), + + // breakdown + h( + "div", + { class: "card" }, + h( + "div", + { class: "card-head" }, + h("h3", {}, t("dash.breakdown.title")), + h( + "div", + { class: "right" }, + seg(GROUPS, groupby, (g) => { + groupby = g; + load(); + }, (g) => t("dash.groupby." + g)) + ) + ), + barList(breakdown, num) + ), + + // recent + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("dash.recent.title"))), + recentTable(recent.requests || []) + ), + + health ? healthCard(health) : null + ); + } + + await load(); + return root; +} + +function recentTable(rows) { + if (!rows.length) return h("div", { class: "empty" }, t("dash.recent.empty")); + return h( + "div", + { class: "table-wrap" }, + h( + "table", + {}, + h( + "thead", + {}, + h( + "tr", + {}, + h("th", {}, t("dash.col.time")), + h("th", {}, t("dash.col.model")), + h("th", {}, t("dash.col.tier")), + h("th", {}, t("dash.col.task")), + h("th", {}, t("dash.col.tokens")), + h("th", {}, t("dash.col.cost")), + h("th", {}, t("dash.col.latency")), + h("th", {}, t("dash.col.outcome")) + ) + ), + h( + "tbody", + {}, + ...rows.map((r) => + h( + "tr", + {}, + h("td", { class: "mono" }, timeAgo(r.ts)), + h("td", {}, r.model_id || "—", r.failover ? h("span", { class: "badge warn", style: "margin-left:6px" }, "fo") : null), + h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"), + h("td", {}, r.task_label || "—"), + h("td", { class: "mono" }, `${r.prompt_tokens}/${r.completion_tokens}`), + h("td", { class: "mono" }, money(r.cost_usd)), + h("td", { class: "mono" }, ms(r.latency_ms)), + h("td", {}, h("span", { class: "badge " + (r.outcome === "ok" ? "ok" : "error") }, r.outcome)) + ) + ) + ) + ) + ); +} + +function healthCard(hh) { + return h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("dash.health.title"))), + h( + "div", + { class: "flex wrap", style: "margin-bottom:12px" }, + h( + "span", + { class: "badge " + (hh.router?.reachable ? "ok" : "bad"), title: hh.router?.url || "" }, + t("status.router") + ": " + (hh.router?.reachable ? t("dash.health.reachable") : t("dash.health.unreachable")) + ) + ), + h( + "div", + { class: "table-wrap" }, + h( + "table", + {}, + h("thead", {}, h("tr", {}, h("th", {}, t("models.col.id")), h("th", {}, t("models.col.provider")), h("th", {}, t("models.col.model")), h("th", {}, t("models.col.key")))), + h( + "tbody", + {}, + ...(hh.models || []).map((m) => + h( + "tr", + {}, + h("td", { class: "mono" }, m.id), + h("td", {}, m.provider), + h("td", { class: "mono" }, m.model), + h("td", {}, h("span", { class: "badge " + keyBadge(m.key_source) }, m.key_source)) + ) + ) + ) + ) + ) + ); +} + +function keyBadge(src) { + if (src === "store") return "store"; + if (src === "env") return "env"; + if (src === "missing") return "bad"; + return ""; +} diff --git a/web/js/views/keys.js b/web/js/views/keys.js new file mode 100644 index 0000000..8e83f25 --- /dev/null +++ b/web/js/views/keys.js @@ -0,0 +1,128 @@ +// API keys — list, create (with copy-once), revoke. +import { h, mount, modal, toast, confirmDialog } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; + +export async function renderKeys() { + const modelsData = await api.listModels(); + const ids = (modelsData.models || []).map((m) => m.id); + const root = h("div"); + + function openCreate(reload) { + const accountIn = h("input", { placeholder: "team-a" }); + const rateIn = h("input", { type: "number", value: 600 }); + const customIn = h("input", { placeholder: "sk-gw-…" }); + const allowInputs = ids.map((id) => ({ id, cb: h("input", { type: "checkbox" }) })); + const body = h( + "div", + {}, + h("label", { class: "field" }, h("span", {}, t("keys.form.account")), accountIn), + h("label", { class: "field" }, h("span", {}, t("keys.form.rate")), rateIn), + h( + "label", + { class: "field" }, + h("span", {}, t("keys.form.allow")), + h("div", { class: "flex wrap" }, ...allowInputs.map(({ id, cb }) => h("label", { class: "check" }, cb, id))), + h("div", { class: "hint" }, t("keys.form.allowHint")) + ), + h("label", { class: "field" }, h("span", {}, t("keys.form.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint"))) + ); + modal(t("keys.form.title"), body, async () => { + try { + const r = await api.createKey({ + key: customIn.value.trim(), + account: accountIn.value.trim() || "default", + rate_per_min: parseInt(rateIn.value) || 0, + allow_models: allowInputs.filter(({ cb }) => cb.checked).map(({ id }) => id), + }); + reload(); + showCreated(r.key); + } catch (e) { + toast(String(e.message || e), "bad"); + return false; + } + }, t("common.add")); + } + + function showCreated(key) { + const input = h("input", { value: key, readonly: true, class: "mono" }); + const copyBtn = h("button", { class: "btn", onClick: () => { input.select(); navigator.clipboard?.writeText(key); toast(t("common.copied"), "good"); } }, t("common.copy")); + modal(t("common.ok"), h("div", {}, h("p", {}, t("keys.created")), h("div", { class: "flex" }, input, copyBtn)), null); + } + + async function reload() { + const data = await api.listKeys(); + const keys = data.keys || []; + mount( + root, + h( + "div", + { class: "page-head" }, + h( + "div", + { class: "flex" }, + h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))), + h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add")) + ) + ), + data.open_mode ? h("div", { class: "callout info" }, t("keys.openMode")) : null, + keys.length === 0 + ? h("div", { class: "card" }, h("div", { class: "empty" }, t("keys.empty"))) + : h( + "div", + { class: "card" }, + h( + "div", + { class: "table-wrap" }, + h( + "table", + {}, + h( + "thead", + {}, + h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions"))) + ), + h( + "tbody", + {}, + ...keys.map((k) => + h( + "tr", + {}, + h("td", { class: "mono" }, k.key_masked), + h("td", {}, k.account), + h("td", { class: "mono" }, k.rate_per_min || "—"), + h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))), + h( + "td", + { class: "right-align" }, + h( + "button", + { + class: "btn sm danger", + onClick: async () => { + if (!(await confirmDialog(t("keys.revokeConfirm")))) return; + try { + await api.deleteKey(k.key); + toast(t("toast.deleted"), "good"); + reload(); + } catch (e) { + toast(String(e.message || e), "bad"); + } + }, + }, + t("keys.revoke") + ) + ) + ) + ) + ) + ) + ) + ) + ); + } + + await reload(); + return root; +} diff --git a/web/js/views/login.js b/web/js/views/login.js new file mode 100644 index 0000000..e8ffd82 --- /dev/null +++ b/web/js/views/login.js @@ -0,0 +1,40 @@ +// Login screen — admin token entry. Shown when there is no token or on 401. +import { h } from "../ui.js"; +import { t } from "../i18n.js"; +import { setToken } from "../api.js"; + +export function renderLogin(onOk) { + const input = h("input", { + type: "password", + placeholder: t("login.tokenPlaceholder"), + autofocus: true, + }); + const submit = () => { + const val = input.value.trim(); + if (!val) return; + setToken(val); + onOk(); + }; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") submit(); + }); + return h( + "div", + { class: "login-wrap" }, + h( + "div", + { class: "login-card" }, + h( + "div", + { class: "brand" }, + h("b", { style: "font-size:20px" }, "allaigate"), + h("span", { class: "sub" }, t("brand.sub")) + ), + h("h2", {}, t("login.title")), + h("p", { class: "sub" }, t("login.subtitle")), + h("label", { class: "field" }, h("span", {}, t("login.token")), input), + h("button", { class: "btn primary", style: "width:100%", onClick: submit }, t("login.submit")), + h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint")) + ) + ); +} diff --git a/web/js/views/models.js b/web/js/views/models.js new file mode 100644 index 0000000..9f3ff68 --- /dev/null +++ b/web/js/views/models.js @@ -0,0 +1,206 @@ +// Models — pool table with add/edit modal, probe, and per-model secret entry. +import { h, mount, modal, toast, confirmDialog, money } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { appState } from "../app.js"; + +function field(label, control, hint) { + return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null); +} +function opt(v, label, sel) { + return h("option", { value: v, selected: sel ? true : null }, label || v); +} + +function keyBadge(src) { + const map = { store: "store", env: "env", missing: "bad", none: "" }; + return h("span", { class: "badge " + (map[src] ?? "") }, src); +} + +function modelForm(meta, existing) { + const m = existing || { provider: "openai", kind: "chat", cost_tier: "cheap", caps: [], price_in: 0, price_out: 0 }; + const idIn = h("input", { value: m.id || "", placeholder: "local-qwen", disabled: existing ? true : null }); + const providerSel = h("select", {}, ...(meta.providers || ["openai"]).map((p) => opt(p, p, p === m.provider))); + const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "http://localhost:8000/v1" }); + const modelIn = h("input", { value: m.model || "", placeholder: "qwen2.5-7b-instruct" }); + const kindSel = h("select", {}, ...(meta.kinds || ["chat"]).map((k) => opt(k, k, k === m.kind))); + const tierSel = h("select", {}, ...(meta.cost_tiers || ["cheap"]).map((c) => opt(c, c, c === m.cost_tier))); + const priceInIn = h("input", { type: "number", step: "0.01", value: m.price_in ?? 0 }); + const priceOutIn = h("input", { type: "number", step: "0.01", value: m.price_out ?? 0 }); + const keyEnvIn = h("input", { value: m.api_key_env || "", placeholder: "OPENAI_API_KEY" }); + const secretIn = h("input", { type: "password", placeholder: t("models.form.secretPlaceholder") }); + const capInputs = (meta.caps || ["tools", "vision"]).map((c) => { + const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null }); + return { c, cb }; + }); + + const node = h( + "div", + {}, + h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)), + field(t("models.form.baseUrl"), baseUrlIn), + field(t("models.form.model"), modelIn), + h("div", { class: "row" }, field(t("models.form.kind"), kindSel), field(t("models.form.costTier"), tierSel)), + h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)), + field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c)))), + field(t("models.form.apiKeyEnv"), keyEnvIn, t("models.form.apiKeyEnvHint")), + field(t("models.form.secret"), secretIn) + ); + + const getValue = () => ({ + model: { + id: existing ? m.id : idIn.value.trim(), + provider: providerSel.value, + base_url: baseUrlIn.value.trim(), + model: modelIn.value.trim(), + kind: kindSel.value, + cost_tier: tierSel.value, + price_in: parseFloat(priceInIn.value) || 0, + price_out: parseFloat(priceOutIn.value) || 0, + api_key_env: keyEnvIn.value.trim() || null, + caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c), + }, + secret: secretIn.value, + keyEnv: keyEnvIn.value.trim(), + }); + + return { node, getValue }; +} + +async function openModal(meta, existing, reload) { + const { node, getValue } = modelForm(meta, existing); + modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => { + const { model, secret, keyEnv } = getValue(); + if (!model.id) { + toast(t("models.form.id") + "?", "bad"); + return false; + } + // strip null api_key_env so serde keeps it optional + if (!model.api_key_env) delete model.api_key_env; + try { + if (existing) await api.updateModel(existing.id, model); + else await api.createModel(model); + if (secret && keyEnv) await api.setSecret(keyEnv, secret); + toast(t("toast.saved"), "good"); + reload(); + } catch (e) { + toast(String(e.message || e), "bad"); + return false; + } + }); +} + +export async function renderModels() { + const meta = appState.meta || (await api.meta()); + const root = h("div"); + + async function reload() { + const data = await api.listModels(); + const models = data.models || []; + mount( + root, + h( + "div", + { class: "page-head" }, + h( + "div", + { class: "flex" }, + h("div", { class: "grow" }, h("h2", {}, t("models.title")), h("p", {}, t("models.subtitle"))), + h("button", { class: "btn primary", onClick: () => openModal(meta, null, reload) }, "+ " + t("models.add")) + ) + ), + models.length === 0 + ? h("div", { class: "card" }, h("div", { class: "empty" }, t("models.empty"))) + : h( + "div", + { class: "card" }, + h( + "div", + { class: "table-wrap" }, + h( + "table", + {}, + h( + "thead", + {}, + h( + "tr", + {}, + h("th", {}, t("models.col.id")), + h("th", {}, t("models.col.provider")), + h("th", {}, t("models.col.model")), + h("th", {}, t("models.col.tier")), + h("th", {}, t("models.col.key")), + h("th", {}, t("models.col.caps")), + h("th", {}, t("models.col.price")), + h("th", { class: "right-align" }, t("common.actions")) + ) + ), + h( + "tbody", + {}, + ...models.map((m) => row(m, meta, reload)) + ) + ) + ) + ) + ); + } + + await reload(); + return root; +} + +function row(m, meta, reload) { + const probeBtn = h("button", { class: "btn sm ghost" }, t("models.probe")); + probeBtn.addEventListener("click", async () => { + probeBtn.disabled = true; + probeBtn.textContent = "…"; + try { + const r = await api.probeModel(m.id); + if (r.ok) toast(t("models.probe.ok", { ms: r.latency_ms }), "good"); + else toast(t("models.probe.fail", { err: r.error || "" }), "bad"); + } catch (e) { + toast(String(e.message || e), "bad"); + } + probeBtn.disabled = false; + probeBtn.textContent = t("models.probe"); + }); + return h( + "tr", + {}, + h("td", { class: "mono" }, m.id), + h("td", {}, m.provider), + h("td", { class: "mono" }, m.model), + h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")), + h("td", {}, keyBadge(m.key_source || (m.api_key_env ? "missing" : "none"))), + h("td", {}, (m.caps || []).length ? h("div", { class: "flex wrap" }, ...m.caps.map((c) => h("span", { class: "badge" }, c))) : "—"), + h("td", { class: "mono" }, `${m.price_in || 0}/${m.price_out || 0}`), + h( + "td", + { class: "right-align" }, + h( + "div", + { class: "flex", style: "justify-content:flex-end" }, + probeBtn, + h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")), + h( + "button", + { + class: "btn sm danger", + onClick: async () => { + if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return; + try { + await api.deleteModel(m.id); + toast(t("toast.deleted"), "good"); + reload(); + } catch (e) { + toast(String(e.message || e), "bad"); + } + }, + }, + t("common.delete") + ) + ) + ) + ); +} diff --git a/web/js/views/playground.js b/web/js/views/playground.js new file mode 100644 index 0000000..bfb198c --- /dev/null +++ b/web/js/views/playground.js @@ -0,0 +1,137 @@ +// Playground — send a prompt through the live pipeline, inspect routing decision. +import { h, mount, money, ms, num } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { appState } from "../app.js"; + +export async function renderPlayground() { + const meta = appState.meta || (await api.meta()); + const modelsData = await api.listModels(); + const ids = (modelsData.models || []).map((m) => m.id); + + let mode = "auto"; + const root = h("div"); + + const sysIn = h("textarea", { placeholder: t("pg.systemPlaceholder"), rows: 2 }); + const promptIn = h("textarea", { placeholder: t("pg.promptPlaceholder"), rows: 5 }); + const tempIn = h("input", { type: "number", step: "0.1", value: 0.7 }); + const maxTokIn = h("input", { type: "number", value: 512 }); + const profileSel = h("select", {}, ...(meta.profiles || []).map((p) => h("option", { value: p }, p))); + const modelSel = h("select", {}, ...ids.map((id) => h("option", { value: id }, id))); + + const modeSeg = h( + "div", + { class: "seg" }, + h("button", { class: "active", onClick: (e) => setMode("auto", e) }, t("pg.mode.auto")), + h("button", { onClick: (e) => setMode("pinned", e) }, t("pg.mode.pinned")) + ); + function buildModeExtra() { + return h( + "label", + { class: "field" }, + h("span", {}, mode === "auto" ? t("pg.profile") : t("pg.model")), + mode === "auto" ? profileSel : modelSel + ); + } + let modeExtra = buildModeExtra(); + function setMode(m, e) { + mode = m; + modeSeg.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + e.target.classList.add("active"); + const next = buildModeExtra(); + modeExtra.replaceWith(next); + modeExtra = next; + } + + const out = h("div", { class: "answer" }, h("div", { class: "empty" }, t("pg.empty"))); + const decision = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—")); + + const sendBtn = h("button", { class: "btn primary" }, t("pg.send")); + sendBtn.addEventListener("click", async () => { + const prompt = promptIn.value.trim(); + if (!prompt) return; + const messages = []; + if (sysIn.value.trim()) messages.push({ role: "system", content: sysIn.value.trim() }); + messages.push({ role: "user", content: prompt }); + let model = "cortiq-auto"; + if (mode === "auto") { + if (profileSel.value) model = "cortiq-auto:" + profileSel.value; + } else { + model = modelSel.value; + } + sendBtn.disabled = true; + sendBtn.textContent = t("pg.sending"); + mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending"))); + try { + const r = await api.test({ + model, + messages, + temperature: parseFloat(tempIn.value), + max_tokens: parseInt(maxTokIn.value) || null, + }); + if (r.ok) { + mount(out, r.answer || h("span", { class: "muted" }, "—")); + renderDecision(r); + } else { + mount(out, h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || "")); + mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—")); + } + } catch (e) { + mount(out, h("div", { class: "callout" }, String(e.message || e))); + } + sendBtn.disabled = false; + sendBtn.textContent = t("pg.send"); + }); + + function kv(k, v) { + return h("div", { class: "kv" }, h("span", { class: "k" }, k), h("span", { class: "v" }, v)); + } + function renderDecision(r) { + const c = r.cortiq || {}; + const score = c.complexity?.score ?? 0; + mount( + decision, + h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), + kv(t("pg.task"), c.task_label || "—"), + kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, c.complexity.tier) : "—"), + h( + "div", + { class: "kv", style: "display:block" }, + h("div", { class: "flex" }, h("span", { class: "k grow" }, t("pg.score")), h("span", { class: "v" }, score.toFixed(2))), + h("div", { class: "scorebar" }, h("i", { style: `width:${Math.min(100, score * 100)}%` })) + ), + kv(t("pg.selected"), h("span", { class: "mono" }, c.selected_model || "—")), + kv(t("pg.source"), c.route_source || "—"), + kv(t("pg.tokens"), `${r.usage?.prompt_tokens ?? 0}/${r.usage?.completion_tokens ?? 0}`), + kv(t("pg.cost"), money(c.cost_usd)), + kv(t("pg.latency"), ms(r.latency_ms)), + kv(t("pg.failover"), c.failover ? "yes" : "no") + ); + } + + mount( + root, + h("div", { class: "page-head" }, h("h2", {}, t("pg.title")), h("p", {}, t("pg.subtitle"))), + h( + "div", + { class: "pg" }, + h( + "div", + {}, + h( + "div", + { class: "card" }, + h("label", { class: "field" }, h("span", {}, t("pg.system")), sysIn), + h("label", { class: "field" }, h("span", {}, t("pg.prompt")), promptIn), + h("label", { class: "field" }, h("span", {}, t("pg.mode")), modeSeg), + modeExtra, + h("div", { class: "row" }, h("label", { class: "field" }, h("span", {}, t("pg.temp")), tempIn), h("label", { class: "field" }, h("span", {}, t("pg.maxTokens")), maxTokIn)), + h("div", { class: "flex", style: "justify-content:flex-end" }, sendBtn) + ), + h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out) + ), + decision + ) + ); + return root; +} diff --git a/web/js/views/protocols.js b/web/js/views/protocols.js new file mode 100644 index 0000000..7e94608 --- /dev/null +++ b/web/js/views/protocols.js @@ -0,0 +1,67 @@ +// Protocols — live toggles for inbound API surfaces. +import { h, mount, toast } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { appState } from "../app.js"; + +const ORDER = [ + "openai_chat", + "openai_completions", + "openai_embeddings", + "openai_models", + "anthropic_messages", + "mcp", + "native_passthrough", +]; + +export async function renderProtocols() { + const meta = appState.meta || (await api.meta()); + const impl = meta.protocols_impl || {}; + const state = await api.getProtocols(); + const root = h("div"); + + async function save() { + try { + await api.putProtocols(state); + toast(t("toast.saved"), "good"); + } catch (e) { + toast(String(e.message || e), "bad"); + } + } + + function row(key) { + const implemented = !!impl[key]; + const input = h("input", { + type: "checkbox", + checked: state[key] ? true : null, + disabled: implemented ? null : true, + onChange: (e) => { + state[key] = e.target.checked; + save(); + }, + }); + return h( + "div", + { class: "togglerow" }, + h( + "div", + { class: "meta" }, + h( + "div", + { class: "name" }, + t("protocols." + key), + h("span", { class: "badge " + (implemented ? "ok" : "") }, implemented ? t("protocols.implemented") : t("protocols.planned")) + ), + h("div", { class: "desc" }, t("protocols." + key + ".d")) + ), + h("label", { class: "switch" }, input, h("span", { class: "slider" })) + ); + } + + mount( + root, + h("div", { class: "page-head" }, h("h2", {}, t("protocols.title")), h("p", {}, t("protocols.subtitle"))), + h("div", { class: "card" }, ...ORDER.map(row)) + ); + return root; +} diff --git a/web/js/views/routing.js b/web/js/views/routing.js new file mode 100644 index 0000000..8dd2a0e --- /dev/null +++ b/web/js/views/routing.js @@ -0,0 +1,158 @@ +// Routing — tier editor (ordered model chips), default model, and policy. +import { h, mount, toast } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { appState } from "../app.js"; + +export async function renderRouting() { + const meta = appState.meta || (await api.meta()); + const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]); + const ids = (modelsData.models || []).map((m) => m.id); + + // local editable state — ensure standard tiers exist for a friendly editor + const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {}); + const st = { + tiers, + default: routing.default || ids[0] || "", + policy: routing.policy || { mode: "fixed_table", min_class: {} }, + }; + + const root = h("div"); + + function moveChip(tier, i, dir) { + const arr = st.tiers[tier]; + const ni = i + dir; + if (ni < 0 || ni >= arr.length) return; + [arr[i], arr[ni]] = [arr[ni], arr[i]]; + render(); + } + function removeChip(tier, i) { + st.tiers[tier].splice(i, 1); + render(); + } + function addChip(tier, id) { + if (id && !st.tiers[tier].includes(id)) st.tiers[tier].push(id); + render(); + } + + function tierRow(tier) { + const arr = st.tiers[tier]; + const avail = ids.filter((id) => !arr.includes(id)); + const addSel = h( + "select", + { + style: "width:auto", + onChange: (e) => { + addChip(tier, e.target.value); + }, + }, + h("option", { value: "" }, t("routing.addModel")), + ...avail.map((id) => h("option", { value: id }, id)) + ); + return h( + "div", + { class: "tierrow" }, + h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)), + h( + "div", + { class: "chips" }, + ...arr.map((id, i) => + h( + "div", + { class: "chip" }, + h("span", { class: "ord" }, i + 1), + id, + h("span", { class: "arrow", title: "←", onClick: () => moveChip(tier, i, -1) }, "◄"), + h("span", { class: "arrow", title: "→", onClick: () => moveChip(tier, i, 1) }, "►"), + h("span", { class: "x", onClick: () => removeChip(tier, i) }, "×") + ) + ), + avail.length ? addSel : null + ) + ); + } + + function policyCard() { + const modeSel = h( + "select", + { onChange: (e) => { st.policy.mode = e.target.value; render(); } }, + h("option", { value: "fixed_table", selected: st.policy.mode === "fixed_table" }, t("routing.policy.fixed")), + h("option", { value: "cost_aware", selected: st.policy.mode === "cost_aware" }, t("routing.policy.costAware")) + ); + const children = [ + h("label", { class: "field" }, h("span", {}, t("routing.policy.mode")), modeSel), + ]; + if (st.policy.mode === "cost_aware") { + const maxIn = h("input", { + type: "number", + step: "0.01", + value: st.policy.max_cost_usd_per_request ?? "", + placeholder: "0.50", + onChange: (e) => { st.policy.max_cost_usd_per_request = e.target.value === "" ? null : parseFloat(e.target.value); }, + }); + children.push(h("label", { class: "field" }, h("span", {}, t("routing.policy.maxCost")), maxIn)); + st.policy.min_class = st.policy.min_class || {}; + const rows = Object.keys(st.tiers).map((tier) => { + const sel = h( + "select", + { onChange: (e) => { st.policy.min_class[tier] = e.target.value; } }, + h("option", { value: "" }, "—"), + ...(meta.cost_tiers || []).map((c) => h("option", { value: c, selected: st.policy.min_class[tier] === c }, c)) + ); + return h("label", { class: "field" }, h("span", {}, t("routing.policy.minClass") + " · " + tier), sel); + }); + children.push(h("div", { class: "row" }, ...rows)); + } + return h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("routing.policy"))), ...children); + } + + function render() { + const defSel = h( + "select", + { onChange: (e) => { st.default = e.target.value; } }, + ...ids.map((id) => h("option", { value: id, selected: id === st.default }, id)) + ); + const saveBtn = h("button", { class: "btn primary" }, t("common.save")); + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + try { + await api.putRouting({ tiers: st.tiers, default: st.default, policy: st.policy }); + toast(t("toast.saved"), "good"); + } catch (e) { + toast(String(e.message || e), "bad"); + } + saveBtn.disabled = false; + }); + + mount( + root, + h( + "div", + { class: "page-head" }, + h( + "div", + { class: "flex" }, + h("div", { class: "grow" }, h("h2", {}, t("routing.title")), h("p", {}, t("routing.subtitle"))), + saveBtn + ) + ), + ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null, + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))), + ...Object.keys(st.tiers).map(tierRow) + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("routing.default"))), + h("label", { class: "field" }, h("span", {}, t("routing.defaultHint")), defSel) + ), + policyCard() + ); + } + + render(); + return root; +} diff --git a/web/js/views/settings.js b/web/js/views/settings.js new file mode 100644 index 0000000..d0a5cc0 --- /dev/null +++ b/web/js/views/settings.js @@ -0,0 +1,191 @@ +// Settings — router/route/breaker/log/telemetry/stats + export/import config. +import { h, mount, toast } from "../ui.js"; +import { t } from "../i18n.js"; +import { api } from "../api.js"; +import { appState } from "../app.js"; + +function field(label, control, hint) { + return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null); +} +function check(label, checked) { + const cb = h("input", { type: "checkbox", checked: checked ? true : null }); + return { node: h("label", { class: "check" }, cb, label), cb }; +} + +export async function renderSettings() { + const meta = appState.meta || (await api.meta()); + const s = await api.getSettings(); + const root = h("div"); + + const listenIn = h("input", { value: s.listen || "" }); + // router + const rUrl = h("input", { value: s.router?.url || "" }); + const rKeyEnv = h("input", { value: s.router?.api_key_env || "", placeholder: "CORTIQ_ROUTER_KEY" }); + const rTimeout = h("input", { type: "number", value: s.router?.timeout_ms ?? 800 }); + const rTax = h("input", { value: s.router?.taxonomy_id || "" }); + const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls); + // route + const strategySel = h("select", {}, ...(meta.text_strategies || []).map((x) => h("option", { value: x, selected: x === s.route?.text_strategy }, x))); + const maxChars = h("input", { type: "number", value: s.route?.max_chars ?? 4000 }); + const cacheTtl = h("input", { value: s.route?.cache_ttl || "60s" }); + const profileSel = h("select", {}, ...(meta.profiles || []).map((x) => h("option", { value: x, selected: x === s.route?.profile }, x))); + // breaker + const brThresh = h("input", { type: "number", value: s.breaker?.threshold ?? 5 }); + const brCool = h("input", { value: s.breaker?.cooldown || "30s" }); + // log + const logSel = h("select", {}, ...["error", "warn", "info", "debug", "trace"].map((x) => h("option", { value: x, selected: x === s.log?.level }, x))); + const logBodies = check(t("settings.log.bodies"), s.log?.bodies); + // telemetry / cortiq + const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics); + const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo); + // stats + const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled); + const statsFile = h("input", { value: s.stats?.file || "" }); + const statsRet = h("input", { value: s.stats?.retention || "7d" }); + + const saveBtn = h("button", { class: "btn primary" }, t("common.save")); + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + const patch = { + listen: listenIn.value.trim(), + router: { + url: rUrl.value.trim(), + api_key_env: rKeyEnv.value.trim() || null, + timeout_ms: parseInt(rTimeout.value) || 800, + verify_tls: rVerify.cb.checked, + taxonomy_id: rTax.value.trim() || null, + }, + route: { + text_strategy: strategySel.value, + max_chars: parseInt(maxChars.value) || 4000, + cache_ttl: cacheTtl.value.trim() || "60s", + profile: profileSel.value, + }, + breaker: { threshold: parseInt(brThresh.value) || 5, cooldown: brCool.value.trim() || "30s" }, + log: { level: logSel.value, bodies: logBodies.cb.checked }, + telemetry: { metrics: metricsCb.cb.checked, otlp_endpoint_env: s.telemetry?.otlp_endpoint_env || null }, + cortiq: { echo: echoCb.cb.checked }, + stats: { + enabled: statsEnabled.cb.checked, + file: statsFile.value.trim(), + retention: statsRet.value.trim() || "7d", + ring_size: s.stats?.ring_size ?? 500, + }, + }; + // serde: drop null optionals + if (!patch.router.api_key_env) delete patch.router.api_key_env; + if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id; + if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env; + try { + const r = await api.putSettings(patch); + toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good"); + } catch (e) { + toast(String(e.message || e), "bad"); + } + saveBtn.disabled = false; + }); + + async function exportConfig() { + const cfg = await api.getConfig(); + const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = h("a", { href: url, download: "gateway-config.json" }); + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + const fileIn = h("input", { type: "file", accept: ".json,application/json", style: "display:none" }); + fileIn.addEventListener("change", async () => { + const f = fileIn.files[0]; + if (!f) return; + try { + const cfg = JSON.parse(await f.text()); + await api.putConfig(cfg); + toast(t("toast.imported"), "good"); + } catch (e) { + toast(String(e.message || e), "bad"); + } + fileIn.value = ""; + }); + + mount( + root, + h( + "div", + { class: "page-head" }, + h( + "div", + { class: "flex" }, + h("div", { class: "grow" }, h("h2", {}, t("settings.title")), h("p", {}, t("settings.subtitle"))), + saveBtn + ) + ), + h( + "div", + { class: "grid cols-2" }, + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.router"))), + field(t("settings.router.url"), rUrl), + field(t("settings.router.keyEnv"), rKeyEnv), + h("div", { class: "row" }, field(t("settings.router.timeout"), rTimeout), field(t("settings.router.taxonomy"), rTax)), + rVerify.node + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))), + field(t("settings.route.strategy"), strategySel), + h("div", { class: "row" }, field(t("settings.route.maxChars"), maxChars), field(t("settings.route.cacheTtl"), cacheTtl)), + field(t("settings.route.profile"), profileSel) + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.breaker"))), + h("div", { class: "row" }, field(t("settings.breaker.threshold"), brThresh), field(t("settings.breaker.cooldown"), brCool)) + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.log"))), + field(t("settings.log.level"), logSel), + logBodies.node + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.telemetry"))), + metricsCb.node, + h("div", { class: "divider" }), + h("div", { class: "card-head" }, h("h3", {}, t("settings.cortiq"))), + echoCb.node + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.stats"))), + statsEnabled.node, + field(t("settings.stats.file"), statsFile), + field(t("settings.stats.retention"), statsRet) + ) + ), + h( + "div", + { class: "card" }, + h("div", { class: "card-head" }, h("h3", {}, t("settings.listen"))), + field(t("settings.listen"), listenIn), + h("div", { class: "divider" }), + h( + "div", + { class: "flex wrap" }, + h("button", { class: "btn", onClick: exportConfig }, t("settings.export")), + h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")), + fileIn + ) + ) + ); + return root; +}