ybrault commited on
Commit
6071e61
·
verified ·
1 Parent(s): 4f186a5

Deploy SovAPI Arena Docker Space

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .git/
7
+ .env
8
+ .env.*
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
2
+
3
+ ENV HOME=/home/user \
4
+ PATH=/home/user/.local/bin:$PATH \
5
+ PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1
7
+
8
+ RUN useradd --create-home --uid 1000 user
9
+
10
+ USER user
11
+ WORKDIR $HOME/app
12
+
13
+ COPY --chown=user requirements.txt ./
14
+ RUN python -m pip install --user --no-cache-dir --upgrade pip \
15
+ && python -m pip install --user --no-cache-dir -r requirements.txt
16
+
17
+ COPY --chown=user app.py index.html legal.html terms.html ./
18
+
19
+ EXPOSE 7860
20
+
21
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
22
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=3)"
23
+
24
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips", "*"]
README.md CHANGED
@@ -1,12 +1,141 @@
1
  ---
2
- title: Sovapi Arena
3
  emoji: ⚡
4
- colorFrom: pink
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  license: apache-2.0
9
  short_description: Compare SovAPI sovereign inference with leading AI models.
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SovAPI Arena
3
  emoji: ⚡
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  license: apache-2.0
9
  short_description: Compare SovAPI sovereign inference with leading AI models.
10
  ---
11
 
12
+ # SovAPI Arena page
13
+
14
+ Forkable HTML Space for the public Arena comparison.
15
+
16
+ Active client direction as of 2026-07-07: align this Space with the Manus visual
17
+ reference
18
+ `https://predeploy-54834d22-sovapi-bfbjw6da-2xnytaovact3rest.manus.space`, launch without
19
+ waiting for DeepSeek, and position the demo around Qwen3-Coder plus Gemma 4. Legal
20
+ notices and terms are now active long-form pages. Legal includes a contextual GDPR notice
21
+ covering the Arena's implemented data flow and user rights; final legal approval remains
22
+ owned by SovInfra.
23
+
24
+ Local update on 2026-07-07: `index.html` has been visually aligned with the Manus
25
+ reference while keeping the existing safe `/v1/challenge` contract.
26
+
27
+ Client update on 2026-07-09: a newer English Manus reference exists at
28
+ `https://predeploy-5553c316-sovapi-bfbjw6da-okbabhpaajvgdv9t.manus.space`. It includes a
29
+ transcription/vocal tab, but Thierry explicitly deferred transcription. The Monday
30
+ 2026-07-13 target is only the core Arena/SovAPI path with a sovereign key and
31
+ `https://api.sovinfra.ai/v1`.
32
+
33
+ Implementation update on 2026-07-09: the Space UI is aligned to the English Arena copy,
34
+ adds the `reasoning` terrain, keeps the transcription tab absent, and degrades gracefully
35
+ when the challenger route is not yet configured. The SovAPI side remains executable on
36
+ its own.
37
+
38
+ Historical runtime note on 2026-07-09: only the Code terrain was live and OpenRouter was
39
+ not provisioned. This is superseded: the server-side challenger and four approved terrains
40
+ are deployed on Shadow. Agent existed from 2026-07-12 to 2026-07-16 and was then retired
41
+ at Thierry's request because it duplicated Structure without executing a tool.
42
+
43
+ Client validation on 2026-07-07: the Space fixed price is accepted, `/v1/challenge` is
44
+ included in that scope, OpenRouter stays server-side only, and the reasoning terrain
45
+ should run through modeless `sovapi` routing without a DeepSeek dependency.
46
+
47
+ `app.py` serves:
48
+
49
+ - a locked CTA when `SOVINFRA_API_KEY` is absent;
50
+ - the HTML challenge page when the fork has `SOVINFRA_API_KEY` configured.
51
+
52
+ The key is never embedded in the HTML response.
53
+
54
+ The browser never receives provider keys. It calls:
55
+
56
+ - `GET /v1/challenge/models` for the challenger model selector.
57
+ - `POST /v1/challenge` twice per duel, once with `side=sovapi` and once with
58
+ `side=challenger`.
59
+
60
+ The active terrain order is `code`, `audit`, `json`, `reasoning`. The schema deliberately
61
+ rejects legacy `agent` requests.
62
+ The live card shows the internal model selected by SovAPI and separate total-latency and
63
+ TTFT ratios. The share PNG keeps those measured ratios, uses the fixed
64
+ `SOVINFRA | SOVEREIGN` brand banner, keeps the left column title as `SovAPI`, and labels
65
+ the sovereign model row with the active product lane (`SovAPI Code`, `SovAPI Analysis`,
66
+ `SovAPI Structure`, or `SovAPI Reasoning`). Challenger attribution remains visible and
67
+ unchanged.
68
+
69
+ Arena runtime update on 2026-07-16: the live sovereign column uses the selected lane label
70
+ (`SOVAPI CODE`, `SOVAPI ANALYSIS`, `SOVAPI STRUCTURE`, or `SOVAPI REASONING`). In the
71
+ generated share artifact, that dynamic label appears only in the sovereign `MODEL` row
72
+ while the brand banner and column title remain fixed. The
73
+ challenger title and model badge remain unchanged. The public stack cards now describe
74
+ `Gemma 4 31B FP8` as `Text, vision and extraction` and `BGE-M3 + ChromaDB` as
75
+ `Long-term memory and search`. LiteLLM keeps the stable public alias `gemma-4-31b` but
76
+ routes it to GPUStack model `gemma-4-31B-it-fp8` on H200. The challenge relay uses the
77
+ gateway stream timeout budget and emits `challenge.error` so the browser can retry an
78
+ interrupted upstream SSE instead of retaining a partial duel.
79
+
80
+ Challenger quota update on 2026-07-16: every click starts a fresh duel id with an isolated
81
+ 30,000-token budget. Retries retain that duel id and are not recounted as new duels. A
82
+ Redis-backed source-IP ceiling allows 100 unique duels per rolling 24-hour window; the
83
+ global 500-duel/24-hour guard remains active. HTTP 429 is never retried by the browser.
84
+ The page distinguishes a per-duel token limit, a source-IP daily limit, global daily
85
+ capacity, provider auth/credit/rate limit, and transient upstream interruption. Never
86
+ replace these states with a fixed missing-key message. The live release gate must include
87
+ real Challenger probes through `scripts/acceptance_arena_stability.py --challenger-runs N`
88
+ and verify that each probe receives a fresh 30,000-token budget.
89
+
90
+ This contract is active on Shadow. The production gate passes controlled duel/IP 429
91
+ cases, exact reconciliation from provider `usage.input_tokens` and `usage.output_tokens`,
92
+ 12/12 sovereign routes, 3/3 real isolated Challenger calls, and two consecutive nominal
93
+ duels in WebKit iPhone and Chrome with no console or page error. See
94
+ `docs/recette-arena-quota-par-duel-2026-07-16.md`.
95
+
96
+ Until the Option B DNS cutover is complete, the hosted Arena uses
97
+ `https://api.sovinfra.cloud` as its browser API origin because `api.sovinfra.ai` still
98
+ resolves to Vercel. The API ACCESS examples intentionally retain the final product URL
99
+ `https://api.sovinfra.ai/v1`. The fork wrapper uses the same `.cloud` default unless
100
+ `SOVAPI_BASE_URL` is set explicitly.
101
+
102
+ The API route injects `SOVAPI_CHALLENGE_SOVAPI_API_KEY` and
103
+ `SOVAPI_CHALLENGE_OPENROUTER_API_KEY` server-side. The gateway atomically reserves a
104
+ worst-case OpenRouter budget before the call, then reconciles it from the provider's real
105
+ usage. The caps are 30,000 tokens per duel, 100 unique duels per source IP over 24 hours,
106
+ and 500 unique duels globally over 24 hours.
107
+
108
+ The 2026-07-07 Manus mockup shows an optional visitor OpenRouter key field, but the
109
+ client validated the safer server-side-only implementation. Do not add a browser-visible
110
+ OpenRouter key input.
111
+
112
+ ## Local preview
113
+
114
+ Open `index.html` directly in a browser for static review. Live execution requires a
115
+ running gateway exposing `/v1/challenge` and permissive CORS for the page origin.
116
+
117
+ Run as a local Space wrapper:
118
+
119
+ ```powershell
120
+ $env:SOVINFRA_API_KEY = "local-presence-only"
121
+ uvicorn spaces.sovapi_challenge.app:app --reload
122
+ ```
123
+
124
+ ## Required gateway env
125
+
126
+ - `SOVAPI_CHALLENGE_SOVAPI_BASE_URL`: SovAPI base URL, default `http://127.0.0.1:8000`
127
+ - `SOVAPI_CHALLENGE_SOVAPI_API_KEY`: controlled demo key for SovAPI side
128
+ - `SOVAPI_CHALLENGE_OPENROUTER_BASE_URL`: default `https://openrouter.ai/api/v1`
129
+ - `SOVAPI_CHALLENGE_OPENROUTER_API_KEY`: server-side OpenRouter key
130
+ - `SOVAPI_CHALLENGE_TOKEN_SESSION_LIMIT`: legacy env name, now the per-duel cap; default `30000`
131
+ - `SOVAPI_CHALLENGE_CLIENT_REQUEST_LIMIT`: unique duels per source IP; default `100`
132
+ - `SOVAPI_CHALLENGE_CLIENT_WINDOW_SECONDS`: source-IP window; default `86400`
133
+ - `SOVAPI_CHALLENGE_GLOBAL_REQUEST_LIMIT`: global unique-duel cap; default `500`
134
+ - `SOVAPI_CHALLENGE_GLOBAL_WINDOW_SECONDS`: global window; default `86400`
135
+ - `SOVAPI_CHALLENGE_CATALOG_MODELS`: comma-separated fallback model ids
136
+
137
+ ## Metrics
138
+
139
+ Displayed GPU/network/cost/token metrics are read from upstream response chunks when
140
+ present. Missing upstream values are rendered as unavailable; the UI must not ship
141
+ hardcoded performance numbers.
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Autonomous challenge page assets for SovAPI."""
__pycache__/__init__.cpython-312.pyc ADDED
Binary file (240 Bytes). View file
 
__pycache__/app.cpython-312.pyc ADDED
Binary file (5.08 kB). View file
 
app.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from html import escape
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.responses import HTMLResponse
9
+
10
+ INDEX_PATH = Path(__file__).with_name("index.html")
11
+ LEGAL_PATH = Path(__file__).with_name("legal.html")
12
+ TERMS_PATH = Path(__file__).with_name("terms.html")
13
+ DEFAULT_API_BASE = "https://api.sovinfra.cloud"
14
+ DEFAULT_SIGNUP_URL = "https://app.sovinfra.ai"
15
+
16
+ app = FastAPI(title="SovAPI Arena Space")
17
+
18
+
19
+ @app.get("/health")
20
+ async def health() -> dict[str, str]:
21
+ """Expose a lightweight container health endpoint."""
22
+ return {"status": "ok"}
23
+
24
+
25
+ @app.get("/", response_class=HTMLResponse)
26
+ async def index() -> HTMLResponse:
27
+ """Serve the forkable HTML challenge Space."""
28
+ api_key = os.getenv("SOVINFRA_API_KEY", "").strip()
29
+ if not api_key:
30
+ return HTMLResponse(locked_html(signup_url()))
31
+ return HTMLResponse(render_index(api_base()))
32
+
33
+
34
+ @app.get("/legal", response_class=HTMLResponse)
35
+ async def legal() -> HTMLResponse:
36
+ """Serve the legal notice page."""
37
+ return HTMLResponse(LEGAL_PATH.read_text(encoding="utf-8"))
38
+
39
+
40
+ @app.get("/terms", response_class=HTMLResponse)
41
+ async def terms() -> HTMLResponse:
42
+ """Serve the terms page."""
43
+ return HTMLResponse(TERMS_PATH.read_text(encoding="utf-8"))
44
+
45
+
46
+ def render_index(base_url: str) -> str:
47
+ """Render the static challenge page with public runtime config only."""
48
+ html = INDEX_PATH.read_text(encoding="utf-8")
49
+ public_config = f'<script>window.SOVAPI_API_BASE = "{escape(base_url)}";</script>'
50
+ return html.replace("</head>", f"{public_config}</head>")
51
+
52
+
53
+ def api_base() -> str:
54
+ """Return the public SovAPI base URL used by browser calls."""
55
+ return os.getenv("SOVAPI_BASE_URL", DEFAULT_API_BASE).rstrip("/") or DEFAULT_API_BASE
56
+
57
+
58
+ def signup_url() -> str:
59
+ """Return the SovInfra OAuth portal URL used by locked forks."""
60
+ return os.getenv("SOVINFRA_SIGNUP_URL", DEFAULT_SIGNUP_URL).strip() or DEFAULT_SIGNUP_URL
61
+
62
+
63
+ def locked_html(signup: str) -> str:
64
+ """Render the locked state shown when a fork has no SovAPI key."""
65
+ safe_signup = escape(signup)
66
+ return f"""<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
71
+ <title>SovAPI Arena locked</title>
72
+ <style>
73
+ body {{
74
+ background: #071728;
75
+ color: #f7fafc;
76
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
77
+ margin: 0;
78
+ min-height: 100vh;
79
+ display: grid;
80
+ place-items: center;
81
+ }}
82
+ main {{
83
+ border: 1px solid #244460;
84
+ border-radius: 8px;
85
+ max-width: 640px;
86
+ padding: 28px;
87
+ }}
88
+ a {{
89
+ background: #ff7a1a;
90
+ border-radius: 6px;
91
+ color: #111827;
92
+ display: inline-block;
93
+ font-weight: 700;
94
+ margin-top: 16px;
95
+ padding: 12px 16px;
96
+ text-decoration: none;
97
+ }}
98
+ code {{ color: #ffb578; }}
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <main>
103
+ <h1>SovAPI key required</h1>
104
+ <p>
105
+ This Arena fork does not have a SovAPI key yet. Create a trial key from the
106
+ SovInfra portal, then add it to the Hugging Face secrets as
107
+ <code>SOVINFRA_API_KEY</code>.
108
+ </p>
109
+ <a href="{safe_signup}" rel="noopener noreferrer">Create my SovAPI key</a>
110
+ </main>
111
+ </body>
112
+ </html>"""
index.html ADDED
@@ -0,0 +1,2122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SovAPI Arena</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1620'/%3E%3Ctext x='16' y='22' font-size='18' text-anchor='middle' fill='%23ff7a1a' font-family='Arial'%3ES%3C/text%3E%3C/svg%3E" />
8
+ <script>
9
+ try {
10
+ const savedTheme = localStorage.getItem("sovapi.theme");
11
+ if (savedTheme === "light" || savedTheme === "dark") {
12
+ document.documentElement.dataset.theme = savedTheme;
13
+ }
14
+ } catch (_error) {
15
+ // Storage can be unavailable in private or embedded browser contexts.
16
+ }
17
+ </script>
18
+ <style>
19
+ :root {
20
+ color-scheme: light;
21
+ --paper: #f3eadc;
22
+ --paper-deep: #e7d8c3;
23
+ --panel: #fff8ec;
24
+ --panel-strong: #f7e5ce;
25
+ --ink: #15110d;
26
+ --muted: #6f6255;
27
+ --line: #201812;
28
+ --line-soft: rgba(32, 24, 18, 0.22);
29
+ --orange: #ff5a1f;
30
+ --orange-dark: #c83a10;
31
+ --green: #26744b;
32
+ --danger: #bb2c2c;
33
+ --input: #fffdf7;
34
+ --shadow: 8px 8px 0 rgba(21, 17, 13, 0.16);
35
+ }
36
+
37
+ html[data-theme="dark"] {
38
+ color-scheme: dark;
39
+ --paper: #15110d;
40
+ --paper-deep: #231a14;
41
+ --panel: #201811;
42
+ --panel-strong: #2d2118;
43
+ --ink: #fff8ec;
44
+ --muted: #c5b39f;
45
+ --line: #f3eadc;
46
+ --line-soft: rgba(243, 234, 220, 0.2);
47
+ --orange: #ff7a2d;
48
+ --orange-dark: #ff5a1f;
49
+ --green: #64c18a;
50
+ --danger: #ff7777;
51
+ --input: #17120d;
52
+ --shadow: 8px 8px 0 rgba(0, 0, 0, 0.38);
53
+ }
54
+
55
+ * {
56
+ box-sizing: border-box;
57
+ }
58
+
59
+ html {
60
+ min-width: 320px;
61
+ }
62
+
63
+ body {
64
+ background:
65
+ linear-gradient(90deg, var(--line-soft) 1px, transparent 1px) 0 0 / 42px 42px,
66
+ linear-gradient(var(--line-soft) 1px, transparent 1px) 0 0 / 42px 42px,
67
+ var(--paper);
68
+ color: var(--ink);
69
+ font-family:
70
+ "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
71
+ "Segoe UI", sans-serif;
72
+ font-size: 16px;
73
+ line-height: 1.45;
74
+ margin: 0;
75
+ }
76
+
77
+ button,
78
+ input,
79
+ select,
80
+ textarea {
81
+ font: inherit;
82
+ }
83
+
84
+ a {
85
+ color: inherit;
86
+ }
87
+
88
+ button:focus-visible,
89
+ a:focus-visible,
90
+ select:focus-visible,
91
+ textarea:focus-visible {
92
+ outline: 3px solid var(--orange);
93
+ outline-offset: 2px;
94
+ }
95
+
96
+ .page {
97
+ margin: 0 auto;
98
+ max-width: 1320px;
99
+ padding: 18px;
100
+ }
101
+
102
+ .topbar {
103
+ align-items: center;
104
+ border: 2px solid var(--line);
105
+ background: var(--panel);
106
+ display: flex;
107
+ gap: 16px;
108
+ justify-content: space-between;
109
+ min-height: 68px;
110
+ padding: 12px 14px;
111
+ }
112
+
113
+ .brand-mark {
114
+ align-items: center;
115
+ display: flex;
116
+ gap: 12px;
117
+ min-width: 0;
118
+ }
119
+
120
+ .brand-badge {
121
+ align-items: center;
122
+ background: var(--ink);
123
+ color: var(--paper);
124
+ display: inline-flex;
125
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
126
+ font-size: 12px;
127
+ font-weight: 800;
128
+ height: 38px;
129
+ justify-content: center;
130
+ letter-spacing: 0;
131
+ min-width: 38px;
132
+ }
133
+
134
+ .brand-copy {
135
+ display: grid;
136
+ gap: 2px;
137
+ }
138
+
139
+ .brand-copy strong {
140
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
141
+ font-size: 13px;
142
+ letter-spacing: 0.04em;
143
+ text-transform: uppercase;
144
+ }
145
+
146
+ .brand-copy span {
147
+ color: var(--muted);
148
+ font-size: 13px;
149
+ }
150
+
151
+ .topnav {
152
+ align-items: center;
153
+ display: flex;
154
+ flex-wrap: wrap;
155
+ gap: 8px;
156
+ justify-content: flex-end;
157
+ }
158
+
159
+ .nav-link,
160
+ .button,
161
+ .tab,
162
+ .terrain-button {
163
+ border: 2px solid var(--line);
164
+ cursor: pointer;
165
+ min-height: 42px;
166
+ }
167
+
168
+ .nav-link {
169
+ align-items: center;
170
+ background: transparent;
171
+ display: inline-flex;
172
+ font-size: 13px;
173
+ font-weight: 800;
174
+ justify-content: center;
175
+ padding: 0 12px;
176
+ text-decoration: none;
177
+ text-transform: uppercase;
178
+ }
179
+
180
+ .nav-link:hover,
181
+ .button.secondary:hover,
182
+ .terrain-button:hover {
183
+ background: var(--panel-strong);
184
+ }
185
+
186
+ .button {
187
+ align-items: center;
188
+ background: var(--orange);
189
+ color: #120b07;
190
+ display: inline-flex;
191
+ font-weight: 900;
192
+ justify-content: center;
193
+ min-width: 0;
194
+ padding: 0 16px;
195
+ text-transform: uppercase;
196
+ }
197
+
198
+ .step .button {
199
+ margin-top: 12px;
200
+ }
201
+
202
+ .button.secondary {
203
+ background: transparent;
204
+ color: var(--ink);
205
+ }
206
+
207
+ .button:disabled,
208
+ select:disabled {
209
+ cursor: not-allowed;
210
+ opacity: 0.55;
211
+ }
212
+
213
+ .hero {
214
+ display: grid;
215
+ gap: 20px;
216
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
217
+ padding: 34px 0 18px;
218
+ }
219
+
220
+ .hero-copy {
221
+ min-width: 0;
222
+ }
223
+
224
+ .eyebrow-row {
225
+ align-items: center;
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 8px;
229
+ margin-bottom: 16px;
230
+ }
231
+
232
+ .eyebrow {
233
+ border: 2px solid var(--line);
234
+ background: var(--panel);
235
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
236
+ font-size: 12px;
237
+ font-weight: 800;
238
+ padding: 7px 10px;
239
+ text-transform: uppercase;
240
+ }
241
+
242
+ .hero h1 {
243
+ font-family:
244
+ "Arial Black", Impact, "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
245
+ font-size: clamp(58px, 12vw, 164px);
246
+ letter-spacing: 0;
247
+ line-height: 0.86;
248
+ margin: 0;
249
+ text-transform: uppercase;
250
+ }
251
+
252
+ .hero-lede {
253
+ border-left: 8px solid var(--orange);
254
+ color: var(--muted);
255
+ font-size: clamp(19px, 2.4vw, 30px);
256
+ font-weight: 700;
257
+ line-height: 1.15;
258
+ margin: 24px 0 0;
259
+ max-width: 900px;
260
+ padding-left: 18px;
261
+ }
262
+
263
+ .proof-grid {
264
+ display: grid;
265
+ gap: 8px;
266
+ grid-template-columns: repeat(2, minmax(0, 1fr));
267
+ margin-top: 24px;
268
+ max-width: 820px;
269
+ }
270
+
271
+ .proof-chip {
272
+ border: 2px solid var(--line);
273
+ background: var(--panel);
274
+ min-height: 58px;
275
+ padding: 10px 12px;
276
+ }
277
+
278
+ .proof-chip strong {
279
+ display: block;
280
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
281
+ font-size: 12px;
282
+ text-transform: uppercase;
283
+ }
284
+
285
+ .proof-chip span {
286
+ color: var(--muted);
287
+ display: block;
288
+ font-size: 13px;
289
+ margin-top: 2px;
290
+ }
291
+
292
+ .status-panel {
293
+ align-self: end;
294
+ background: var(--ink);
295
+ border: 2px solid var(--line);
296
+ color: var(--paper);
297
+ min-height: 260px;
298
+ padding: 18px;
299
+ position: relative;
300
+ }
301
+
302
+ html[data-theme="dark"] .status-panel {
303
+ background: var(--panel);
304
+ color: var(--ink);
305
+ }
306
+
307
+ .status-panel:before {
308
+ border: 2px solid var(--orange);
309
+ bottom: 12px;
310
+ content: "";
311
+ left: 12px;
312
+ pointer-events: none;
313
+ position: absolute;
314
+ right: 12px;
315
+ top: 12px;
316
+ }
317
+
318
+ .status-panel h2 {
319
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
320
+ font-size: 13px;
321
+ letter-spacing: 0.04em;
322
+ margin: 0 0 18px;
323
+ text-transform: uppercase;
324
+ }
325
+
326
+ .status-metric {
327
+ border-top: 1px solid currentColor;
328
+ display: flex;
329
+ gap: 14px;
330
+ justify-content: space-between;
331
+ padding: 12px 0;
332
+ position: relative;
333
+ z-index: 1;
334
+ }
335
+
336
+ .status-metric span {
337
+ color: inherit;
338
+ opacity: 0.72;
339
+ }
340
+
341
+ .status-link {
342
+ display: inline-flex;
343
+ font-weight: 800;
344
+ margin-top: 14px;
345
+ position: relative;
346
+ text-transform: uppercase;
347
+ z-index: 1;
348
+ }
349
+
350
+ .status {
351
+ align-items: center;
352
+ background: var(--panel);
353
+ border: 2px solid var(--line);
354
+ display: flex;
355
+ gap: 14px;
356
+ justify-content: space-between;
357
+ margin: 0 0 14px;
358
+ padding: 12px 14px;
359
+ }
360
+
361
+ .status strong {
362
+ color: var(--orange-dark);
363
+ }
364
+
365
+ .status span,
366
+ .muted {
367
+ color: var(--muted);
368
+ }
369
+
370
+ .tabs {
371
+ background: var(--line);
372
+ border: 2px solid var(--line);
373
+ display: flex;
374
+ gap: 2px;
375
+ margin: 18px 0;
376
+ max-width: 100%;
377
+ overflow-x: auto;
378
+ padding: 2px;
379
+ scrollbar-width: thin;
380
+ }
381
+
382
+ .tab {
383
+ background: var(--panel);
384
+ color: var(--muted);
385
+ flex: 0 0 auto;
386
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
387
+ font-size: 12px;
388
+ font-weight: 800;
389
+ padding: 0 14px;
390
+ text-transform: uppercase;
391
+ }
392
+
393
+ .tab[aria-selected="true"] {
394
+ background: var(--orange);
395
+ color: #120b07;
396
+ }
397
+
398
+ .tab[disabled] {
399
+ cursor: default;
400
+ opacity: 0.7;
401
+ }
402
+
403
+ .view[hidden] {
404
+ display: none;
405
+ }
406
+
407
+ .challenge-grid {
408
+ display: grid;
409
+ gap: 18px;
410
+ grid-template-columns: minmax(290px, 410px) minmax(0, 1fr);
411
+ }
412
+
413
+ .challenge-grid > *,
414
+ .duel > * {
415
+ min-width: 0;
416
+ }
417
+
418
+ .panel,
419
+ .metric,
420
+ .step,
421
+ .code-block {
422
+ background: var(--panel);
423
+ border: 2px solid var(--line);
424
+ }
425
+
426
+ .panel {
427
+ padding: 16px;
428
+ }
429
+
430
+ .control-panel {
431
+ box-shadow: var(--shadow);
432
+ }
433
+
434
+ .section-kicker {
435
+ color: var(--muted);
436
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
437
+ font-size: 12px;
438
+ font-weight: 800;
439
+ margin: 0 0 6px;
440
+ text-transform: uppercase;
441
+ }
442
+
443
+ .section-title {
444
+ font-size: 28px;
445
+ line-height: 1;
446
+ margin: 0 0 14px;
447
+ text-transform: uppercase;
448
+ }
449
+
450
+ .field {
451
+ display: grid;
452
+ gap: 8px;
453
+ margin-bottom: 14px;
454
+ }
455
+
456
+ label {
457
+ color: var(--muted);
458
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
459
+ font-size: 12px;
460
+ font-weight: 800;
461
+ text-transform: uppercase;
462
+ }
463
+
464
+ select,
465
+ textarea {
466
+ background: var(--input);
467
+ border: 2px solid var(--line);
468
+ color: var(--ink);
469
+ min-height: 46px;
470
+ padding: 11px 12px;
471
+ width: 100%;
472
+ }
473
+
474
+ textarea {
475
+ min-height: 190px;
476
+ resize: vertical;
477
+ }
478
+
479
+ .terrain-select {
480
+ display: none;
481
+ }
482
+
483
+ .terrain-grid {
484
+ display: grid;
485
+ gap: 8px;
486
+ grid-template-columns: 1fr;
487
+ }
488
+
489
+ .terrain-button {
490
+ background: var(--input);
491
+ color: var(--ink);
492
+ display: grid;
493
+ gap: 2px;
494
+ min-height: 64px;
495
+ padding: 10px 12px;
496
+ text-align: left;
497
+ }
498
+
499
+ .terrain-button strong {
500
+ font-size: 15px;
501
+ text-transform: uppercase;
502
+ }
503
+
504
+ .terrain-button span {
505
+ color: var(--muted);
506
+ font-size: 13px;
507
+ }
508
+
509
+ .terrain-button[aria-pressed="true"] {
510
+ background: var(--orange);
511
+ color: #120b07;
512
+ }
513
+
514
+ .terrain-button[aria-pressed="true"] span {
515
+ color: #3d1b0d;
516
+ }
517
+
518
+ .duel {
519
+ display: grid;
520
+ gap: 16px;
521
+ grid-template-columns: repeat(2, minmax(0, 1fr));
522
+ }
523
+
524
+ .duel-panel {
525
+ display: grid;
526
+ grid-template-rows: auto minmax(260px, 1fr) auto;
527
+ min-height: 100%;
528
+ }
529
+
530
+ .duel-panel > * {
531
+ max-width: 100%;
532
+ min-width: 0;
533
+ }
534
+
535
+ .output {
536
+ background: var(--input);
537
+ border: 2px solid var(--line);
538
+ min-height: 260px;
539
+ overflow: auto;
540
+ padding: 13px;
541
+ white-space: pre-wrap;
542
+ word-break: break-word;
543
+ }
544
+
545
+ .output:empty:before {
546
+ color: var(--muted);
547
+ content: "Waiting for stream.";
548
+ }
549
+
550
+ .panel-title {
551
+ align-items: flex-start;
552
+ display: flex;
553
+ gap: 10px;
554
+ justify-content: space-between;
555
+ margin-bottom: 12px;
556
+ }
557
+
558
+ .panel-title h2 {
559
+ font-size: 24px;
560
+ line-height: 1;
561
+ margin: 0;
562
+ text-transform: uppercase;
563
+ }
564
+
565
+ .badge {
566
+ background: var(--ink);
567
+ border: 2px solid var(--line);
568
+ color: var(--paper);
569
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
570
+ font-size: 12px;
571
+ font-weight: 800;
572
+ max-width: 100%;
573
+ overflow: hidden;
574
+ padding: 5px 8px;
575
+ text-overflow: ellipsis;
576
+ text-transform: uppercase;
577
+ white-space: nowrap;
578
+ }
579
+
580
+ html[data-theme="dark"] .badge {
581
+ background: var(--orange);
582
+ color: #120b07;
583
+ }
584
+
585
+ .metrics {
586
+ display: grid;
587
+ gap: 8px;
588
+ grid-template-columns: repeat(2, minmax(0, 1fr));
589
+ margin-top: 12px;
590
+ }
591
+
592
+ .metric {
593
+ min-height: 62px;
594
+ padding: 9px;
595
+ }
596
+
597
+ .metric span {
598
+ color: var(--muted);
599
+ display: block;
600
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
601
+ font-size: 11px;
602
+ font-weight: 800;
603
+ text-transform: uppercase;
604
+ }
605
+
606
+ .metric strong {
607
+ display: block;
608
+ margin-top: 3px;
609
+ overflow-wrap: anywhere;
610
+ }
611
+
612
+ .verdict {
613
+ background: var(--panel);
614
+ border: 2px solid var(--line);
615
+ border-left: 12px solid var(--orange);
616
+ display: grid;
617
+ font-weight: 800;
618
+ gap: 4px;
619
+ margin-top: 16px;
620
+ padding: 12px 14px;
621
+ }
622
+
623
+ .verdict strong {
624
+ font-size: 18px;
625
+ }
626
+
627
+ .verdict span {
628
+ color: var(--orange-dark);
629
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
630
+ font-size: 14px;
631
+ text-transform: uppercase;
632
+ }
633
+
634
+ .verdict small {
635
+ color: var(--muted);
636
+ font-weight: 700;
637
+ }
638
+
639
+ .live-note,
640
+ .reference-note {
641
+ color: var(--muted);
642
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
643
+ font-size: 12px;
644
+ font-weight: 800;
645
+ letter-spacing: 0;
646
+ margin: 8px 0 0;
647
+ text-transform: uppercase;
648
+ }
649
+
650
+ .live-note strong,
651
+ .reference-note strong {
652
+ color: var(--ink);
653
+ }
654
+
655
+ .actions {
656
+ display: flex;
657
+ flex-wrap: wrap;
658
+ gap: 10px;
659
+ margin-top: 14px;
660
+ }
661
+
662
+ .benchmark-grid {
663
+ display: grid;
664
+ gap: 16px;
665
+ grid-template-columns: repeat(3, minmax(0, 1fr));
666
+ }
667
+
668
+ .benchmark-card {
669
+ min-height: 190px;
670
+ }
671
+
672
+ .benchmark-card strong {
673
+ display: block;
674
+ font-size: clamp(34px, 7vw, 78px);
675
+ letter-spacing: 0;
676
+ line-height: 0.9;
677
+ margin: 6px 0 10px;
678
+ text-transform: uppercase;
679
+ }
680
+
681
+ .stat-strip {
682
+ background: var(--orange);
683
+ border: 2px solid var(--line);
684
+ color: #120b07;
685
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
686
+ font-size: clamp(22px, 5vw, 54px);
687
+ font-weight: 900;
688
+ line-height: 0.95;
689
+ margin-bottom: 16px;
690
+ padding: 18px;
691
+ text-transform: uppercase;
692
+ }
693
+
694
+ .api-grid {
695
+ display: grid;
696
+ gap: 16px;
697
+ grid-template-columns: repeat(3, minmax(0, 1fr));
698
+ }
699
+
700
+ .step,
701
+ .code-block {
702
+ padding: 16px;
703
+ }
704
+
705
+ .step h2,
706
+ .panel h2 {
707
+ line-height: 1.1;
708
+ margin-top: 0;
709
+ }
710
+
711
+ pre {
712
+ margin: 0;
713
+ overflow-x: auto;
714
+ white-space: pre;
715
+ }
716
+
717
+ code {
718
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
719
+ font-size: 13px;
720
+ }
721
+
722
+ table {
723
+ border-collapse: collapse;
724
+ width: 100%;
725
+ }
726
+
727
+ .table-scroll {
728
+ margin-top: 10px;
729
+ max-width: 100%;
730
+ overflow-x: auto;
731
+ -webkit-overflow-scrolling: touch;
732
+ }
733
+
734
+ .table-scroll table {
735
+ min-width: 640px;
736
+ }
737
+
738
+ td,
739
+ th {
740
+ border-bottom: 2px solid var(--line);
741
+ padding: 10px;
742
+ text-align: left;
743
+ vertical-align: top;
744
+ }
745
+
746
+ .footer {
747
+ align-items: center;
748
+ border-top: 2px solid var(--line);
749
+ color: var(--muted);
750
+ display: flex;
751
+ flex-wrap: wrap;
752
+ gap: 12px;
753
+ justify-content: space-between;
754
+ margin-top: 28px;
755
+ padding: 18px 0 0;
756
+ }
757
+
758
+ .footer-links {
759
+ display: flex;
760
+ flex-wrap: wrap;
761
+ gap: 12px;
762
+ }
763
+
764
+ .footer a {
765
+ font-weight: 800;
766
+ }
767
+
768
+ @media (max-width: 980px) {
769
+ .hero,
770
+ .challenge-grid,
771
+ .duel,
772
+ .api-grid,
773
+ .benchmark-grid {
774
+ grid-template-columns: 1fr;
775
+ }
776
+
777
+ .status-panel {
778
+ min-height: 220px;
779
+ }
780
+ }
781
+
782
+ @media (max-width: 700px) {
783
+ .page {
784
+ padding: 12px;
785
+ }
786
+
787
+ .topbar,
788
+ .status {
789
+ align-items: flex-start;
790
+ flex-direction: column;
791
+ }
792
+
793
+ .topnav {
794
+ justify-content: flex-start;
795
+ width: 100%;
796
+ }
797
+
798
+ .nav-link,
799
+ .topnav .button {
800
+ flex: 1 1 130px;
801
+ }
802
+
803
+ .hero {
804
+ padding-top: 24px;
805
+ }
806
+
807
+ .hero h1 {
808
+ font-size: clamp(54px, 23vw, 96px);
809
+ }
810
+
811
+ .proof-grid,
812
+ .metrics {
813
+ grid-template-columns: 1fr;
814
+ }
815
+
816
+ .tabs {
817
+ flex-wrap: wrap;
818
+ overflow: visible;
819
+ }
820
+
821
+ .tab {
822
+ flex: 1 1 calc(50% - 2px);
823
+ }
824
+
825
+ .panel {
826
+ padding: 12px;
827
+ }
828
+
829
+ .table-scroll {
830
+ border-left: 2px solid var(--line);
831
+ border-right: 2px solid var(--line);
832
+ margin-left: -2px;
833
+ margin-right: -2px;
834
+ padding: 0 8px 6px;
835
+ }
836
+
837
+ textarea {
838
+ min-height: 170px;
839
+ }
840
+ }
841
+
842
+ @media (prefers-reduced-motion: no-preference) {
843
+ .button,
844
+ .nav-link,
845
+ .tab,
846
+ .terrain-button {
847
+ transition:
848
+ background 140ms ease,
849
+ color 140ms ease,
850
+ transform 140ms ease;
851
+ }
852
+
853
+ .button:hover,
854
+ .nav-link:hover,
855
+ .tab:not([disabled]):hover,
856
+ .terrain-button:hover {
857
+ transform: translate(-1px, -1px);
858
+ }
859
+ }
860
+ </style>
861
+ </head>
862
+ <body>
863
+ <main class="page">
864
+ <header class="topbar">
865
+ <div class="brand-mark">
866
+ <span class="brand-badge">S</span>
867
+ <div class="brand-copy">
868
+ <strong>SOVINFRA | SOVEREIGN</strong>
869
+ <span>European modeless inference</span>
870
+ </div>
871
+ </div>
872
+ <nav class="topnav" aria-label="Navigation">
873
+ <button class="nav-link" data-nav-tab="api" type="button">Developers</button>
874
+ <a class="nav-link" href="https://codeberg.org/sovinfra" rel="noopener noreferrer" target="_blank">
875
+ Codeberg
876
+ </a>
877
+ <a class="nav-link" data-console-link href="https://app.sovinfra.ai" rel="noopener noreferrer" target="_blank">
878
+ Console
879
+ </a>
880
+ <button class="button secondary" id="themeButton" type="button">Dark mode</button>
881
+ </nav>
882
+ </header>
883
+
884
+ <section class="hero" aria-labelledby="hero-title">
885
+ <div class="hero-copy">
886
+ <div class="eyebrow-row">
887
+ <span class="eyebrow">Sovereign AI stack</span>
888
+ <span class="eyebrow">July 2026</span>
889
+ </div>
890
+ <h1 id="hero-title">SovAPI</h1>
891
+ <p class="hero-lede">
892
+ Modeless inference infrastructure for sovereign, high-performance AI at token-level
893
+ pricing.
894
+ </p>
895
+ <div class="proof-grid" aria-label="Models and modalities">
896
+ <div class="proof-chip">
897
+ <strong>Qwen3-Coder-Next FP8</strong>
898
+ <span>Code and technical generation</span>
899
+ </div>
900
+ <div class="proof-chip">
901
+ <strong>Gemma 4 31B FP8</strong>
902
+ <span>Text, vision and extraction</span>
903
+ </div>
904
+ <div class="proof-chip">
905
+ <strong>Whisper Large V3</strong>
906
+ <span>FR / EN audio coverage</span>
907
+ </div>
908
+ <div class="proof-chip">
909
+ <strong>BGE-M3 + ChromaDB</strong>
910
+ <span>Long-term memory and search</span>
911
+ </div>
912
+ </div>
913
+ </div>
914
+
915
+ <aside class="status-panel" aria-label="Resume infrastructure">
916
+ <h2>Live stack</h2>
917
+ <div class="status-metric">
918
+ <span>Mode</span>
919
+ <strong>Modeless</strong>
920
+ </div>
921
+ <div class="status-metric">
922
+ <span>Region</span>
923
+ <strong>Europe</strong>
924
+ </div>
925
+ <div class="status-metric">
926
+ <span>Input</span>
927
+ <strong>0,13 EUR / M in</strong>
928
+ </div>
929
+ <div class="status-metric">
930
+ <span>Output</span>
931
+ <strong>0,26 EUR / M out</strong>
932
+ </div>
933
+ <a class="status-link" href="#view-defi">Launch arena</a>
934
+ </aside>
935
+ </section>
936
+
937
+ <section class="status" aria-label="Statut infrastructure">
938
+ <span><strong id="statusLabel">loading live status</strong><span id="statusDetail"></span></span>
939
+ <a href="https://status.sovinfra.cloud/" rel="noopener noreferrer" target="_blank">
940
+ Full status
941
+ </a>
942
+ </section>
943
+
944
+ <nav class="tabs" aria-label="Vues">
945
+ <button class="tab" data-tab="defi" aria-selected="true" type="button">Arena</button>
946
+ <button class="tab" data-tab="speed" aria-selected="false" type="button">Speed</button>
947
+ <button class="tab" data-tab="best" aria-selected="false" type="button">Best-of-2</button>
948
+ <button class="tab" data-tab="economy" aria-selected="false" type="button">Economy</button>
949
+ <button class="tab" data-tab="api" aria-selected="false" type="button">API ACCESS</button>
950
+ </nav>
951
+
952
+ <section class="view" id="view-defi">
953
+ <div class="challenge-grid">
954
+ <aside class="panel control-panel">
955
+ <p class="section-kicker">Preloaded prompt</p>
956
+ <h2 class="section-title">The Arena</h2>
957
+ <div class="field">
958
+ <label>Terrain</label>
959
+ <select class="terrain-select" id="terrain" aria-label="Terrain">
960
+ <option value="code">Code a function</option>
961
+ <option value="audit">Audit a text</option>
962
+ <option value="json">Extract to JSON</option>
963
+ <option value="reasoning">Complex reasoning</option>
964
+ </select>
965
+ <div class="terrain-grid" aria-label="Terrain choices">
966
+ <button class="terrain-button" data-terrain="code" aria-pressed="true" type="button">
967
+ <strong>Code</strong>
968
+ <span>Code a function</span>
969
+ </button>
970
+ <button class="terrain-button" data-terrain="audit" aria-pressed="false" type="button">
971
+ <strong>Analysis</strong>
972
+ <span>Audit a text</span>
973
+ </button>
974
+ <button class="terrain-button" data-terrain="json" aria-pressed="false" type="button">
975
+ <strong>Structure</strong>
976
+ <span>Extract to JSON</span>
977
+ </button>
978
+ <button class="terrain-button" data-terrain="reasoning" aria-pressed="false" type="button">
979
+ <strong>Reasoning</strong>
980
+ <span>Stress-test modeless routing</span>
981
+ </button>
982
+ </div>
983
+ </div>
984
+ <div class="field">
985
+ <label for="prompt">Editable prompt</label>
986
+ <textarea id="prompt">Implement merge_intervals(intervals: Array&lt;[number, number]&gt;) in TypeScript. Return sorted, non-overlapping intervals. Merge touching intervals, reject invalid ranges, handle empty input and negative values, and include concise tests.</textarea>
987
+ </div>
988
+ <div class="field">
989
+ <label for="challenger">Challenger</label>
990
+ <select id="challenger">
991
+ <option value="anthropic/claude-sonnet-5">Claude Sonnet 5</option>
992
+ </select>
993
+ <p class="muted">The challenger runs through the server-side OpenRouter relay. No provider key is exposed in the browser.</p>
994
+ </div>
995
+ <button class="button" id="runButton" type="button">Test SovAPI</button>
996
+ <p class="muted" id="quotaText">Challenger cap: 30,000 tokens per duel.</p>
997
+ </aside>
998
+
999
+ <section>
1000
+ <div class="duel">
1001
+ <article class="panel duel-panel">
1002
+ <div class="panel-title">
1003
+ <div>
1004
+ <p class="section-kicker">A - Sovereign</p>
1005
+ <h2 id="sovapiLaneTitle">SOVAPI CODE</h2>
1006
+ </div>
1007
+ <span class="badge">sovapi</span>
1008
+ </div>
1009
+ <div class="output" id="sovapiOutput"></div>
1010
+ <div class="metrics" id="sovapiMetrics"></div>
1011
+ <p class="live-note">Live measured from this run.</p>
1012
+ </article>
1013
+ <article class="panel duel-panel">
1014
+ <div class="panel-title">
1015
+ <div>
1016
+ <p class="section-kicker">B - Challenger</p>
1017
+ <h2>OpenRouter</h2>
1018
+ </div>
1019
+ <span class="badge" id="challengerBadge">openrouter</span>
1020
+ </div>
1021
+ <div class="output" id="challengerOutput"></div>
1022
+ <div class="metrics" id="challengerMetrics"></div>
1023
+ <p class="live-note">Live measured from this run.</p>
1024
+ </article>
1025
+ </div>
1026
+ <div class="verdict" id="verdict" aria-live="polite">
1027
+ The live verdict appears after both measured runs complete.
1028
+ </div>
1029
+ <div class="actions">
1030
+ <button class="button secondary" id="newButton" type="button">New test</button>
1031
+ <button class="button secondary" id="shareButton" type="button" disabled>
1032
+ Share result
1033
+ </button>
1034
+ </div>
1035
+ <canvas id="shareCanvas" width="1600" height="900" hidden></canvas>
1036
+ </section>
1037
+ </div>
1038
+ </section>
1039
+
1040
+ <section class="view" id="view-speed" hidden>
1041
+ <div class="stat-strip">Reference target: 18x faster TTFT on the code lane</div>
1042
+ <div class="benchmark-grid">
1043
+ <article class="panel benchmark-card">
1044
+ <p class="section-kicker">SovAPI code TTFT</p>
1045
+ <strong>0.10 s</strong>
1046
+ <p>Qwen3-Coder on H200 target path for code and technical generation.</p>
1047
+ </article>
1048
+ <article class="panel benchmark-card">
1049
+ <p class="section-kicker">Code throughput</p>
1050
+ <strong>148 tok/s</strong>
1051
+ <p>Expected Qwen3-Coder generation rate on the dedicated code lane.</p>
1052
+ </article>
1053
+ <article class="panel benchmark-card">
1054
+ <p class="section-kicker">Gemma H200 reference</p>
1055
+ <strong>110 tok/s</strong>
1056
+ <p>Reference throughput for Analysis and Structure once Gemma runs on H200.</p>
1057
+ </article>
1058
+ </div>
1059
+ <div class="panel" style="margin-top: 16px">
1060
+ <h2>Latency reference table</h2>
1061
+ <p class="reference-note">
1062
+ <strong>Reference / expected values.</strong> The Arena verdict above is recalculated
1063
+ from each live duel.
1064
+ </p>
1065
+ <div class="table-scroll" role="region" aria-label="Latency reference table" tabindex="0">
1066
+ <table>
1067
+ <thead>
1068
+ <tr><th>Scenario</th><th>SovAPI route</th><th>SovAPI reference</th><th>Sonnet 5 reference</th><th>Signal</th></tr>
1069
+ </thead>
1070
+ <tbody>
1071
+ <tr><td>Code TTFT</td><td>Qwen3-Coder</td><td>0.10 s expected</td><td>1.80 s reference</td><td>18x faster target</td></tr>
1072
+ <tr><td>Code throughput</td><td>Qwen3-Coder</td><td>148 tok/s expected</td><td>48 tok/s reference</td><td>3.1x more tokens/s target</td></tr>
1073
+ <tr><td>Analysis / Structure throughput</td><td>Gemma 4 31B</td><td>110 tok/s expected</td><td>48 tok/s reference</td><td>H200 reference</td></tr>
1074
+ <tr><td>End-to-end streaming</td><td>Modeless SovAPI</td><td>SSE direct</td><td>OpenRouter relay</td><td>Measured live only in Arena</td></tr>
1075
+ </tbody>
1076
+ </table>
1077
+ </div>
1078
+ </div>
1079
+ </section>
1080
+
1081
+ <section class="view" id="view-best" hidden>
1082
+ <div class="stat-strip">Anthropic-level quality, 40x cheaper, in Europe</div>
1083
+ <div class="panel">
1084
+ <h2>SovAPI vs Claude Sonnet 5</h2>
1085
+ <p class="reference-note">
1086
+ <strong>Public benchmark reference values.</strong> 5/6 wins or ties. Live Arena
1087
+ results remain measured separately for each prompt.
1088
+ </p>
1089
+ <div class="table-scroll" role="region" aria-label="SovAPI versus Sonnet 5 benchmarks" tabindex="0">
1090
+ <table>
1091
+ <thead>
1092
+ <tr><th>Benchmark</th><th>SovAPI</th><th>Sonnet 5</th><th>Gap</th></tr>
1093
+ </thead>
1094
+ <tbody>
1095
+ <tr><td>GPQA Diamond</td><td>87%</td><td>~83%</td><td>+4 pts</td></tr>
1096
+ <tr><td>AIME 2025</td><td>91%</td><td>~78%</td><td>+13 pts</td></tr>
1097
+ <tr><td>MMLU Pro</td><td>86%</td><td>~86%</td><td>Tie</td></tr>
1098
+ <tr><td>LiveCodeBench</td><td>~88%</td><td>~85%</td><td>+3 pts</td></tr>
1099
+ <tr><td>SWE-bench Verified</td><td>~70%</td><td>~72%</td><td>-2 pts</td></tr>
1100
+ <tr><td>BFCL v2</td><td>~89%</td><td>~88%</td><td>+1 pt</td></tr>
1101
+ </tbody>
1102
+ </table>
1103
+ </div>
1104
+ </div>
1105
+ <div class="panel" style="margin-top: 16px">
1106
+ <h2>SovAPI vs DeepSeek</h2>
1107
+ <p class="reference-note">
1108
+ <strong>Public benchmark reference values.</strong> Same price, two orchestrated
1109
+ models versus one. 7/7 reference wins, average gap +8.7 points.
1110
+ </p>
1111
+ <div class="table-scroll" role="region" aria-label="SovAPI versus DeepSeek benchmarks" tabindex="0">
1112
+ <table>
1113
+ <thead>
1114
+ <tr><th>Benchmark</th><th>Qwen3</th><th>Gemma 4</th><th>SovAPI</th><th>DeepSeek</th><th>Gap</th></tr>
1115
+ </thead>
1116
+ <tbody>
1117
+ <tr><td>LiveCodeBench</td><td>88%</td><td>76%</td><td>88%</td><td>72%</td><td>+16 pts</td></tr>
1118
+ <tr><td>SWE-bench Verified</td><td>70%</td><td>58%</td><td>70%</td><td>62%</td><td>+8 pts</td></tr>
1119
+ <tr><td>BFCL v2</td><td>89%</td><td>82%</td><td>89%</td><td>78%</td><td>+11 pts</td></tr>
1120
+ <tr><td>GPQA Diamond</td><td>85%</td><td>87%</td><td>87%</td><td>78%</td><td>+9 pts</td></tr>
1121
+ <tr><td>AIME 2025</td><td>88%</td><td>91%</td><td>91%</td><td>85%</td><td>+6 pts</td></tr>
1122
+ <tr><td>MMLU Pro</td><td>85%</td><td>86%</td><td>86%</td><td>82%</td><td>+4 pts</td></tr>
1123
+ <tr><td>HumanEval+</td><td>92%</td><td>84%</td><td>92%</td><td>85%</td><td>+7 pts</td></tr>
1124
+ </tbody>
1125
+ </table>
1126
+ </div>
1127
+ </div>
1128
+ </section>
1129
+
1130
+ <section class="view" id="view-economy" hidden>
1131
+ <div class="stat-strip">Reference target: 40x lower unit price</div>
1132
+ <div class="benchmark-grid">
1133
+ <article class="panel benchmark-card">
1134
+ <p class="section-kicker">Input</p>
1135
+ <strong>0.13 EUR</strong>
1136
+ <p>Per million input tokens.</p>
1137
+ </article>
1138
+ <article class="panel benchmark-card">
1139
+ <p class="section-kicker">Output</p>
1140
+ <strong>0.26 EUR</strong>
1141
+ <p>Per million output tokens.</p>
1142
+ </article>
1143
+ <article class="panel benchmark-card">
1144
+ <p class="section-kicker">Trial grant</p>
1145
+ <strong>100 M</strong>
1146
+ <p>Trial tokens for developers starting from API ACCESS.</p>
1147
+ </article>
1148
+ </div>
1149
+ <div class="panel" style="margin-top: 16px">
1150
+ <h2>Economy reference table</h2>
1151
+ <p class="reference-note">
1152
+ <strong>Reference / expected values.</strong> Per-duel cost is recalculated from
1153
+ live token usage and OpenRouter cost data.
1154
+ </p>
1155
+ <div class="table-scroll" role="region" aria-label="Economy reference table" tabindex="0">
1156
+ <table>
1157
+ <thead>
1158
+ <tr><th>Metric</th><th>SovAPI</th><th>Challenger</th><th>How it is shown</th></tr>
1159
+ </thead>
1160
+ <tbody>
1161
+ <tr><td>Input token price</td><td>0.13 EUR / M reference grid</td><td>OpenRouter live cost</td><td>Reference grid + live usage</td></tr>
1162
+ <tr><td>Output token price</td><td>0.26 EUR / M reference grid</td><td>OpenRouter live cost</td><td>Reference grid + live usage</td></tr>
1163
+ <tr><td>Per-request cost</td><td>Calculated from real tokens</td><td>Read from OpenRouter headers or usage</td><td>Displayed after each live duel</td></tr>
1164
+ <tr><td>Trial grant</td><td>100 M tokens</td><td>None</td><td>API ACCESS onboarding</td></tr>
1165
+ </tbody>
1166
+ </table>
1167
+ </div>
1168
+ </div>
1169
+ </section>
1170
+
1171
+ <section class="view" id="view-api" hidden>
1172
+ <div class="api-grid">
1173
+ <div class="step">
1174
+ <p class="section-kicker">Step 1</p>
1175
+ <h2>Create your key</h2>
1176
+ <p>Use GitHub or Google OAuth, then generate your trial SovAPI key.</p>
1177
+ <a class="button" data-console-link href="https://app.sovinfra.ai" rel="noopener noreferrer" target="_blank">
1178
+ Create my API key
1179
+ </a>
1180
+ </div>
1181
+ <div class="step">
1182
+ <p class="section-kicker">Step 2</p>
1183
+ <h2>Change two lines</h2>
1184
+ <p>Keep your OpenAI SDK, replace only base_url and api_key.</p>
1185
+ </div>
1186
+ <div class="step">
1187
+ <p class="section-kicker">Step 3</p>
1188
+ <h2>Call sovapi</h2>
1189
+ <p>One public model, automatic routing by modality.</p>
1190
+ </div>
1191
+ </div>
1192
+
1193
+ <div class="panel" style="margin-top: 16px">
1194
+ <h2>Model and pricing</h2>
1195
+ <div class="table-scroll" role="region" aria-label="Model and pricing table" tabindex="0">
1196
+ <table>
1197
+ <thead>
1198
+ <tr><th>Model</th><th>Routing</th><th>Trial grant</th><th>Price</th></tr>
1199
+ </thead>
1200
+ <tbody>
1201
+ <tr>
1202
+ <td>sovapi</td>
1203
+ <td>text, vision, code, audio, embeddings</td>
1204
+ <td>100 M tokens</td>
1205
+ <td>0,13 EUR / M in - 0,26 EUR / M out</td>
1206
+ </tr>
1207
+ </tbody>
1208
+ </table>
1209
+ </div>
1210
+ </div>
1211
+
1212
+ <div class="code-block" style="margin-top: 16px">
1213
+ <pre><code>curl https://api.sovinfra.ai/v1/chat/completions \
1214
+ -H "Authorization: Bearer $SOVINFRA_API_KEY" \
1215
+ -H "Content-Type: application/json" \
1216
+ -d '{"model":"sovapi","messages":[{"role":"user","content":"Hello"}]}'</code></pre>
1217
+ </div>
1218
+ <div class="code-block" style="margin-top: 16px">
1219
+ <pre><code>from openai import OpenAI
1220
+
1221
+ client = OpenAI(
1222
+ base_url="https://api.sovinfra.ai/v1",
1223
+ api_key="sk-sov-...",
1224
+ )
1225
+
1226
+ response = client.chat.completions.create(
1227
+ model="sovapi",
1228
+ messages=[{"role": "user", "content": "Explain SovAPI in one sentence."}],
1229
+ )</code></pre>
1230
+ </div>
1231
+ <div class="code-block" style="margin-top: 16px">
1232
+ <pre><code>import OpenAI from "openai";
1233
+
1234
+ const client = new OpenAI({
1235
+ baseURL: "https://api.sovinfra.ai/v1",
1236
+ apiKey: process.env.SOVINFRA_API_KEY,
1237
+ });</code></pre>
1238
+ </div>
1239
+
1240
+ <div class="panel" style="margin-top: 16px">
1241
+ <h2>Compliance and sovereignty</h2>
1242
+ <p>
1243
+ Sovereign inference, models hosted in Europe, server-side secrets, and logs without
1244
+ prompts or keys. Legal notices and terms are available from the footer.
1245
+ </p>
1246
+ </div>
1247
+ </section>
1248
+
1249
+ <footer class="footer">
1250
+ <span>SOVINFRA - FPLC SAS, 10 Place Vendome, Paris.</span>
1251
+ <div class="footer-links">
1252
+ <a href="https://status.sovinfra.cloud/" rel="noopener noreferrer" target="_blank">Status</a>
1253
+ <a href="https://codeberg.org/sovinfra" rel="noopener noreferrer" target="_blank">Codeberg</a>
1254
+ <a href="/legal">Legal</a>
1255
+ <a href="/terms">Terms</a>
1256
+ <a href="mailto:contact@sovinfra.ai">contact@sovinfra.ai</a>
1257
+ </div>
1258
+ </footer>
1259
+ </main>
1260
+
1261
+ <script>
1262
+ const REFERRAL_CODE_PATTERN = /^[a-z0-9_-]{1,64}$/;
1263
+
1264
+ function propagateReferralToConsole() {
1265
+ const requestedCode = new URL(window.location.href).searchParams.get("ref");
1266
+ const referralCode = requestedCode?.trim().toLowerCase() || "";
1267
+ if (!REFERRAL_CODE_PATTERN.test(referralCode)) return;
1268
+
1269
+ document.querySelectorAll("a[data-console-link]").forEach((link) => {
1270
+ const target = new URL(link.href);
1271
+ if (target.origin !== "https://app.sovinfra.ai") return;
1272
+ target.searchParams.set("ref", referralCode);
1273
+ link.href = target.toString();
1274
+ });
1275
+ }
1276
+
1277
+ propagateReferralToConsole();
1278
+
1279
+ const API_BASE = window.SOVAPI_API_BASE || "https://api.sovinfra.cloud";
1280
+ const API_FALLBACK_BASE = window.SOVAPI_API_FALLBACK_BASE || "https://api.sovinfra.cloud";
1281
+ const API_BASES = [API_BASE];
1282
+ if (API_FALLBACK_BASE !== API_BASE) {
1283
+ API_BASES.push(API_FALLBACK_BASE);
1284
+ }
1285
+ const STATUS_SUMMARY_URL =
1286
+ window.SOVAPI_STATUS_SUMMARY_URL || "https://api.sovinfra.cloud/health/ready";
1287
+ const DEFAULT_CHALLENGER_MODEL = "anthropic/claude-sonnet-5";
1288
+ const THEME_KEY = "sovapi.theme";
1289
+ const MOBILE_RETRY_DELAYS_MS = [0, 900, 2200];
1290
+ const DESKTOP_RETRY_DELAYS_MS = [0, 900];
1291
+ const MOBILE_REQUEST_TIMEOUT_MS = 180000;
1292
+ const DESKTOP_REQUEST_TIMEOUT_MS = 150000;
1293
+ const SHARE_CARD_WIDTH = 1600;
1294
+ const SHARE_CARD_HEIGHT = 900;
1295
+ const SHARE_SAFE_MARGIN = 60;
1296
+ const SHARE_CARD_LAYOUT = Object.freeze({
1297
+ frame: { x: 62, y: 62, width: 1476, height: 776 },
1298
+ table: { y: 250, width: 680, height: 300, leftX: 96, rightX: 824 },
1299
+ verdict: { x: 96, y: 590, width: 1408, height: 164 },
1300
+ });
1301
+ const CURATED_CHALLENGERS = [
1302
+ { id: "anthropic/claude-sonnet-5", label: "Anthropic - Claude Sonnet 5" },
1303
+ { id: "anthropic/claude-opus-4.8", label: "Anthropic - Claude Opus 4.8" },
1304
+ { id: "openai/gpt-5.6-luna-pro", label: "OpenAI - GPT-5.6" },
1305
+ { id: "google/gemini-3.5-flash", label: "Google - Gemini 3.5" },
1306
+ { id: "deepseek/deepseek-v4-pro", label: "DeepSeek V4" },
1307
+ { id: "moonshotai/kimi-k2.7-code", label: "MoonshotAI - Kimi K2.7" },
1308
+ { id: "qwen/qwen3.7-max", label: "Qwen 3.7" },
1309
+ { id: "z-ai/glm-5.2", label: "Z.ai - GLM 5.2" },
1310
+ ];
1311
+ const PROMPTS = {
1312
+ code: "Implement merge_intervals(intervals: Array<[number, number]>) in TypeScript. Return sorted, non-overlapping intervals. Merge touching intervals, reject invalid ranges, handle empty input and negative values, and include concise tests.",
1313
+ audit:
1314
+ "Audit this reasoning: 'Our competitor lowered prices this quarter, therefore their infrastructure must be cheaper to operate and we should immediately match their price.' Identify the fallacies, missing evidence, business risks, and a corrected decision process.",
1315
+ json:
1316
+ "Extract this invoice into strict JSON only: Invoice INV-2026-0710, issued by Northwind Hosting SAS to FPLC SAS, 10 Place Vendome, Paris. Date 2026-07-10. Due 2026-07-25. Lines: H200 GPU hosting, quantity 42 hours, unit price 18.50 EUR; Sovereign inference support, quantity 1, unit price 690 EUR. VAT rate 20%. Return invoice_number, supplier, customer, dates, line_items, subtotal_eur, vat_eur, total_eur.",
1317
+ reasoning:
1318
+ "Two trains leave at the same time. Train A leaves Paris for Lyon at 300 km/h. Train B leaves Lyon for Paris at 220 km/h. The rail distance is 465 km. After 18 minutes, Train A slows to 250 km/h because of traffic. At what time after departure do the trains meet? Show the calculation clearly.",
1319
+ };
1320
+ const TERRAIN_LABELS = {
1321
+ code: "CODE",
1322
+ audit: "ANALYSIS",
1323
+ json: "STRUCTURE",
1324
+ reasoning: "REASONING",
1325
+ };
1326
+ const state = {
1327
+ sovapi: null,
1328
+ challenger: null,
1329
+ duelId: null,
1330
+ };
1331
+
1332
+ const qs = (selector) => document.querySelector(selector);
1333
+
1334
+ function randomDuelId() {
1335
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
1336
+ return globalThis.crypto.randomUUID();
1337
+ }
1338
+ return `duel-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1339
+ }
1340
+
1341
+ function setTheme(nextTheme, persist = true) {
1342
+ const normalizedTheme = nextTheme === "dark" ? "dark" : "light";
1343
+ document.documentElement.dataset.theme = normalizedTheme;
1344
+ qs("#themeButton").textContent = normalizedTheme === "dark" ? "Light mode" : "Dark mode";
1345
+ if (!persist) return;
1346
+ try {
1347
+ localStorage.setItem(THEME_KEY, normalizedTheme);
1348
+ } catch (_error) {
1349
+ // Keep the toggle functional when storage is unavailable.
1350
+ }
1351
+ }
1352
+
1353
+ setTheme(document.documentElement.dataset.theme, false);
1354
+
1355
+ qs("#themeButton").addEventListener("click", () => {
1356
+ const current = document.documentElement.dataset.theme;
1357
+ setTheme(current === "dark" ? "light" : "dark");
1358
+ });
1359
+
1360
+ function sleep(ms) {
1361
+ return new Promise((resolve) => setTimeout(resolve, ms));
1362
+ }
1363
+
1364
+ function shouldUseMobileSafeMode() {
1365
+ const userAgent = navigator.userAgent || "";
1366
+ const coarsePointer = window.matchMedia?.("(pointer: coarse)")?.matches || false;
1367
+ return /iPhone|iPad|iPod|Android|Mobile/.test(userAgent) || coarsePointer;
1368
+ }
1369
+
1370
+ function requestTimeoutMs() {
1371
+ return shouldUseMobileSafeMode() ? MOBILE_REQUEST_TIMEOUT_MS : DESKTOP_REQUEST_TIMEOUT_MS;
1372
+ }
1373
+
1374
+ function retryDelaysMs() {
1375
+ return shouldUseMobileSafeMode() ? MOBILE_RETRY_DELAYS_MS : DESKTOP_RETRY_DELAYS_MS;
1376
+ }
1377
+
1378
+ function isRetryableStatus(status) {
1379
+ return status === 408 || status === 425 || status === 429 || status >= 500;
1380
+ }
1381
+
1382
+ function annotateChallengeError(error, details = {}) {
1383
+ error.code = details.code || "challenge_unavailable";
1384
+ error.publicMessage = details.publicMessage || "";
1385
+ error.retryAfterSeconds = details.retryAfterSeconds || null;
1386
+ return error;
1387
+ }
1388
+
1389
+ function retryableError(message, details = {}) {
1390
+ const error = new Error(message);
1391
+ error.retryable = true;
1392
+ return annotateChallengeError(error, details);
1393
+ }
1394
+
1395
+ function nonRetryableError(message, details = {}) {
1396
+ const error = new Error(message);
1397
+ error.retryable = false;
1398
+ return annotateChallengeError(error, details);
1399
+ }
1400
+
1401
+ function shouldRetryError(error) {
1402
+ return error?.retryable !== false;
1403
+ }
1404
+
1405
+ function retryAfterCopy(rawSeconds) {
1406
+ const seconds = Number(rawSeconds);
1407
+ if (!Number.isFinite(seconds) || seconds <= 0) return "later";
1408
+ if (seconds < 120) return `in about ${Math.ceil(seconds)} seconds`;
1409
+ if (seconds < 7200) return `in about ${Math.ceil(seconds / 60)} minutes`;
1410
+ return `in about ${Math.ceil(seconds / 3600)} hours`;
1411
+ }
1412
+
1413
+ function challengeFailureCopy(side, code, retryAfterSeconds) {
1414
+ if (
1415
+ code === "challenge_duel_token_limit_exceeded" ||
1416
+ code === "challenge_token_limit_exceeded"
1417
+ ) {
1418
+ return "This duel has reached its 30,000-token Challenger limit. Start a new duel to continue.";
1419
+ }
1420
+ if (
1421
+ code === "challenge_daily_ip_limit_exceeded" ||
1422
+ code === "challenge_request_limit_exceeded"
1423
+ ) {
1424
+ return `This network has reached its daily Challenger duel limit. Please retry ${retryAfterCopy(retryAfterSeconds)}.`;
1425
+ }
1426
+ if (code === "challenge_global_daily_limit_exceeded") {
1427
+ return `Today's public Challenger capacity has been reached. Please retry ${retryAfterCopy(retryAfterSeconds)}.`;
1428
+ }
1429
+ if (code === "challenge_provider_rate_limited") {
1430
+ return "The Challenger provider is temporarily rate-limited. Please retry shortly.";
1431
+ }
1432
+ if (
1433
+ code === "openrouter_not_configured" ||
1434
+ code === "challenge_provider_auth_failed" ||
1435
+ code === "challenge_provider_credit_exhausted"
1436
+ ) {
1437
+ return "The Challenger service is temporarily unavailable. The server-side provider configuration is being checked.";
1438
+ }
1439
+ if (side === "challenger") {
1440
+ return "The Challenger provider is temporarily unavailable. Please retry shortly.";
1441
+ }
1442
+ return "SovAPI is temporarily unavailable. Please retry shortly.";
1443
+ }
1444
+
1445
+ async function challengeResponseError(response, side) {
1446
+ let payload = {};
1447
+ try {
1448
+ payload = await response.json();
1449
+ } catch (_error) {
1450
+ // Preserve a sanitized HTTP fallback when an intermediary returns non-JSON.
1451
+ }
1452
+ const code = payload?.error?.code || `challenge_http_${response.status}`;
1453
+ const retryAfterSeconds = Number(response.headers.get("retry-after")) || null;
1454
+ const details = {
1455
+ code,
1456
+ retryAfterSeconds,
1457
+ publicMessage: challengeFailureCopy(side, code, retryAfterSeconds),
1458
+ };
1459
+ const message = `Challenge ${side} failed with ${response.status} (${code})`;
1460
+ const retryable = isRetryableStatus(response.status) && response.status !== 429;
1461
+ return retryable ? retryableError(message, details) : nonRetryableError(message, details);
1462
+ }
1463
+
1464
+ function challengeEventError(side, payload) {
1465
+ const code = payload?.code || "challenge_upstream_interrupted";
1466
+ const retryAfterSeconds = Number(payload?.retry_after_seconds) || null;
1467
+ const details = {
1468
+ code,
1469
+ retryAfterSeconds,
1470
+ publicMessage: challengeFailureCopy(side, code, retryAfterSeconds),
1471
+ };
1472
+ const message = payload?.message || "Challenge upstream stream was interrupted";
1473
+ return payload?.retryable === false
1474
+ ? nonRetryableError(message, details)
1475
+ : retryableError(message, details);
1476
+ }
1477
+
1478
+ function rejectedRunCopy(side, error) {
1479
+ return error?.publicMessage || challengeFailureCopy(side, error?.code, error?.retryAfterSeconds);
1480
+ }
1481
+
1482
+ async function fetchWithTimeout(url, options = {}) {
1483
+ const controller = new AbortController();
1484
+ const timeout = setTimeout(() => controller.abort(), requestTimeoutMs());
1485
+ try {
1486
+ return await fetch(url, {
1487
+ cache: "no-store",
1488
+ credentials: "omit",
1489
+ mode: "cors",
1490
+ ...options,
1491
+ signal: controller.signal,
1492
+ });
1493
+ } finally {
1494
+ clearTimeout(timeout);
1495
+ }
1496
+ }
1497
+
1498
+ async function fetchJsonFromApiBases(path) {
1499
+ let lastError;
1500
+ for (const base of API_BASES) {
1501
+ try {
1502
+ const response = await fetchWithTimeout(`${base}${path}`, {
1503
+ headers: { Accept: "application/json" },
1504
+ });
1505
+ if (!response.ok) {
1506
+ throw isRetryableStatus(response.status)
1507
+ ? retryableError(`GET ${path} failed with ${response.status}`)
1508
+ : nonRetryableError(`GET ${path} failed with ${response.status}`);
1509
+ }
1510
+ return await response.json();
1511
+ } catch (error) {
1512
+ lastError = error;
1513
+ if (!shouldRetryError(error)) break;
1514
+ }
1515
+ }
1516
+ throw lastError || new Error(`GET ${path} failed`);
1517
+ }
1518
+
1519
+ function selectTab(tab) {
1520
+ document.querySelectorAll(".tab[data-tab]").forEach((item) => {
1521
+ item.setAttribute("aria-selected", String(item.dataset.tab === tab));
1522
+ });
1523
+ document.querySelectorAll(".view[id^='view-']").forEach((view) => {
1524
+ view.hidden = view.id !== `view-${tab}`;
1525
+ });
1526
+ const target = qs(`#view-${tab}`) || qs("#view-defi");
1527
+ target.scrollIntoView({ block: "start", behavior: "smooth" });
1528
+ }
1529
+
1530
+ document.querySelectorAll(".tab[data-tab]").forEach((button) => {
1531
+ button.addEventListener("click", () => {
1532
+ selectTab(button.dataset.tab);
1533
+ });
1534
+ });
1535
+
1536
+ document.querySelectorAll("[data-nav-tab]").forEach((button) => {
1537
+ button.addEventListener("click", () => {
1538
+ selectTab(button.dataset.navTab);
1539
+ });
1540
+ });
1541
+
1542
+ function selectTerrain(terrain) {
1543
+ const selectedTerrain = PROMPTS[terrain] ? terrain : "code";
1544
+ qs("#terrain").value = selectedTerrain;
1545
+ document.querySelectorAll(".terrain-button").forEach((item) => {
1546
+ item.setAttribute("aria-pressed", String(item.dataset.terrain === selectedTerrain));
1547
+ });
1548
+ qs("#prompt").value = PROMPTS[selectedTerrain];
1549
+ qs("#sovapiLaneTitle").textContent = activeLaneTitle(selectedTerrain);
1550
+ }
1551
+
1552
+ function activeLaneTitle(terrain = qs("#terrain").value) {
1553
+ return `SOVAPI ${TERRAIN_LABELS[terrain] || TERRAIN_LABELS.code}`;
1554
+ }
1555
+
1556
+ function activeShareModelName(terrain = qs("#terrain").value) {
1557
+ const label = TERRAIN_LABELS[terrain] || TERRAIN_LABELS.code;
1558
+ return `SovAPI ${label.charAt(0)}${label.slice(1).toLowerCase()}`;
1559
+ }
1560
+
1561
+ document.querySelectorAll(".terrain-button").forEach((button) => {
1562
+ button.addEventListener("click", () => selectTerrain(button.dataset.terrain));
1563
+ });
1564
+
1565
+ window.addEventListener("pageshow", () => selectTerrain(qs("#terrain").value));
1566
+
1567
+ async function loadModels() {
1568
+ const select = qs("#challenger");
1569
+ try {
1570
+ const payload = await fetchJsonFromApiBases("/v1/challenge/models");
1571
+ const models = Array.isArray(payload.data) ? payload.data : [];
1572
+ if (!models.length) throw new Error("empty catalogue");
1573
+ select.replaceChildren(...challengerOptions(models));
1574
+ select.value = DEFAULT_CHALLENGER_MODEL;
1575
+ } catch (_error) {
1576
+ select.replaceChildren(...challengerOptions([]));
1577
+ select.value = DEFAULT_CHALLENGER_MODEL;
1578
+ }
1579
+ }
1580
+
1581
+ function challengerOptions(models) {
1582
+ return CURATED_CHALLENGERS.map((challenger) => {
1583
+ const model = models.find((item) => item.id === challenger.id);
1584
+ const option = document.createElement("option");
1585
+ option.value = model?.id || challenger.id;
1586
+ option.textContent = challenger.label;
1587
+ return option;
1588
+ });
1589
+ }
1590
+
1591
+ function resetRun() {
1592
+ state.sovapi = null;
1593
+ state.challenger = null;
1594
+ qs("#sovapiOutput").textContent = "";
1595
+ qs("#challengerOutput").textContent = "";
1596
+ qs("#sovapiMetrics").replaceChildren();
1597
+ qs("#challengerMetrics").replaceChildren();
1598
+ qs("#verdict").textContent = "Live run in progress.";
1599
+ qs("#shareButton").disabled = true;
1600
+ qs("#quotaText").textContent = "30,000 Challenger tokens available for this duel.";
1601
+ }
1602
+
1603
+ function metric(label, value) {
1604
+ const node = document.createElement("div");
1605
+ const labelNode = document.createElement("span");
1606
+ const valueNode = document.createElement("strong");
1607
+ node.className = "metric";
1608
+ labelNode.textContent = label;
1609
+ valueNode.textContent = value;
1610
+ node.append(labelNode, valueNode);
1611
+ return node;
1612
+ }
1613
+
1614
+ function renderMetrics(target, metrics) {
1615
+ const node = qs(target);
1616
+ const rows = [
1617
+ metric("Live model", metrics.model || "--"),
1618
+ metric("Live TTFT", formatMs(metrics.time_to_first_token_ms)),
1619
+ metric("Live latency", formatMs(metrics.latency_ms)),
1620
+ metric("Live tokens/s", formatNumber(metrics.tokens_per_second)),
1621
+ metric(
1622
+ "Live tokens in/out",
1623
+ `${formatNumber(metrics.input_tokens)}/${formatNumber(metrics.output_tokens)}`,
1624
+ ),
1625
+ metric("Finish reason", metrics.finish_reason || "--"),
1626
+ metric("Live cost", formatCost(metrics.cost_eur)),
1627
+ ];
1628
+ if (metrics.gpu_ms !== undefined) {
1629
+ rows.push(metric("GPU", formatMs(metrics.gpu_ms)));
1630
+ rows.push(metric("Network", formatMs(metrics.network_ms)));
1631
+ }
1632
+ node.replaceChildren(...rows);
1633
+ }
1634
+
1635
+ function formatNumber(value) {
1636
+ if (value === null || value === undefined) return "--";
1637
+ return Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 });
1638
+ }
1639
+
1640
+ function formatMs(value) {
1641
+ if (value === null || value === undefined) return "--";
1642
+ return `${Math.round(value)} ms`;
1643
+ }
1644
+
1645
+ function formatCost(value) {
1646
+ if (value === null || value === undefined) return "--";
1647
+ return `${Number(value).toFixed(6)} EUR`;
1648
+ }
1649
+
1650
+ function formatSeconds(value) {
1651
+ if (value === null || value === undefined) return "--";
1652
+ return `${(Number(value) / 1000).toFixed(2)} s`;
1653
+ }
1654
+
1655
+ function formatRatio(value) {
1656
+ if (!Number.isFinite(value) || value <= 0) return "";
1657
+ return value >= 10 ? value.toFixed(0) : value.toFixed(1);
1658
+ }
1659
+
1660
+ function ratioPhrase(sovValue, challengerValue, betterWord, worseWord, equalPhrase, unavailablePhrase) {
1661
+ const sov = Number(sovValue);
1662
+ const challenger = Number(challengerValue);
1663
+ if (!Number.isFinite(sov) || !Number.isFinite(challenger)) {
1664
+ return unavailablePhrase;
1665
+ }
1666
+ if (sov === challenger) return equalPhrase;
1667
+ const sovWins = sov < challenger;
1668
+ if (sov <= 0 || challenger <= 0) return sovWins ? betterWord : worseWord;
1669
+ const ratio = sovWins ? challenger / sov : sov / challenger;
1670
+ return `${formatRatio(ratio)}x ${sovWins ? betterWord : worseWord}`.trim();
1671
+ }
1672
+
1673
+ function comparisonSummary() {
1674
+ if (!state.sovapi || !state.challenger) {
1675
+ return {
1676
+ headline: "Live duel pending",
1677
+ ttft: "TTFT pending",
1678
+ detail: "Run both sides to generate a shareable result.",
1679
+ };
1680
+ }
1681
+ const speed = ratioPhrase(
1682
+ state.sovapi.latency_ms,
1683
+ state.challenger.latency_ms,
1684
+ "faster",
1685
+ "slower",
1686
+ "same speed",
1687
+ "speed unavailable",
1688
+ );
1689
+ const cost = ratioPhrase(
1690
+ state.sovapi.cost_eur,
1691
+ state.challenger.cost_eur,
1692
+ "cheaper",
1693
+ "more expensive",
1694
+ "same cost",
1695
+ "cost unavailable",
1696
+ );
1697
+ const ttft = ratioPhrase(
1698
+ state.sovapi.time_to_first_token_ms,
1699
+ state.challenger.time_to_first_token_ms,
1700
+ "faster",
1701
+ "slower",
1702
+ "same speed",
1703
+ "unavailable",
1704
+ );
1705
+ return {
1706
+ headline: `SovAPI ${speed} - ${cost}`,
1707
+ ttft: `TTFT ${ttft}`,
1708
+ detail: "Measured from live API responses for this exact prompt.",
1709
+ };
1710
+ }
1711
+
1712
+ function renderLiveVerdict(summary) {
1713
+ const headline = document.createElement("strong");
1714
+ const ttft = document.createElement("span");
1715
+ const detail = document.createElement("small");
1716
+ headline.textContent = summary.headline;
1717
+ ttft.textContent = summary.ttft;
1718
+ detail.textContent = summary.detail;
1719
+ qs("#verdict").replaceChildren(headline, ttft, detail);
1720
+ }
1721
+
1722
+ function updateVerdict() {
1723
+ if (!state.sovapi || !state.challenger) {
1724
+ if (state.sovapi) {
1725
+ qs("#verdict").textContent =
1726
+ "SovAPI run complete. Waiting for challenger metrics before sharing the result.";
1727
+ }
1728
+ qs("#shareButton").disabled = true;
1729
+ return;
1730
+ }
1731
+ renderLiveVerdict(comparisonSummary());
1732
+ qs("#shareButton").disabled = false;
1733
+ }
1734
+
1735
+ function shouldUseBufferedSse() {
1736
+ const userAgent = navigator.userAgent || "";
1737
+ const isIphone = /iPhone|iPod/.test(userAgent);
1738
+ const isWebKit = /Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS|Edg|OPR/.test(userAgent);
1739
+ return isIphone && isWebKit;
1740
+ }
1741
+
1742
+ function markSideRetrying(side, attempt, base) {
1743
+ const output = side === "sovapi" ? qs("#sovapiOutput") : qs("#challengerOutput");
1744
+ const metrics = side === "sovapi" ? qs("#sovapiMetrics") : qs("#challengerMetrics");
1745
+ output.textContent = `Mobile network retry ${attempt} in progress.`;
1746
+ metrics.replaceChildren(metric("Status", "Retrying"), metric("Endpoint", base.replace("https://", "")));
1747
+ }
1748
+
1749
+ function resetSideBeforeRetry(side) {
1750
+ const output = side === "sovapi" ? qs("#sovapiOutput") : qs("#challengerOutput");
1751
+ const metrics = side === "sovapi" ? qs("#sovapiMetrics") : qs("#challengerMetrics");
1752
+ output.textContent = "";
1753
+ metrics.replaceChildren();
1754
+ state[side] = null;
1755
+ }
1756
+
1757
+ async function runSide(side) {
1758
+ if (!state.duelId) {
1759
+ throw nonRetryableError("Duel identifier is not initialized");
1760
+ }
1761
+ const body = {
1762
+ side,
1763
+ terrain: qs("#terrain").value,
1764
+ prompt: qs("#prompt").value,
1765
+ // The legacy API field carries a fresh duel id, not a browser session id.
1766
+ session_id: state.duelId,
1767
+ challenger_model: qs("#challenger").value,
1768
+ };
1769
+ const delays = retryDelaysMs();
1770
+ let lastError;
1771
+ for (let attemptIndex = 0; attemptIndex < delays.length; attemptIndex += 1) {
1772
+ if (delays[attemptIndex] > 0) await sleep(delays[attemptIndex]);
1773
+ for (const base of API_BASES) {
1774
+ try {
1775
+ if (attemptIndex > 0) {
1776
+ resetSideBeforeRetry(side);
1777
+ markSideRetrying(side, attemptIndex + 1, base);
1778
+ }
1779
+ await runSideAttempt(side, body, base);
1780
+ return;
1781
+ } catch (error) {
1782
+ lastError = error;
1783
+ if (!shouldRetryError(error)) throw error;
1784
+ }
1785
+ }
1786
+ }
1787
+ throw lastError || new Error(`Challenge ${side} failed`);
1788
+ }
1789
+
1790
+ async function runSideAttempt(side, body, base) {
1791
+ const response = await fetchWithTimeout(`${base}/v1/challenge`, {
1792
+ method: "POST",
1793
+ headers: {
1794
+ Accept: "text/event-stream",
1795
+ "Content-Type": "application/json",
1796
+ },
1797
+ body: JSON.stringify(body),
1798
+ });
1799
+ if (!response.ok) {
1800
+ throw await challengeResponseError(response, side);
1801
+ }
1802
+ if (shouldUseBufferedSse() || !response.body || typeof response.body.getReader !== "function") {
1803
+ consumeSseText(side, await response.text(), true);
1804
+ if (!state[side]) throw retryableError(`Challenge ${side} completed without metrics`);
1805
+ return;
1806
+ }
1807
+ const reader = response.body.getReader();
1808
+ const decoder = new TextDecoder();
1809
+ let buffer = "";
1810
+ while (true) {
1811
+ const { done, value } = await reader.read();
1812
+ if (done) break;
1813
+ buffer = consumeSseText(side, buffer + decoder.decode(value, { stream: true }));
1814
+ }
1815
+ const trailing = decoder.decode();
1816
+ consumeSseText(side, buffer + trailing, true);
1817
+ if (!state[side]) throw retryableError(`Challenge ${side} completed without metrics`);
1818
+ }
1819
+
1820
+ function consumeSseText(side, text, flush = false) {
1821
+ const parts = text.replace(/\r\n/g, "\n").split("\n\n");
1822
+ const events = flush ? parts : parts.slice(0, -1);
1823
+ events.forEach((eventText) => handleEvent(side, eventText));
1824
+ return flush ? "" : parts[parts.length - 1] || "";
1825
+ }
1826
+
1827
+ function markSideUnavailable(side, message) {
1828
+ const output = side === "sovapi" ? qs("#sovapiOutput") : qs("#challengerOutput");
1829
+ const metrics = side === "sovapi" ? qs("#sovapiMetrics") : qs("#challengerMetrics");
1830
+ output.textContent = message;
1831
+ metrics.replaceChildren(metric("Status", "Unavailable"));
1832
+ if (side === "challenger") {
1833
+ state.challenger = null;
1834
+ } else {
1835
+ state.sovapi = null;
1836
+ }
1837
+ }
1838
+
1839
+ function handleEvent(side, eventText) {
1840
+ if (eventText.includes("data: [DONE]")) return;
1841
+ const eventLine = eventText.split("\n").find((line) => line.startsWith("event:"));
1842
+ const eventName = eventLine?.replace("event:", "").trim() || "message";
1843
+ const dataLine = eventText.split("\n").find((line) => line.startsWith("data:"));
1844
+ if (!dataLine) return;
1845
+ let payload;
1846
+ try {
1847
+ payload = JSON.parse(dataLine.replace("data:", "").trim());
1848
+ } catch (_error) {
1849
+ return;
1850
+ }
1851
+ if (eventName === "challenge.error") {
1852
+ throw challengeEventError(side, payload);
1853
+ }
1854
+ if (eventName === "challenge.metrics") {
1855
+ state[side] = payload;
1856
+ renderMetrics(side === "sovapi" ? "#sovapiMetrics" : "#challengerMetrics", payload);
1857
+ if (side === "challenger") {
1858
+ qs("#quotaText").textContent = `${formatNumber(payload.remaining_tokens)} Challenger tokens left in this duel.`;
1859
+ }
1860
+ updateVerdict();
1861
+ return;
1862
+ }
1863
+ const output = side === "sovapi" ? qs("#sovapiOutput") : qs("#challengerOutput");
1864
+ output.textContent += payload.delta || "";
1865
+ }
1866
+
1867
+ async function settleRun(promise) {
1868
+ try {
1869
+ await promise;
1870
+ return { status: "fulfilled" };
1871
+ } catch (reason) {
1872
+ return { status: "rejected", reason };
1873
+ }
1874
+ }
1875
+
1876
+ qs("#runButton").addEventListener("click", async () => {
1877
+ state.duelId = randomDuelId();
1878
+ resetRun();
1879
+ const selectedOption = qs("#challenger").selectedOptions[0];
1880
+ qs("#challengerBadge").textContent = selectedOption?.textContent || "openrouter";
1881
+ qs("#runButton").disabled = true;
1882
+ try {
1883
+ const results = shouldUseMobileSafeMode()
1884
+ ? [await settleRun(runSide("sovapi")), await settleRun(runSide("challenger"))]
1885
+ : await Promise.allSettled([runSide("sovapi"), runSide("challenger")]);
1886
+ if (results[0].status === "rejected") {
1887
+ markSideUnavailable("sovapi", rejectedRunCopy("sovapi", results[0].reason));
1888
+ }
1889
+ if (results[1].status === "rejected") {
1890
+ markSideUnavailable("challenger", rejectedRunCopy("challenger", results[1].reason));
1891
+ }
1892
+ updateVerdict();
1893
+ } finally {
1894
+ qs("#runButton").disabled = false;
1895
+ }
1896
+ });
1897
+
1898
+ qs("#newButton").addEventListener("click", () => {
1899
+ state.duelId = null;
1900
+ qs("#sovapiOutput").textContent = "";
1901
+ qs("#challengerOutput").textContent = "";
1902
+ qs("#sovapiMetrics").replaceChildren();
1903
+ qs("#challengerMetrics").replaceChildren();
1904
+ qs("#verdict").textContent =
1905
+ "The live verdict appears after both measured runs complete.";
1906
+ qs("#shareButton").disabled = true;
1907
+ qs("#quotaText").textContent = "Challenger cap: 30,000 tokens per duel.";
1908
+ });
1909
+
1910
+ qs("#shareButton").addEventListener("click", async () => {
1911
+ if (!state.sovapi || !state.challenger) return;
1912
+ const canvas = qs("#shareCanvas");
1913
+ drawShareCard(canvas);
1914
+ const blob = await canvasToBlob(canvas);
1915
+ if (!blob) return;
1916
+ const file = new File([blob], "sovapi-arena-result.png", { type: "image/png" });
1917
+ const shareData = {
1918
+ title: "SOVINFRA | SOVEREIGN",
1919
+ text: `${comparisonSummary().headline}. ${comparisonSummary().ttft}.`,
1920
+ files: [file],
1921
+ };
1922
+ if (navigator.canShare?.(shareData) && navigator.share) {
1923
+ try {
1924
+ await navigator.share(shareData);
1925
+ return;
1926
+ } catch (error) {
1927
+ if (error instanceof DOMException && error.name === "AbortError") return;
1928
+ }
1929
+ }
1930
+ downloadBlob(blob, "sovapi-arena-result.png");
1931
+ });
1932
+
1933
+ function drawShareCard(canvas) {
1934
+ canvas.width = SHARE_CARD_WIDTH;
1935
+ canvas.height = SHARE_CARD_HEIGHT;
1936
+ const context = canvas.getContext("2d");
1937
+ if (!context) return;
1938
+ const styles = getComputedStyle(document.documentElement);
1939
+ const paper = styles.getPropertyValue("--paper").trim() || "#f3eadc";
1940
+ const panel = styles.getPropertyValue("--panel").trim() || "#fff8ec";
1941
+ const ink = styles.getPropertyValue("--ink").trim() || "#15110d";
1942
+ const muted = styles.getPropertyValue("--muted").trim() || "#6f6255";
1943
+ const orange = styles.getPropertyValue("--orange").trim() || "#ff5a1f";
1944
+ const shareModelName = activeShareModelName();
1945
+ context.fillStyle = paper;
1946
+ context.fillRect(0, 0, canvas.width, canvas.height);
1947
+ context.save();
1948
+ context.beginPath();
1949
+ context.rect(
1950
+ SHARE_SAFE_MARGIN,
1951
+ SHARE_SAFE_MARGIN,
1952
+ canvas.width - SHARE_SAFE_MARGIN * 2,
1953
+ canvas.height - SHARE_SAFE_MARGIN * 2,
1954
+ );
1955
+ context.clip();
1956
+ drawFrame(
1957
+ context,
1958
+ SHARE_CARD_LAYOUT.frame.x,
1959
+ SHARE_CARD_LAYOUT.frame.y,
1960
+ SHARE_CARD_LAYOUT.frame.width,
1961
+ SHARE_CARD_LAYOUT.frame.height,
1962
+ ink,
1963
+ );
1964
+ context.fillStyle = orange;
1965
+ context.fillRect(96, 92, 220, 20);
1966
+ context.fillStyle = ink;
1967
+ context.font = "900 68px Arial, sans-serif";
1968
+ context.fillText("SOVINFRA | SOVEREIGN", 96, 174, 1408);
1969
+ context.font = "700 24px Arial, sans-serif";
1970
+ context.fillStyle = muted;
1971
+ context.fillText(
1972
+ "Live model comparison - values read from real API responses",
1973
+ 96,
1974
+ 216,
1975
+ 1408,
1976
+ );
1977
+
1978
+ drawMetricsTable(
1979
+ context,
1980
+ SHARE_CARD_LAYOUT.table.leftX,
1981
+ SHARE_CARD_LAYOUT.table.y,
1982
+ SHARE_CARD_LAYOUT.table.width,
1983
+ SHARE_CARD_LAYOUT.table.height,
1984
+ "SovAPI",
1985
+ state.sovapi,
1986
+ panel,
1987
+ ink,
1988
+ muted,
1989
+ orange,
1990
+ shareModelName,
1991
+ );
1992
+ drawMetricsTable(
1993
+ context,
1994
+ SHARE_CARD_LAYOUT.table.rightX,
1995
+ SHARE_CARD_LAYOUT.table.y,
1996
+ SHARE_CARD_LAYOUT.table.width,
1997
+ SHARE_CARD_LAYOUT.table.height,
1998
+ qs("#challenger").selectedOptions[0]?.textContent || "Challenger",
1999
+ state.challenger,
2000
+ panel,
2001
+ ink,
2002
+ muted,
2003
+ orange,
2004
+ );
2005
+ context.strokeStyle = orange;
2006
+ context.lineWidth = 3;
2007
+ context.strokeRect(
2008
+ SHARE_CARD_LAYOUT.verdict.x,
2009
+ SHARE_CARD_LAYOUT.verdict.y,
2010
+ SHARE_CARD_LAYOUT.verdict.width,
2011
+ SHARE_CARD_LAYOUT.verdict.height,
2012
+ );
2013
+ const summary = comparisonSummary();
2014
+ context.fillStyle = ink;
2015
+ context.font = "900 40px Arial, sans-serif";
2016
+ context.textAlign = "center";
2017
+ context.fillText(summary.headline, 800, 650, 1280);
2018
+ context.fillStyle = orange;
2019
+ context.font = "900 28px Arial, sans-serif";
2020
+ context.fillText(summary.ttft, 800, 704, 1280);
2021
+ context.fillStyle = muted;
2022
+ context.font = "20px Arial, sans-serif";
2023
+ context.fillText("arena.sovinfra.ai \u00b7 sovereign inference", 800, 810, 1280);
2024
+ context.restore();
2025
+ }
2026
+
2027
+ function drawFrame(context, x, y, width, height, color) {
2028
+ context.strokeStyle = color;
2029
+ context.lineWidth = 3;
2030
+ context.strokeRect(x, y, width, height);
2031
+ }
2032
+
2033
+ function drawMetricsTable(
2034
+ context,
2035
+ x,
2036
+ y,
2037
+ width,
2038
+ height,
2039
+ title,
2040
+ metrics,
2041
+ panel,
2042
+ ink,
2043
+ muted,
2044
+ orange,
2045
+ sharedModelName = null,
2046
+ ) {
2047
+ context.fillStyle = panel;
2048
+ context.fillRect(x, y, width, height);
2049
+ context.strokeStyle = ink;
2050
+ context.lineWidth = 2;
2051
+ context.strokeRect(x, y, width, height);
2052
+ context.fillStyle = ink;
2053
+ context.font = "900 30px Arial, sans-serif";
2054
+ context.fillText(title, x + 24, y + 48, width - 48);
2055
+ const rows = [
2056
+ ["Model", sharedModelName || metrics?.model || "--"],
2057
+ ["TTFT", formatMs(metrics?.time_to_first_token_ms)],
2058
+ ["Throughput", `${formatNumber(metrics?.tokens_per_second)} tok/s`],
2059
+ ["Tokens", `${formatNumber(metrics?.input_tokens)} in / ${formatNumber(metrics?.output_tokens)} out`],
2060
+ ["Cost", formatCost(metrics?.cost_eur)],
2061
+ ["Total time", formatSeconds(metrics?.latency_ms)],
2062
+ ];
2063
+ context.font = "700 21px Arial, sans-serif";
2064
+ rows.forEach(([label, value], index) => {
2065
+ const rowY = y + 88 + index * 36;
2066
+ context.fillStyle = index % 2 === 0 ? "rgba(255, 90, 31, 0.08)" : "rgba(255, 255, 255, 0)";
2067
+ context.fillRect(x + 16, rowY - 25, width - 32, 32);
2068
+ context.fillStyle = muted;
2069
+ context.fillText(label.toUpperCase(), x + 28, rowY, 180);
2070
+ context.fillStyle = label === "Cost" ? orange : ink;
2071
+ context.fillText(value, x + 230, rowY, width - 262);
2072
+ });
2073
+ }
2074
+
2075
+ function canvasToBlob(canvas) {
2076
+ return new Promise((resolve) => {
2077
+ if (canvas.toBlob) {
2078
+ canvas.toBlob(resolve, "image/png");
2079
+ return;
2080
+ }
2081
+ const dataUrl = canvas.toDataURL("image/png");
2082
+ const parts = dataUrl.split(",");
2083
+ const binary = atob(parts[1]);
2084
+ const bytes = new Uint8Array(binary.length);
2085
+ for (let index = 0; index < binary.length; index += 1) {
2086
+ bytes[index] = binary.charCodeAt(index);
2087
+ }
2088
+ resolve(new Blob([bytes], { type: "image/png" }));
2089
+ });
2090
+ }
2091
+
2092
+ function downloadBlob(blob, filename) {
2093
+ const link = document.createElement("a");
2094
+ link.href = URL.createObjectURL(blob);
2095
+ link.download = filename;
2096
+ link.click();
2097
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
2098
+ }
2099
+
2100
+ async function refreshStatus() {
2101
+ try {
2102
+ const response = await fetch(STATUS_SUMMARY_URL, { cache: "no-store" });
2103
+ if (!response.ok) throw new Error("status unavailable");
2104
+ const payload = await response.json();
2105
+ const status = payload.status || {};
2106
+ const description =
2107
+ typeof status === "string" ? status : status.description || "operational";
2108
+ qs("#statusLabel").textContent = `sovereign infra - ${description}`;
2109
+ qs("#statusDetail").textContent = payload.page?.updated_at
2110
+ ? ` - updated ${new Date(payload.page.updated_at).toLocaleString("en-GB")}`
2111
+ : "";
2112
+ } catch (_error) {
2113
+ qs("#statusLabel").textContent = "live status unavailable";
2114
+ qs("#statusDetail").textContent = "";
2115
+ }
2116
+ }
2117
+
2118
+ loadModels();
2119
+ refreshStatus();
2120
+ </script>
2121
+ </body>
2122
+ </html>
legal.html ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SovAPI Legal Notice</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1620'/%3E%3Ctext x='16' y='22' font-size='18' text-anchor='middle' fill='%23ff7a1a' font-family='Arial'%3ES%3C/text%3E%3C/svg%3E" />
8
+ <style>
9
+ :root {
10
+ --paper: #f3eadc;
11
+ --panel: #fff8ec;
12
+ --ink: #15110d;
13
+ --muted: #6f6255;
14
+ --line: #201812;
15
+ --orange: #ff5a1f;
16
+ }
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+ body {
21
+ background: var(--paper);
22
+ color: var(--ink);
23
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
24
+ line-height: 1.5;
25
+ margin: 0;
26
+ }
27
+ main {
28
+ margin: 0 auto;
29
+ max-width: 980px;
30
+ padding: 28px 18px 48px;
31
+ }
32
+ a {
33
+ color: inherit;
34
+ font-weight: 800;
35
+ }
36
+ .panel {
37
+ background: var(--panel);
38
+ border: 2px solid var(--line);
39
+ padding: 18px;
40
+ }
41
+ h1 {
42
+ font-size: clamp(42px, 11vw, 92px);
43
+ letter-spacing: 0;
44
+ line-height: 0.9;
45
+ margin: 0 0 18px;
46
+ text-transform: uppercase;
47
+ }
48
+ h2 {
49
+ border-top: 2px solid var(--line);
50
+ margin-top: 24px;
51
+ padding-top: 14px;
52
+ text-transform: uppercase;
53
+ }
54
+ dl {
55
+ display: grid;
56
+ gap: 10px;
57
+ grid-template-columns: minmax(160px, 240px) minmax(0, 1fr);
58
+ }
59
+ dt {
60
+ color: var(--muted);
61
+ font-weight: 800;
62
+ text-transform: uppercase;
63
+ }
64
+ dd {
65
+ margin: 0;
66
+ }
67
+ .kicker {
68
+ color: var(--orange);
69
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
70
+ font-weight: 900;
71
+ text-transform: uppercase;
72
+ }
73
+ .muted {
74
+ color: var(--muted);
75
+ }
76
+ @media (max-width: 700px) {
77
+ dl {
78
+ grid-template-columns: 1fr;
79
+ }
80
+ }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <main>
85
+ <p><a href="/">Back to SovAPI Arena</a></p>
86
+ <h1>Legal Notice</h1>
87
+ <section class="panel">
88
+ <p class="kicker">SovAPI Arena - legal information</p>
89
+ <h2>Publisher</h2>
90
+ <dl>
91
+ <dt>Company</dt>
92
+ <dd>FPLC SAS, operating the SovInfra and SovAPI services.</dd>
93
+ <dt>Registered office</dt>
94
+ <dd>10 Place Vendome, 75001 Paris, France.</dd>
95
+ <dt>Legal form</dt>
96
+ <dd>SAS, societe par actions simplifiee.</dd>
97
+ <dt>Share capital</dt>
98
+ <dd>168,000 EUR.</dd>
99
+ <dt>SIREN</dt>
100
+ <dd>934 780 917.</dd>
101
+ <dt>SIRET</dt>
102
+ <dd>934 780 917 00019.</dd>
103
+ <dt>RCS</dt>
104
+ <dd>Paris.</dd>
105
+ <dt>VAT number</dt>
106
+ <dd>FR13 934 780 917.</dd>
107
+ <dt>Publication director</dt>
108
+ <dd>Thierry Abimelech, Founder of SovInfra.</dd>
109
+ <dt>Contact</dt>
110
+ <dd><a href="mailto:contact@sovinfra.ai">contact@sovinfra.ai</a></dd>
111
+ </dl>
112
+
113
+ <h2>Service</h2>
114
+ <p>
115
+ SovAPI Arena is a public demonstration interface for SovAPI, an
116
+ OpenAI-compatible, modeless inference API. The Arena compares a live SovAPI
117
+ route with a selected challenger model and displays latency, throughput,
118
+ token usage and cost data returned by the live API responses.
119
+ </p>
120
+
121
+ <h2>Hosting</h2>
122
+ <p>
123
+ The Arena and the SovAPI gateway are hosted on SovInfra-controlled European
124
+ infrastructure. Public API traffic targets <code>https://api.sovinfra.ai/v1</code>.
125
+ Provider and gateway secrets are held server-side and are not embedded in browser
126
+ assets.
127
+ </p>
128
+
129
+ <h2>Intellectual property</h2>
130
+ <p>
131
+ SovInfra, SovAPI, the Arena interface, documentation, software and visual identity
132
+ are protected by applicable intellectual-property law. Third-party model names and
133
+ trademarks belong to their respective owners.
134
+ </p>
135
+
136
+ <h2>Personal data and GDPR</h2>
137
+ <p>
138
+ <strong>Data controller.</strong> FPLC SAS, 10 Place Vendome, 75001 Paris, France,
139
+ operates SovAPI Arena and acts as data controller for the processing described in
140
+ this notice. Privacy requests can be sent to
141
+ <a href="mailto:legal@sovinfra.eu">legal@sovinfra.eu</a>.
142
+ </p>
143
+ <p>
144
+ <strong>Data processed and purposes.</strong> To run a duel, the Arena transiently
145
+ processes the prompt and generated outputs, a pseudonymous browser session identifier,
146
+ token and request counters, and a one-way technical fingerprint derived from the IP
147
+ address and user-agent. These data are used only to provide the requested comparison,
148
+ calculate live metrics, enforce public-demo quotas, prevent abuse and secure the
149
+ service. The Arena does not use advertising or analytics trackers.
150
+ </p>
151
+ <p>
152
+ <strong>Legal basis.</strong> This processing is based on FPLC SAS's legitimate
153
+ interests in operating, measuring and securing its public demonstration service under
154
+ Article 6(1)(f) GDPR, and on compliance with legal obligations where applicable.
155
+ Providing a prompt is optional, but a prompt is required to run a duel.
156
+ </p>
157
+ <p>
158
+ <strong>Recipients and challenger providers.</strong> Data are available only to
159
+ authorised SovInfra personnel and technical providers where necessary to operate the
160
+ service. The SovAPI side is processed through SovInfra-controlled European
161
+ infrastructure. For the challenger side, the prompt is transmitted through OpenRouter
162
+ to the selected third-party model provider. That provider may process data outside the
163
+ European Economic Area, depending on the selected model and provider. Before using a
164
+ challenger, review the
165
+ <a href="https://openrouter.ai/privacy" rel="noopener noreferrer" target="_blank"
166
+ >OpenRouter privacy policy</a
167
+ >
168
+ and the selected provider's data practices.
169
+ </p>
170
+ <p>
171
+ <strong>Retention and local storage.</strong> The Arena application does not persist
172
+ prompts or generated outputs in its application database or production logs. The
173
+ pseudonymous session quota record expires after no more than 24 hours, and the
174
+ client-rate fingerprint expires after no more than one hour. The browser stores a
175
+ random session identifier and the selected theme in local storage until the user clears
176
+ site data. The Arena does not set advertising cookies.
177
+ </p>
178
+ <p>
179
+ <strong>Your rights.</strong> Subject to the conditions of the GDPR, you may request
180
+ access, rectification, erasure, restriction or portability of your personal data, and
181
+ object to its processing, by contacting
182
+ <a href="mailto:legal@sovinfra.eu">legal@sovinfra.eu</a>. FPLC SAS will respond without
183
+ undue delay and within one month. This period may be extended by up to two further
184
+ months for a complex request, in which case you will be informed within the first
185
+ month. If you believe your rights have not been respected, you may lodge a complaint
186
+ with the
187
+ <a
188
+ href="https://www.cnil.fr/fr/adresser-une-plainte"
189
+ rel="noopener noreferrer"
190
+ target="_blank"
191
+ >French Data Protection Authority (CNIL)</a
192
+ >.
193
+ </p>
194
+ <p>
195
+ The public Arena is intended for demonstration use. Do not submit confidential,
196
+ sensitive, regulated or third-party personal data in prompts.
197
+ </p>
198
+
199
+ <p class="muted">Last updated: July 2026.</p>
200
+ </section>
201
+ </main>
202
+ </body>
203
+ </html>
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi>=0.137,<0.138
2
+ uvicorn[standard]>=0.32,<1.0
terms.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SovAPI Terms</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1620'/%3E%3Ctext x='16' y='22' font-size='18' text-anchor='middle' fill='%23ff7a1a' font-family='Arial'%3ES%3C/text%3E%3C/svg%3E" />
8
+ <style>
9
+ :root {
10
+ --paper: #f3eadc;
11
+ --panel: #fff8ec;
12
+ --ink: #15110d;
13
+ --muted: #6f6255;
14
+ --line: #201812;
15
+ --orange: #ff5a1f;
16
+ }
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+ body {
21
+ background: var(--paper);
22
+ color: var(--ink);
23
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
24
+ line-height: 1.5;
25
+ margin: 0;
26
+ }
27
+ main {
28
+ margin: 0 auto;
29
+ max-width: 980px;
30
+ padding: 28px 18px 48px;
31
+ }
32
+ a {
33
+ color: inherit;
34
+ font-weight: 800;
35
+ }
36
+ .panel {
37
+ background: var(--panel);
38
+ border: 2px solid var(--line);
39
+ padding: 18px;
40
+ }
41
+ h1 {
42
+ font-size: clamp(42px, 11vw, 92px);
43
+ letter-spacing: 0;
44
+ line-height: 0.9;
45
+ margin: 0 0 18px;
46
+ text-transform: uppercase;
47
+ }
48
+ h2 {
49
+ border-top: 2px solid var(--line);
50
+ margin-top: 24px;
51
+ padding-top: 14px;
52
+ text-transform: uppercase;
53
+ }
54
+ .kicker {
55
+ color: var(--orange);
56
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
57
+ font-weight: 900;
58
+ text-transform: uppercase;
59
+ }
60
+ .muted {
61
+ color: var(--muted);
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <main>
67
+ <p><a href="/">Back to SovAPI Arena</a></p>
68
+ <h1>Terms</h1>
69
+ <section class="panel">
70
+ <p class="kicker">SovAPI Arena terms of use</p>
71
+
72
+ <h2>1. Acceptance</h2>
73
+ <p>
74
+ By accessing SovAPI Arena, you agree to these terms. If you do not agree, do not
75
+ use the Arena or the SovAPI demonstration endpoints.
76
+ </p>
77
+
78
+ <h2>2. Service description</h2>
79
+ <p>
80
+ The Arena is a public demonstration of SovAPI modeless routing. It sends one prompt
81
+ to SovAPI and, when available, to a selected challenger through a server-side relay,
82
+ then displays live metrics returned by the APIs.
83
+ </p>
84
+
85
+ <h2>3. API access</h2>
86
+ <p>
87
+ Production API usage requires a SovAPI key issued through the SovInfra portal at
88
+ <a href="https://app.sovinfra.ai" rel="noopener noreferrer" target="_blank">app.sovinfra.ai</a>.
89
+ The public model alias is <code>sovapi</code> and the OpenAI-compatible base URL is
90
+ <code>https://api.sovinfra.ai/v1</code>.
91
+ </p>
92
+
93
+ <h2>4. Trial grant</h2>
94
+ <p>
95
+ Eligible developers may receive a trial grant of 100 M tokens. Trial availability,
96
+ duration and limits may be changed or withdrawn to protect the service from abuse.
97
+ </p>
98
+
99
+ <h2>5. Pricing</h2>
100
+ <p>
101
+ SovAPI token pricing displayed in the Arena is 0.13 EUR per million input tokens
102
+ and 0.26 EUR per million output tokens, unless a signed agreement states otherwise.
103
+ Challenger costs are read from the provider response when available.
104
+ </p>
105
+
106
+ <h2>6. Public-demo quota</h2>
107
+ <p>
108
+ Public challenge sessions are limited to 30,000 challenger tokens per browser
109
+ session. This limit protects the OpenRouter key used by the server-side relay.
110
+ </p>
111
+
112
+ <h2>7. Acceptable use</h2>
113
+ <p>
114
+ You must not use the Arena to submit illegal, harmful, confidential, sensitive,
115
+ regulated or third-party personal data. You must not attempt to bypass quotas,
116
+ extract secrets, disrupt the service or resell access without written permission.
117
+ </p>
118
+
119
+ <h2>8. Secrets and provider keys</h2>
120
+ <p>
121
+ SovAPI keys, OpenRouter keys, gateway credentials and provider secrets are held
122
+ server-side. They must not be exposed in browser code, public repositories or shared
123
+ screenshots.
124
+ </p>
125
+
126
+ <h2>9. Benchmark information</h2>
127
+ <p>
128
+ Arena metrics are measured on live requests and may vary with prompt length, provider
129
+ availability, network conditions, GPU placement and selected challenger model. Shared
130
+ result cards must be read as demonstrations, not guaranteed performance commitments.
131
+ </p>
132
+
133
+ <h2>10. Generated content</h2>
134
+ <p>
135
+ Outputs are generated by AI systems and may be incomplete, inaccurate or unsuitable
136
+ for production use without review. You remain responsible for validating any output
137
+ before using it.
138
+ </p>
139
+
140
+ <h2>11. Intellectual property</h2>
141
+ <p>
142
+ The SovAPI Arena interface, copy, software and brand assets remain the property of
143
+ FPLC SAS / SovInfra. Third-party model names and trademarks belong to their respective
144
+ owners.
145
+ </p>
146
+
147
+ <h2>12. Availability</h2>
148
+ <p>
149
+ The Arena is provided as a demonstration service. It may be modified, suspended or
150
+ unavailable during maintenance, infrastructure changes, provider incidents or abuse
151
+ mitigation.
152
+ </p>
153
+
154
+ <h2>13. Liability</h2>
155
+ <p>
156
+ To the maximum extent permitted by law, FPLC SAS / SovInfra is not liable for indirect
157
+ damages, loss of business, loss of data, provider outages or decisions made from
158
+ unverified AI outputs.
159
+ </p>
160
+
161
+ <h2>14. Governing law and contact</h2>
162
+ <p>
163
+ These terms are governed by French law and applicable European Union law. For legal
164
+ questions, contact <a href="mailto:legal@sovinfra.eu">legal@sovinfra.eu</a>.
165
+ </p>
166
+
167
+ <p class="muted">Last updated: July 2026.</p>
168
+ </section>
169
+ </main>
170
+ </body>
171
+ </html>