Compare commits

...

17 Commits

Author SHA1 Message Date
oyi77
0f96a97e33 fix: browser-pool stub type errors, silent-fallback regression, grokClearance signature
Pre-merge fixes for the CloakBrowser/Playwright extraction into the
optional @omniroute/browser-pool workspace package:

- Add the @omniroute/browser-pool path mapping to the root tsconfig.json
  (tsconfig.typecheck-core.json extends the root, not open-sse/tsconfig.json,
  so typecheck:core was failing with 3x TS2307 in browserPool.ts).
- tryBackedChat() in browserBackedChat.ts silently returned the stale
  httpResult (the unsolved challenge/403 body) when the optional
  browser-pool package was absent, instead of surfacing an error. Now
  throws a descriptive error so callers (claude-web, duckduckgo-web) and
  upstream fallback handling can react correctly instead of treating an
  unsolved challenge as a definitive response. Restored
  resolveBrowserContextProxy (dropped during the browserPool.ts merge
  conflict resolution against origin/release/v3.8.49) which
  tests/unit/browserPool-proxy.test.ts already depends on.
- Fix acquireFreshGrokClearance stub signature in grokClearance.ts to
  match the real package impl and the grok-web.ts caller:
  (signal?: AbortSignal | null) => Promise<string | null>, not
  (prompt: string) => Promise<PooledContext | null>.
- Add tests/unit/browserBackedChat-optional-package-absent.test.ts
  covering the previously-silent "package absent + challenge response"
  regression, plus a __setBrowserPoolModOverrideForTesting() hook to
  simulate that path deterministically.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 20:35:58 -03:00
oyi77
e6d1f4b048 Merge remote-tracking branch 'origin/release/v3.8.49' into HEAD
# Conflicts:
#	open-sse/services/browserPool.ts
2026-07-23 20:12:03 -03:00
diegosouzapw
c525a0f452 Add SVG flags for various countries
- Added Sweden flag (se.svg)
- Added Slovakia flag (sk.svg)
- Added Thailand flag (th.svg)
- Added Turkey flag (tr.svg)
- Added Taiwan flag (tw.svg)
- Added Tanzania flag (tz.svg)
- Added Ukraine flag (ua.svg)
- Added United States flag (us.svg)
- Added Vietnam flag (vn.svg)
2026-07-23 14:55:50 -03:00
NOXX - Commiter
27f0ad0db0 fix(notion-web): use Chrome TLS impersonation for runInferenceTranscript (#8159)
Node/undici fetch is rejected by Notion's edge with HTTP 200
temporarily-unavailable and empty assistant text (messages appear in the
thread, UI shows 502 No response from Notion AI). The same cookie and body
succeed via curl/Schannel and a browser Chrome JA3 handshake.

Route inference through tls-client-node (chrome_146), matching Claude/
Perplexity web providers. Also detect nested patch-start error objects so
operators see temporarily-unavailable instead of a misleading empty-body
502, and treat that subtype as retryable.

Verified live: notion-web/fable-5, hyperagent/fable, and
promptql/vertex-claude-fable-5 all return PONG through the packaged
backend; unit tests 83/83.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 12:17:58 -03:00
Ravi Tharuma
08a21bcf27 fix(backend): add structure-aware chat admission (#8296)
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 11:59:20 -03:00
oyi77
4919aa89a9 fix: remove duplicate getMod/modPromise in browserBackedChat stub
Two copies of the module proxy got committed — the typed BrowserPoolModule
version at lines 50-56 and a stale any-typed duplicate at lines 64-71.
Removed the duplicate, keeping the typed version.

Verification:
- 40/40 browser tests pass (both previously-failing suites now green)
- typecheck:core: 0 errors
- env kill switch (OMNIROUTE_BROWSER_POOL=off): verified
2026-07-23 21:56:32 +07:00
oyi77
7c1e228353 fix: align three stub implementations with original code
- chatUrlMatcher: restore original 3-arg signature (u, matchDomain, chatUrl)
  with PLACEHOLDER-aware path segment matching
- shouldUseGrokBrowserBacked: remove required param, restore env-var logic
  checking both WEB_COOKIE_USE_BROWSER and OMNIROUTE_BROWSER_POOL
- browserPool.ts: add Turbopack rationale comment and join-trick helper
  to satisfy the optional-import test assertions
- browserBackedChat.ts: replace any types with typed BrowserPoolModule interface

Verification: 40/40 browser node:test pass, typecheck:core 0 errors
2026-07-23 21:27:44 +07:00
Sean Ford
14468cdec5 fix(bifrost): send v-prefixed transport version, not bare semver (#8194)
* fix(bifrost): send v-prefixed transport version, not bare semver

bifrost.ts resolveSpawnArgs() set BIFROST_TRANSPORT_VERSION straight from
getInstalledVersionSync(), which reads the raw "version" field out of
node_modules/@maximhq/bifrost/package.json - always bare semver per npm
convention (e.g. "1.6.3").

@maximhq/bifrost's own bin.js validates that env var against
/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ or the literal "latest" and rejects
anything else with "Invalid transport version format", exiting immediately.
Every embedded Bifrost instance failed to start as a result.

Adds formatTransportVersion() to normalize at the call site that owns the
env var, so bifrost.ts and the upstream @maximhq/bifrost package both stay
exactly as designed - no changes needed to bifrost itself.

Strengthens the existing resolveSpawnArgs test to assert the actual v-prefix
format (it previously only checked for a non-empty string, the same gap
#6877 called out for cliproxy's pre-existing test), and adds a dedicated
regression test file covering the pure formatTransportVersion() helper plus
a real-filesystem resolveSpawnArgs() integration check.

Found and fixed while self-hosting OmniRoute and diagnosing why bifrost
kept crash-looping on startup.

* fix(bifrost): resolve install dir lazily so version read honors runtime DATA_DIR

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: seanford <seanford@users.noreply.github.com>
2026-07-23 11:25:28 -03:00
Moseyuh333
d43e71613e optimize(chaos+ponytail): i18n ponytail, dedupl dispatch, provider diversity (#8264)
* feat(chaos+ponytail): parallel chaos-mode dispatch + ponytail output style (rebased on v3.8.49)

- Chaos mode: new auto/chaos variant fans the prompt out to the top-N
  stable models in parallel and returns a single merged SSE stream.
  - Progressive streaming: each panel model's answer is enqueued as it
    lands (omni-chaos-part event), instead of awaiting the whole panel.
  - withTimeout now aborts the underlying request (modelAbortSignal) on
    timeout so the connection is released, not leaked.
  - concatSseText parses both OpenAI and Anthropic SSE wire formats.
  - autoPrefix/modePacks add the chaos-mode weight pack; virtualFactory
    materializes auto/chaos with fusion strategy + chaos config flag.
- Ponytail (lazy-senior-dev mode) integrated into the existing
  OUTPUT_STYLE_CATALOG registry (id 'ponytail') so it rides the production
  output-style injector, instead of a bespoke duplicate module. Dev-only
  scripts and the duplicate ponytail/ module are removed.
- Tests: chaosEngine/chaosVirtualCombo cover panel dispatch, progressive
  broadcast, timeout abort, and Anthropic parsing; autoCombo pack count
  updated to 6.

Rebased onto release/v3.8.49 (no provider-registry or validation changes —
those are split out per review).

* optimize(chaos+ponytail): i18n ponytail, dedupl chaos dispatch, provider diversity

- Ponytail: add vi/ja/pt-BR/id i18n with lite/full/ultra levels
- chaosEngine: extract dispatchOnePanelModel (shared), add onResult for
  progressive SSE streaming, fix withTimeout anti-pattern
- virtualFactory: deduplicate chaos panel by provider, add tuning overrides
- dispatchChaosFromCombo: accept ChaosTuning, enforce minPanel
- Add/port 8 node-runner tests for ponytail i18n + catalog integrity
- Add muse-spark-web.ts to KNOWN_MISSING_ERROR_HELPER (pre-existing)

* fix(8264): use HandleSingleModel type in chaosEngine dispatch (base-drift)

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 11:21:53 -03:00
growab
e9f297021e fix(usage): correct token/request counting for 30D/90D/YTD/ALL ranges (#7300)
* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168)

* fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179)

* fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216)

* fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220)

* fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225)

* fix(usage): correct token/request counting for 30D/90D/YTD/ALL ranges

Two bugs caused incorrect usage statistics for date ranges beyond the
raw data retention window:

1. Cutoff mismatch: the analytics route computed rawCutoffDate from
   aggregation.rawDataRetentionDays (migration 046 seeds =7) while
   cleanupUsageHistory rolls up and deletes at retention.usageHistory
   (=30). The window [day-30, day-7) existed in usage_history but was
   excluded from BOTH UNION legs — raw leg floored at day-7, aggregated
   leg ended at day-7 — producing undercounted token sums for 30D,
   90D, YTD, and ALL ranges.

   Fix: use dbSettings.retention.usageHistory for the raw cutoff in
   both route.ts and getRawDataCutoffDate() (aggregateHistory.ts),
   matching the actual cleanup boundary.

2. Request undercount: COUNT(*) on the unified source counted each
   daily_usage_summary row as 1, not total_requests. A day with 50
   rolled-up requests counted as 1.

   Fix: add a 'requests' column to both UNION legs (raw: 1, aggregated:
   total_requests), change COUNT(*) to SUM(requests) in 6 query
   functions, and change successfulRequests from
   SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) to
   SUM(CASE WHEN success=1 THEN requests ELSE 0 END). Also set
   agg leg latency_ms to NULL so AVG(latency_ms) is not skewed.

Tests: 33/33 source-level tests pass (db-usageanalytics-split.test.ts),
verifying 'requests' column presence and SUM(requests) usage in all
affected queries. DB-level integration test added to
usage-analytics.test.ts (requires node + better-sqlite3).

* fix(ci): green CI reds on #7300 — file-size ratchet, stale test fixture, shallow-checkout selfref test

- src/app/api/usage/analytics/route.ts: trim the new comment to keep the file
  at the frozen file-size baseline (942 lines) after the retention.usageHistory
  cutoff fix — no logic change.
- tests/unit/usage-analytics-route.test.ts: the pre-existing "does not
  double-count raw and aggregated rows" test hardcoded a 30-day cutoff that
  matched the OLD (buggy) aggregation.rawDataRetentionDays default. Now that
  the raw/aggregated boundary correctly uses retention.usageHistory (365 days
  by default, matching cleanupUsageHistory's actual rollup/delete boundary),
  the fixture's synthetic "old" row was within the raw window and got
  excluded from the aggregated leg. Read the real retention setting instead
  of hardcoding 30 so the fixture reflects the corrected boundary. Same
  assertions (still expects no double-counting, totalRequests=2,
  totalTokens=185) — only the fixture dates change.
- tests/unit/check-test-masking-selfref-6634.test.ts: tolerate the shallow/
  single-ref checkout used by GitHub-hosted Unit Tests runners (no local
  origin/main ref) by fetching it on demand and skipping (never failing) when
  unreachable offline. Matches the fix already applied on another branch
  (2e42b8efc/#7174) for the same root cause, not yet on main.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* test(ci): make the #6634 selfref test checkout-independent (read the real file, no git ref)

The previous on-demand `git fetch origin main` + t.skip() fallback cleared the
shallow-checkout failure but tripped the PR Test Policy's test-masking gate
(a new .skip counts as a silenced assert — correctly so).

Drop the git dependency entirely instead: read the REAL current source of
tests/unit/check-test-masking.test.ts from disk (so the actual #6404 fixture
literals stay under test) and model the pre-#6404 state with an empty base,
which maximizes headTaut - baseTaut — the strictest input for the exclusion
this test asserts. No skip, no weakened assertion, same deepEqual guarantee.

Verified non-vacuous: neutralizing SELF_TEST_FIXTURE_RE in
scripts/check/check-test-masking.mjs makes this test fail; restoring it makes
it pass.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* test(ci): make the #6634 selfref guard hermetic — main's copy hard-fails every PR (#7341)

main's copy of this test still does git I/O inside a unit test:

    const baseSrc = git(['show', 'origin/main:' + FILE]);

Runners check out a shallow single ref, so origin/main does not resolve and the
test dies with 'fatal: invalid object name origin/main'. Every PR into main
fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336
and #7337, six PRs red on a defect none of them introduced. #7313 has no other
red at all.

release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch
origin/main on demand, t.skip() when unreachable), but it only reaches main at
release time — so main stays broken for the whole cycle. Cherry-picking it would
also import a new problem: PR Test Policy classifies t.skip() as a silenced
assertion, which we watched it correctly catch on #7300 today.

This is the hermetic version instead (ported from #7327, which does the same for
the release branch): read the file straight off disk, compare against an empty
base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point —
and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the
runner's checkout depth can break.

The #6634 regression stays covered: the guard's logic lives in
SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both
ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and
the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left
byte-identical.

Co-authored-by: growab <nekron@icloud.com>

* chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347)

main's ratchet had been failing --require-tighten on every PR: 11 metrics
improved but the baseline was never tightened. Same class as the #6634
selfref guard — an infra fix that lands only on the release branch leaves
main red for the whole cycle, and every PR into main pays for it.

Values are the merged-coverage numbers from a run on main itself (a local
run measures ~68% vs CI's ~80%; the baseline's own note warns about that
gap). Only the 11 coverage values change — gitleaks and semgrepFindings
keep main's own state.

No changelog fragment: #7326 carries it on release/v3.8.49, and a second
one here would double the entry at release time.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-23 11:15:18 -03:00
backryun
869d08ff8b Add Alibaba-family media model support (#8266)
* Add Alibaba-family media models

* chore(quality): rebaseline imageRegistry+cognitive for #8266 media own-growth

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 11:12:28 -03:00
Alberto Punter
2670674107 fix(i18n): re-sync and complete es-ES translations with latest release/v3.8.49 (#8289) 2026-07-23 11:07:08 -03:00
Ravi Tharuma
069d5a7925 fix(dashboard): stop sidebar RSC prefetch storms (#8292)
* fix(dashboard): disable sidebar route prefetch

Co-Authored-By: Claude <noreply@anthropic.com>

* test(dashboard): cover sidebar prefetch traffic

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 11:06:59 -03:00
Austin Liu
acd3d46ab4 fix(sanitizer): strip zero-width chars from Anthropic-native streaming text_delta (#8271) (#8287)
sanitizeStreamingChunk() only stripped zero-width characters (U+200B,
U+200C, U+200D, U+FEFF) from OpenAI-format choices[].delta.content.
Anthropic-native content_block_delta events with text_delta or
thinking_delta payloads bypassed that path entirely, leaking U+200D
to clients on the Messages API streaming route.

Add a content_block_delta branch that strips zero-width characters
from delta.text and delta.thinking before returning the event,
matching the existing OpenAI path behavior from #5857.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:51 -03:00
Austin Liu
99ad15a37f fix(resilience): skip terminal connections in token health check sweep (#8182) (#8286)
Background token health check sweep was probing connections with
terminal statuses (credits_exhausted / banned / expired) on every
cycle, wasting CPU and network. These connections can never self-heal
via token refresh — they need manual re-auth or credit top-up.

Add a terminal status guard in checkConnection() that mirrors the
existing isTerminalConnectionStatus() in auth.ts and
TERMINAL_CONNECTION_STATUSES in connectionRecovery.ts.

Verified by reporter: after manually disabling 11 credits_exhausted
connections, CPU dropped from ~53% to ~2.7%. This fix automates that
skip so the sweep never touches terminal connections in the first place.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:44 -03:00
Kayn Xu
56e2d2efb0 fix(providers): update Learn more documentation link (#8284)
* fix(providers): update Learn more documentation link

* docs(changelog): note provider documentation link fix
2026-07-23 11:06:36 -03:00
Austin Liu
d3cdd489be fix(cli): resolve claude.cmd on Windows in omniroute launch (#8246) (#8283)
spawn('claude') without shell:true cannot resolve the .cmd shim
that npm installs on Windows, causing ENOENT and a misleading
'not found in PATH' error. Use claude.cmd + shell:true + windowsHide
on win32, matching the pattern already used in launch-codex.mjs.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:28 -03:00
116 changed files with 11883 additions and 6007 deletions

View File

@@ -312,6 +312,14 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
# Hard message-count cap; excess receives compact-required 413. Default 800.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast

296
README.md
View File

@@ -66,56 +66,56 @@
</div>
<div align="center">
<b>🌐 In 43 languages</b>
<table>
<b>🌐 In 43 languages</b>
<table>
<tr>
<td align="center"><a href="README.md">🇺🇸</a></td>
<td align="center"><a href="docs/i18n/pt-BR/README.md">🇧🇷</a></td>
<td align="center"><a href="docs/i18n/pt/README.md">🇵🇹</a></td>
<td align="center"><a href="docs/i18n/es/README.md">🇪🇸</a></td>
<td align="center"><a href="docs/i18n/fr/README.md">🇫🇷</a></td>
<td align="center"><a href="docs/i18n/it/README.md">🇮🇹</a></td>
<td align="center"><a href="docs/i18n/de/README.md">🇩🇪</a></td>
<td align="center"><a href="docs/i18n/nl/README.md">🇳🇱</a></td>
<td align="center"><a href="docs/i18n/ru/README.md">🇷🇺</a></td>
<td align="center"><a href="docs/i18n/uk-UA/README.md">🇺🇦</a></td>
<td align="center"><a href="docs/i18n/pl/README.md">🇵🇱</a></td>
<td align="center"><a href="docs/i18n/cs/README.md">🇨🇿</a></td>
<td align="center"><a href="docs/i18n/sk/README.md">🇸🇰</a></td>
<td align="center"><a href="docs/i18n/ro/README.md">🇷🇴</a></td>
<td align="center"><a href="docs/i18n/hu/README.md">🇭🇺</a></td>
<td align="center"><a href="README.md"><img src="docs/assets/flags/us.svg" width="26" alt="English (en)"></a></td>
<td align="center"><a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="26" alt="Português — Brasil (pt-BR)"></a></td>
<td align="center"><a href="docs/i18n/pt/README.md"><img src="docs/assets/flags/pt.svg" width="26" alt="Português (pt)"></a></td>
<td align="center"><a href="docs/i18n/es/README.md"><img src="docs/assets/flags/es.svg" width="26" alt="Español (es)"></a></td>
<td align="center"><a href="docs/i18n/fr/README.md"><img src="docs/assets/flags/fr.svg" width="26" alt="Français (fr)"></a></td>
<td align="center"><a href="docs/i18n/it/README.md"><img src="docs/assets/flags/it.svg" width="26" alt="Italiano (it)"></a></td>
<td align="center"><a href="docs/i18n/de/README.md"><img src="docs/assets/flags/de.svg" width="26" alt="Deutsch (de)"></a></td>
<td align="center"><a href="docs/i18n/nl/README.md"><img src="docs/assets/flags/nl.svg" width="26" alt="Nederlands (nl)"></a></td>
<td align="center"><a href="docs/i18n/ru/README.md"><img src="docs/assets/flags/ru.svg" width="26" alt="Русский (ru)"></a></td>
<td align="center"><a href="docs/i18n/uk-UA/README.md"><img src="docs/assets/flags/ua.svg" width="26" alt="Українська (uk-UA)"></a></td>
<td align="center"><a href="docs/i18n/pl/README.md"><img src="docs/assets/flags/pl.svg" width="26" alt="Polski (pl)"></a></td>
<td align="center"><a href="docs/i18n/cs/README.md"><img src="docs/assets/flags/cz.svg" width="26" alt="Čeština (cs)"></a></td>
<td align="center"><a href="docs/i18n/sk/README.md"><img src="docs/assets/flags/sk.svg" width="26" alt="Slovenčina (sk)"></a></td>
<td align="center"><a href="docs/i18n/ro/README.md"><img src="docs/assets/flags/ro.svg" width="26" alt="Română (ro)"></a></td>
<td align="center"><a href="docs/i18n/hu/README.md"><img src="docs/assets/flags/hu.svg" width="26" alt="Magyar (hu)"></a></td>
</tr>
<tr>
<td align="center"><a href="docs/i18n/bg/README.md">🇧🇬</a></td>
<td align="center"><a href="docs/i18n/da/README.md">🇩🇰</a></td>
<td align="center"><a href="docs/i18n/fi/README.md">🇫🇮</a></td>
<td align="center"><a href="docs/i18n/no/README.md">🇳🇴</a></td>
<td align="center"><a href="docs/i18n/sv/README.md">🇸🇪</a></td>
<td align="center"><a href="docs/i18n/zh-CN/README.md">🇨🇳</a></td>
<td align="center"><a href="docs/i18n/zh-TW/README.md">🇹🇼</a></td>
<td align="center"><a href="docs/i18n/ja/README.md">🇯🇵</a></td>
<td align="center"><a href="docs/i18n/ko/README.md">🇰🇷</a></td>
<td align="center"><a href="docs/i18n/th/README.md">🇹🇭</a></td>
<td align="center"><a href="docs/i18n/vi/README.md">🇻🇳</a></td>
<td align="center"><a href="docs/i18n/id/README.md">🇮🇩</a></td>
<td align="center"><a href="docs/i18n/ms/README.md">🇲🇾</a></td>
<td align="center"><a href="docs/i18n/phi/README.md">🇵🇭</a></td>
<td align="center"><a href="docs/i18n/bg/README.md"><img src="docs/assets/flags/bg.svg" width="26" alt="Български (bg)"></a></td>
<td align="center"><a href="docs/i18n/da/README.md"><img src="docs/assets/flags/dk.svg" width="26" alt="Dansk (da)"></a></td>
<td align="center"><a href="docs/i18n/fi/README.md"><img src="docs/assets/flags/fi.svg" width="26" alt="Suomi (fi)"></a></td>
<td align="center"><a href="docs/i18n/no/README.md"><img src="docs/assets/flags/no.svg" width="26" alt="Norsk (no)"></a></td>
<td align="center"><a href="docs/i18n/sv/README.md"><img src="docs/assets/flags/se.svg" width="26" alt="Svenska (sv)"></a></td>
<td align="center"><a href="docs/i18n/zh-CN/README.md"><img src="docs/assets/flags/cn.svg" width="26" alt="中文 — 简体 (zh-CN)"></a></td>
<td align="center"><a href="docs/i18n/zh-TW/README.md"><img src="docs/assets/flags/tw.svg" width="26" alt="中文 — 繁體 (zh-TW)"></a></td>
<td align="center"><a href="docs/i18n/ja/README.md"><img src="docs/assets/flags/jp.svg" width="26" alt="日本語 (ja)"></a></td>
<td align="center"><a href="docs/i18n/ko/README.md"><img src="docs/assets/flags/kr.svg" width="26" alt="한국어 (ko)"></a></td>
<td align="center"><a href="docs/i18n/th/README.md"><img src="docs/assets/flags/th.svg" width="26" alt="ไทย (th)"></a></td>
<td align="center"><a href="docs/i18n/vi/README.md"><img src="docs/assets/flags/vn.svg" width="26" alt="Tiếng Việt (vi)"></a></td>
<td align="center"><a href="docs/i18n/id/README.md"><img src="docs/assets/flags/id.svg" width="26" alt="Bahasa Indonesia (id)"></a></td>
<td align="center"><a href="docs/i18n/ms/README.md"><img src="docs/assets/flags/my.svg" width="26" alt="Bahasa Melayu (ms)"></a></td>
<td align="center"><a href="docs/i18n/phi/README.md"><img src="docs/assets/flags/ph.svg" width="26" alt="Filipino (phi)"></a></td>
</tr>
<tr>
<td align="center"><a href="docs/i18n/in/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/hi/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/gu/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/mr/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/ta/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/te/README.md">🇮🇳</a></td>
<td align="center"><a href="docs/i18n/bn/README.md">🇧🇩</a></td>
<td align="center"><a href="docs/i18n/ur/README.md">🇵🇰</a></td>
<td align="center"><a href="docs/i18n/fa/README.md">🇮🇷</a></td>
<td align="center"><a href="docs/i18n/ar/README.md">🇸🇦</a></td>
<td align="center"><a href="docs/i18n/he/README.md">🇮🇱</a></td>
<td align="center"><a href="docs/i18n/tr/README.md">🇹🇷</a></td>
<td align="center"><a href="docs/i18n/az/README.md">🇦🇿</a></td>
<td align="center"><a href="docs/i18n/sw/README.md">🇹🇿</a></td>
<td align="center"><a href="docs/i18n/in/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="हिन्दी (in)"></a></td>
<td align="center"><a href="docs/i18n/hi/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="हिन्दी (hi)"></a></td>
<td align="center"><a href="docs/i18n/gu/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="ગુજરાતી (gu)"></a></td>
<td align="center"><a href="docs/i18n/mr/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="मराठी (mr)"></a></td>
<td align="center"><a href="docs/i18n/ta/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="தமிழ் (ta)"></a></td>
<td align="center"><a href="docs/i18n/te/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="తెలుగు (te)"></a></td>
<td align="center"><a href="docs/i18n/bn/README.md"><img src="docs/assets/flags/bd.svg" width="26" alt="বাংলা (bn)"></a></td>
<td align="center"><a href="docs/i18n/ur/README.md"><img src="docs/assets/flags/pk.svg" width="26" alt="اردو (ur)"></a></td>
<td align="center"><a href="docs/i18n/fa/README.md"><img src="docs/assets/flags/ir.svg" width="26" alt="فارسی (fa)"></a></td>
<td align="center"><a href="docs/i18n/ar/README.md"><img src="docs/assets/flags/sa.svg" width="26" alt="العربية (ar)"></a></td>
<td align="center"><a href="docs/i18n/he/README.md"><img src="docs/assets/flags/il.svg" width="26" alt="עברית (he)"></a></td>
<td align="center"><a href="docs/i18n/tr/README.md"><img src="docs/assets/flags/tr.svg" width="26" alt="Türkçe (tr)"></a></td>
<td align="center"><a href="docs/i18n/az/README.md"><img src="docs/assets/flags/az.svg" width="26" alt="Azərbaycan (az)"></a></td>
<td align="center"><a href="docs/i18n/sw/README.md"><img src="docs/assets/flags/tz.svg" width="26" alt="Kiswahili (sw)"></a></td>
</tr>
</table>
</div>
@@ -168,6 +168,40 @@ curl http://localhost:20128/v1/chat/completions \
<br/>
## 🤝 Supported by our Open Source Friends
<p align="center">
<a href="https://www.kimi.com/code?aff=omniroute">
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
</a>
</p>
> **Want to join as an Open Source Friend?** These are the companies that back open source and help keep OmniRoute moving — and we say publicly where every token they give us goes. Reach out: [diegosouza.pw@outlook.com](mailto:diegosouza.pw@outlook.com)
<table>
<tr>
<td align="center" width="150">
<a href="https://www.kimi.com/code?aff=omniroute">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
</picture>
</a>
<br/><b>Kimi</b><br/><sub>Moonshot AI</sub><br/><br/>
<img src="https://img.shields.io/badge/Founding_Friend-1783FF?style=flat-square" alt="Founding Open Source Friend"/>
</td>
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
</td>
</tr>
</table>
<sub>Links tagged <code>aff=omniroute</code> are partner links. They fund the project at no extra cost to you.</sub>
<br/>
<div align="center">
# 🎯 Combos — The Flagship
@@ -273,6 +307,16 @@ All **19** strategies — mix & match per combo step:
<br/>
## ❤️ Support
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
-**Star the repo** — it genuinely helps visibility
- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — fund ongoing maintenance and new providers
- 🐛 **Report bugs and share feedback** in [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions)
<br/>
<div align="center">
# ✨ What's New
@@ -829,67 +873,57 @@ same process on one port, so there is no separate CLI-only package today.
### 📘 Getting Started
| Document | Description                                                                                                                                                                          |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles |
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
- **[User Guide](docs/guides/USER_GUIDE.md)** — Providers, combos, CLI integration, deployment
- **[Setup Guide](docs/guides/SETUP_GUIDE.md)** — Full install methods, CLI tool configs, protocol setup, timeout tuning
- **[CLI Tools Guide](docs/reference/CLI-TOOLS.md)** — Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot
- **[Remote Mode](docs/guides/REMOTE-MODE.md)** — Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens
- **[Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md)** — Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles
- **[Quick Start](README.md#-quick-start)** — 3-step install → connect → configure
### 🔧 Operations & Deployment
| Document | Description                                                                                                                                                                         |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux |
| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
| [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods |
| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references |
- **[Docker Guide](docs/guides/DOCKER_GUIDE.md)** — Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags
- **[Podman Guide](contrib/podman/README.md)** — Quadlet systemd integration, podman-compose, SELinux
- **[VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md)** — Complete guide: VM + nginx + Cloudflare setup
- **[Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md)** — Deploy to Fly.io with persistent storage
- **[Termux Guide](docs/guides/TERMUX_GUIDE.md)** — Run OmniRoute on Android via Termux
- **[PWA Guide](docs/guides/PWA_GUIDE.md)** — Progressive Web App install, caching, architecture
- **[Uninstall Guide](docs/guides/UNINSTALL.md)** — Clean removal for all install methods
- **[Environment Config](docs/reference/ENVIRONMENT.md)** — Complete `.env` variables and references
### 🧠 Features & Architecture
| Document | Description                                                                                                                                                        |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals |
| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 12-factor scoring, mode packs, self-healing |
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
- **[Architecture](docs/architecture/ARCHITECTURE.md)** — System architecture, data flow, and internals
- **[Compression Guide](docs/compression/COMPRESSION_GUIDE.md)** — 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked
- **[RTK Compression](docs/compression/RTK_COMPRESSION.md)** — Command-output compression, filters, trust, verify, raw-output recovery
- **[Compression Engines](docs/compression/COMPRESSION_ENGINES.md)** — Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces
- **[Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md)** — JSON rule-pack schemas for Caveman and RTK filters
- **[Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md)** — Language detection and Caveman rule-pack authoring
- **[Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)** — Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing
- **[Auto-Combo Engine](docs/routing/AUTO-COMBO.md)** — 12-factor scoring, mode packs, self-healing
- **[Proxy Guide](docs/ops/PROXY_GUIDE.md)** — 3-level proxy system, 1proxy marketplace, registry CRUD
- **[Free Tiers](docs/reference/FREE_TIERS.md)** — 25+ free API providers consolidated directory
- **[Features Gallery](docs/guides/FEATURES.md)** — Visual dashboard tour with screenshots
- **[Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md)** — Beginner-friendly codebase walkthrough
### 🤖 Protocols & APIs
| Document | Description                                                                                                                                                                             |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification |
| [MCP Server](open-sse/mcp-server/README.md) | 104 MCP tools, IDE configs, Python/TS/Go clients |
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
- **[API Reference](docs/reference/API_REFERENCE.md)** — All endpoints with examples
- **[OpenAPI Spec](docs/openapi.yaml)** — OpenAPI 3.0 specification
- **[MCP Server](open-sse/mcp-server/README.md)** — 104 MCP tools, IDE configs, Python/TS/Go clients
- **[MCP Server Guide](docs/frameworks/MCP-SERVER.md)** — MCP installation, transports, and tool reference
- **[A2A Server](src/lib/a2a/README.md)** — JSON-RPC 2.0 protocol, skills, streaming, task mgmt
- **[A2A Server Guide](docs/frameworks/A2A-SERVER.md)** — A2A agent card, tasks, skills, and streaming
### 📋 Project & Quality
| Document | Description                                                                                                                                                                              |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
| [Branching & Release Model](docs/ops/BRANCHING_MODEL.md) | Where PRs target (`release/*`), what `main` and tags mean |
| [Changelog](CHANGELOG.md) | Full per-version release history |
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL |
| [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps |
| [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 25,000+ test suite |
- **[Contributing](CONTRIBUTING.md)** — Development setup and guidelines
- **[Branching & Release Model](docs/ops/BRANCHING_MODEL.md)** — Where PRs target (`release/*`), what `main` and tags mean
- **[Changelog](CHANGELOG.md)** — Full per-version release history
- **[Security Policy](SECURITY.md)** — Vulnerability reporting and security practices
- **[i18n Guide](docs/guides/I18N.md)** — 40+ language support, translation workflow, RTL
- **[Release Checklist](docs/ops/RELEASE_CHECKLIST.md)** — Pre-release validation steps
- **[Coverage Plan](docs/ops/COVERAGE_PLAN.md)** — Test coverage strategy and 25,000+ test suite
<br/>
@@ -994,6 +1028,24 @@ same process on one port, so there is no separate CLI-only package today.
<br/>
## 💖 Sponsors
A heartfelt thank-you to the people who fund OmniRoute out of their own pocket. Every contribution keeps the project free, independent and moving.
<p align="center">
<a href="https://github.com/igormorais123"><img src="https://github.com/igormorais123.png?size=140" width="80" alt="Professor Igor Morais Vasconcelos (@igormorais123)"/></a>
&nbsp;&nbsp;&nbsp;
<a href="https://github.com/longtao77"><img src="https://github.com/longtao77.png?size=140" width="80" alt="longtao (@longtao77)"/></a>
</p>
<p align="center">
<sub><b><a href="https://github.com/igormorais123">Professor Igor Morais Vasconcelos</a></b> &nbsp;·&nbsp; <b><a href="https://github.com/longtao77">longtao</a></b> &nbsp;·&nbsp; and others who prefer to stay private 💛</sub>
</p>
> **Want to support OmniRoute?** <a href="https://github.com/sponsors/diegosouzapw"><b>Become a sponsor →</b></a> Every dollar goes straight into keeping the project free and independent.
<br/>
<div align="center">
## 👥 500+ Contributors
@@ -1136,62 +1188,6 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **[CodeWebChat](https://github.com/robertpiosik/CodeWebChat)** · robertpiosik | Editor-side companion — VS Code + browser extension that autofills 15+ chatbot web UIs with editor context. Owns the free-web-UI rail alongside OmniRoute's API rail; can point its API mode at OmniRoute. |
## 🤝 Supported by our Open Source Friends
<p align="center">
<a href="https://www.kimi.com/code?aff=omniroute">
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
</a>
</p>
> **Want to join as an Open Source Friend?** These are the companies that back open source and help keep OmniRoute moving — and we say publicly where every token they give us goes. Reach out: [diegosouza.pw@outlook.com](mailto:diegosouza.pw@outlook.com)
<table>
<tr>
<td align="center" width="150">
<a href="https://www.kimi.com/code?aff=omniroute">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
</picture>
</a>
<br/><b>Kimi</b><br/><sub>Moonshot AI</sub><br/><br/>
<img src="https://img.shields.io/badge/Founding_Friend-1783FF?style=flat-square" alt="Founding Open Source Friend"/>
</td>
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
</td>
</tr>
</table>
<sub>Links tagged <code>aff=omniroute</code> are partner links. They fund the project at no extra cost to you.</sub>
## 💖 Sponsors
A heartfelt thank-you to the people who fund OmniRoute out of their own pocket. Every contribution keeps the project free, independent and moving.
<p align="center">
<a href="https://github.com/igormorais123"><img src="https://github.com/igormorais123.png?size=140" width="80" alt="Professor Igor Morais Vasconcelos (@igormorais123)"/></a>
&nbsp;&nbsp;&nbsp;
<a href="https://github.com/longtao77"><img src="https://github.com/longtao77.png?size=140" width="80" alt="longtao (@longtao77)"/></a>
</p>
<p align="center">
<sub><b><a href="https://github.com/igormorais123">Professor Igor Morais Vasconcelos</a></b> &nbsp;·&nbsp; <b><a href="https://github.com/longtao77">longtao</a></b> &nbsp;·&nbsp; and others who prefer to stay private 💛</sub>
</p>
> **Want to support OmniRoute?** <a href="https://github.com/sponsors/diegosouzapw"><b>Become a sponsor →</b></a> Every dollar goes straight into keeping the project free and independent.
## ❤️ Support
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
-**Star the repo** — it genuinely helps visibility
- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — fund ongoing maintenance and new providers
- 🐛 **Report bugs and share feedback** in [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions)
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.

View File

@@ -106,10 +106,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
if (!res.ok) throw new Error(`status ${res.status}`);
} catch {
console.error(
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
baseUrl
)
(
t("launch.notRunning") ||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
).replace("{port}", baseUrl)
);
return 1;
}
@@ -120,7 +120,14 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
return await new Promise((resolve) => {
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
// #8246: on Windows, npm installs claude as a .cmd shim — spawn() without
// shell:true cannot resolve PATHEXT shims and fails with ENOENT.
const claudeCommand = process.platform === "win32" ? "claude.cmd" : "claude";
const child = spawn(claudeCommand, claudeArgs, {
env,
stdio: "inherit",
...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
});
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
@@ -142,7 +149,10 @@ export function registerLaunch(program) {
)
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
.option(
"--profile <name>",
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
)
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true)

View File

@@ -0,0 +1 @@
- **fix(providers):** the Providers page Learn more button now opens the maintained GitHub documentation instead of the retired `docs.omniroute.io` page ([#8284](https://github.com/diegosouzapw/OmniRoute/pull/8284)) - thanks @KaynXu

View File

@@ -54,11 +54,6 @@
"count": 3
}
},
"open-sse/handlers/imageGeneration.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"open-sse/handlers/musicGeneration.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -1845,11 +1840,6 @@
"count": 3
}
},
"tests/unit/prompt-required-routes.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"tests/unit/provider-connection-apikey-dedup.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -2430,4 +2420,4 @@
"count": 5
}
}
}
}

View File

@@ -159,7 +159,8 @@
"_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.",
"open-sse/services/qoderCli.ts": 989,
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"open-sse/config/imageRegistry.ts": 821,
"_rebaseline_2026_07_23_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.",
"open-sse/config/imageRegistry.ts": 979,
"open-sse/config/providerRegistry.ts": 4731,
"open-sse/executors/antigravity.ts": 1813,
"open-sse/executors/base.ts": 1540,

View File

@@ -123,7 +123,7 @@
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
},
"cognitiveComplexity": {
"value": 950,
"value": 951,
"_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.",
"_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.",
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.",
@@ -146,7 +146,8 @@
"_rebaseline_2026_07_06_v3845_release_close": "867->877 (+10). v3.8.45 cycle drift measured by check:release-green (hermetic) on release tip 5ecca12aa5 during the /generate-release Phase 0 pre-flight. Inherited from the cycle's merge burst (cognitive-complexity does not run on PR->release fast-gates); the captain's pre-flight fixes are gate/test/workflow changes (complexity-neutral). Tighten via --update next cycle.",
"_rebaseline_2026_07_07_6519_chirag_fallback_reasons": "882->883 (+1). PR #6519 (@chirag127, #6461): the preview route's fallbackReasons dedup loop adds one function over the cognitive threshold. Owner-approved rebaseline (contributor own-growth). Tighten via --update next cycle.",
"_rebaseline_2026_07_08_6556_inherited_drift": "883->884 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (cognitive-complexity nao roda no fast-path PR->release). Trust-but-verify: check:cognitive-complexity mede 884 IDENTICO na base pura origin/release/v3.8.47 e neste HEAD — o PR e cognitive-net-zero (runCompressionAsync extraido para engines/omniglyphSingleMode.ts e o page client dividido em section components na mesma rodada). Tighten via --update next cycle.",
"_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "890->900 (owner-approved, 2026-07-19). Same /fix-prs cluster as the complexity note: the clean-by-cyclomatic subset still bumped cognitive-complexity (measured 896 on the 9-PR combined probe tip vs 890 baseline). Raised to 900 = 896 + 4 headroom, matching the complexity rebaseline. Tighten via --update next cycle."
"_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "890->900 (owner-approved, 2026-07-19). Same /fix-prs cluster as the complexity note: the clean-by-cyclomatic subset still bumped cognitive-complexity (measured 896 on the 9-PR combined probe tip vs 890 baseline). Raised to 900 = 896 + 4 headroom, matching the complexity rebaseline. Tighten via --update next cycle.",
"_note_8266": "2026-07-23: +1 own-growth from #8266 (@backryun) Alibaba media provider dispatch wiring"
},
"typeCoveragePct": {
"value": 92.17,

View File

@@ -0,0 +1,19 @@
# Flag icons
SVG country flags used by the language selector in the root `README.md`.
They are served locally (instead of Unicode flag emoji) because regional-indicator
flag emoji do **not** render on Windows and several Safari/WebKit configurations,
which broke the "In 43 languages" table on those platforms.
## Source & license
Flags are from [lipis/flag-icons](https://github.com/lipis/flag-icons)
(`flags/4x3/`), licensed **MIT**. Each file is named by its ISO 3166-1 alpha-2
country code (e.g. `br.svg`, `us.svg`). The six Indian-language cells (`in`, `hi`,
`gu`, `mr`, `ta`, `te`) reuse the single `in.svg`.
```
MIT License — Copyright (c) 2013 Panayiotis Lipiridis
https://github.com/lipis/flag-icons/blob/main/LICENSE
```

8
docs/assets/flags/az.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-az" viewBox="0 0 640 480">
<path fill="#3f9c35" d="M.1 0h640v480H.1z"/>
<path fill="#ed2939" d="M.1 0h640v320H.1z"/>
<path fill="#00b9e4" d="M.1 0h640v160H.1z"/>
<circle cx="304" cy="240" r="72" fill="#fff"/>
<circle cx="320" cy="240" r="60" fill="#ed2939"/>
<path fill="#fff" d="m384 200 7.7 21.5 20.6-9.8-9.8 20.7L424 240l-21.5 7.7 9.8 20.6-20.6-9.8L384 280l-7.7-21.5-20.6 9.8 9.8-20.6L344 240l21.5-7.7-9.8-20.6 20.6 9.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

4
docs/assets/flags/bd.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bd" viewBox="0 0 640 480">
<path fill="#006a4e" d="M0 0h640v480H0z"/>
<circle cx="280" cy="240" r="160" fill="#f42a41"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

5
docs/assets/flags/bg.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bg" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#00966e" d="M0 160h640v160H0z"/>
<path fill="#d62612" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

45
docs/assets/flags/br.svg Normal file
View File

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
<g stroke-width="1pt">
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
</g>
<g stroke-opacity=".5">
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
</g>
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

11
docs/assets/flags/cn.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-cn" viewBox="0 0 640 480">
<defs>
<path id="cn-a" fill="#ff0" d="M-.6.8 0-1 .6.8-1-.3h2z"/>
</defs>
<path fill="#ee1c25" d="M0 0h640v480H0z"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(71.9991 0 0 72 120 120)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-12.33562 -20.5871 20.58684 -12.33577 240.3 48)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(-3.38573 -23.75998 23.75968 -3.38578 288 95.8)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(6.5991 -23.0749 23.0746 6.59919 288 168)"/>
<use xlink:href="#cn-a" width="30" height="20" transform="matrix(14.9991 -18.73557 18.73533 14.99929 240 216)"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

5
docs/assets/flags/cz.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-cz" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v240H0z"/>
<path fill="#d7141a" d="M0 240h640v240H0z"/>
<path fill="#11457e" d="M360 240 0 0v480z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

5
docs/assets/flags/de.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
<path fill="#fc0" d="M0 320h640v160H0z"/>
<path fill="#000001" d="M0 0h640v160H0z"/>
<path fill="red" d="M0 160h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

5
docs/assets/flags/dk.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-dk" viewBox="0 0 640 480">
<path fill="#c8102e" d="M0 0h640.1v480H0z"/>
<path fill="#fff" d="M205.7 0h68.6v480h-68.6z"/>
<path fill="#fff" d="M0 205.7h640.1v68.6H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

544
docs/assets/flags/es.svg Normal file
View File

@@ -0,0 +1,544 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-es" viewBox="0 0 640 480">
<path fill="#AA151B" d="M0 0h640v480H0z"/>
<path fill="#F1BF00" d="M0 120h640v240H0z"/>
<path fill="#ad1519" d="m127.3 213.3-.8-.1-1-1-.7-.4-.6-.8s-.7-1.1-.4-2q.5-1.2 1.4-1.5l1.5-.5 1-.4 1.3-.3.5-.3 1-.2 1-.2 1.6.1h4.8c.4 0 1.2.3 1.4.4l2 .7c.5.1 1.6.3 2.2.6q.8.5 1.1 1l.5 1v1.1l-.5.8-.6 1-.8.6s-.5.5-1 .4-4.8-.8-7.6-.8-7.3.9-7.3.9"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="m127.3 213.3-.8-.1-1-1-.7-.4-.6-.8s-.7-1.1-.4-2q.5-1.2 1.4-1.5l1.5-.5 1-.4 1.3-.3.5-.3 1-.2 1-.2 1.6.1h4.8c.4 0 1.2.3 1.4.4l2 .7c.5.1 1.6.3 2.2.6q.8.5 1.1 1l.5 1v1.1l-.5.8-.6 1-.8.6s-.5.5-1 .4-4.8-.8-7.6-.8-7.3.9-7.3.9z"/>
<path fill="#c8b100" d="M133.3 207q.1-2.1 1.3-2.3 1.3.1 1.4 2.4c0 1.3-.6 2.4-1.4 2.4s-1.3-1.1-1.3-2.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M133.3 207q.1-2.1 1.3-2.3 1.3.1 1.4 2.4c0 1.3-.6 2.4-1.4 2.4s-1.3-1.1-1.3-2.5z"/>
<path fill="#c8b100" d="M134 207q0-2 .7-2.1c.3 0 .6 1 .6 2.1q0 2-.6 2.2c-.4 0-.6-1-.6-2.2"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134 207q0-2 .7-2.1c.3 0 .6 1 .6 2.1q0 2-.6 2.2c-.4 0-.6-1-.6-2.2z"/>
<path fill="#c8b100" d="M133.8 204.5q.1-.7.8-.8 1 0 1 .8-.1.8-1 .9c-.9.1-.8-.4-.8-.9"/>
<path fill="#c8b100" d="M135.3 204.2v.6h-1.4v-.6h.5V203h-.7v-.6h.7v-.5h.5v.5h.6v.6h-.6v1.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M135.3 204.2v.6h-1.4v-.6h.5V203h-.7v-.6h.7v-.5h.5v.5h.6v.6h-.6v1.2h.4"/>
<path fill="#c8b100" d="M135.9 204.2v.6h-2.5v-.6h1V203h-.7v-.6h.7v-.5h.5v.5h.6v.6h-.6v1.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M135.9 204.2v.6h-2.5v-.6h1V203h-.7v-.6h.7v-.5h.5v.5h.6v.6h-.6v1.2h1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134.9 203.7q.6.2.6.8-.1.8-.8.9-1 0-1-.9 0-.6.7-.8"/>
<path fill="#c8b100" d="M134.7 213.2H130v-1.1l-.3-1.2-.2-1.5c-1.3-1.7-2.5-2.8-2.9-2.5q.1-.5.5-.7c1.1-.7 3.5 1 5.2 3.6l.5.7h3.8l.4-.7c1.8-2.7 4.1-4.3 5.2-3.6q.4.2.5.7c-.4-.3-1.6.8-2.9 2.5l-.2 1.5-.2 1.2-.1 1.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134.7 213.2H130v-1.1l-.3-1.2-.2-1.5c-1.3-1.7-2.5-2.8-2.9-2.5q.1-.5.5-.7c1.1-.7 3.5 1 5.2 3.6l.5.7h3.8l.4-.7c1.8-2.7 4.1-4.3 5.2-3.6q.4.2.5.7c-.4-.3-1.6.8-2.9 2.5l-.2 1.5-.2 1.2-.1 1.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M126.8 206.8c1-.5 3 1.1 4.6 3.6m11-3.6c-.8-.5-2.8 1.1-4.5 3.6"/>
<path fill="#c8b100" d="m127.8 215.3-.5-1a27 27 0 0 1 14.7 0l-.5.8-.3.8a23 23 0 0 0-6.6-.8c-2.6 0-5.2.3-6.5.8z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m127.8 215.3-.5-1a27 27 0 0 1 14.7 0l-.5.8-.3.8a23 23 0 0 0-6.6-.8c-2.6 0-5.2.3-6.5.8l-.3-.6"/>
<path fill="#c8b100" d="M134.6 217.7c2.4 0 5-.4 5.9-.6q1-.3 1-.8 0-.3-.4-.4c-1.4-.5-4-.8-6.5-.8s-5 .3-6.4.8q-.3 0-.4.3 0 .6 1 .9c1 .2 3.5.6 5.8.6"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134.6 217.7c2.4 0 5-.4 5.9-.6q1-.3 1-.8 0-.3-.4-.4c-1.4-.5-4-.8-6.5-.8s-5 .3-6.4.8q-.3 0-.4.3 0 .6 1 .9c1 .2 3.5.6 5.8.6z"/>
<path fill="#c8b100" d="m142.1 213.2-.5-.5s-.6.3-1.3.2c-.6 0-.9-1-.9-1s-.7.7-1.3.7-1-.6-1-.6-.7.5-1.3.4-1.2-.8-1.2-.8-.6.8-1.2.8c-.6.1-1-.5-1-.5s-.4.6-1.1.7-1.4-.6-1.4-.6-.5.7-1 1c-.5 0-1.2-.4-1.2-.4l-.2.5-.3.1.2.5a27 27 0 0 1 7.2-.9q4.5 0 7.4 1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m142.1 213.2-.5-.5s-.6.3-1.3.2c-.6 0-.9-1-.9-1s-.7.7-1.3.7-1-.6-1-.6-.7.5-1.3.4-1.2-.8-1.2-.8-.6.8-1.2.8c-.6.1-1-.5-1-.5s-.4.6-1.1.7-1.4-.6-1.4-.6-.5.7-1 1c-.5 0-1.2-.4-1.2-.4l-.2.5-.3.1.2.5a27 27 0 0 1 7.2-.9q4.5 0 7.4 1z"/>
<path fill="#c8b100" d="M134.7 210.7h.2v.4q0 1 1 1a1 1 0 0 0 1-.7l.2-.3v.4q.3.8 1.1.8 1 0 1-1v-.1l.4-.4.2.5-.1.4a1 1 0 0 0 1 1q.6 0 .9-.5l.2-.2v.3q0 .5.4.7l1-.4.7-.7v.4s-.5.8-1 1q-.3.4-.8.3-.5 0-.7-.6-.3.3-.7.2-1 0-1.4-.8-.5.5-1.1.5a2 2 0 0 1-1.2-.6l-1 .4a2 2 0 0 1-1.3-.6 1.6 1.6 0 0 1-2.4.2 2 2 0 0 1-1.2.6l-1.1-.5q-.4.8-1.4.8l-.7-.2q-.2.5-.7.6t-.9-.2l-1-1 .1-.5.8.7q.8.5.9.4.4-.1.4-.7v-.3l.2.2q.3.5.9.5a1 1 0 0 0 1-1v-.9l.4.4v.1q.1 1 1 1t1.1-.9v-.3l.2.3q.3.6 1 .7 1 0 1.1-1v-.3h.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134.7 210.7h.2v.4q0 1 1 1a1 1 0 0 0 1-.7l.2-.3v.4q.3.8 1.1.8 1 0 1-1v-.1l.4-.4.2.5-.1.4a1 1 0 0 0 1 1q.6 0 .9-.5l.2-.2v.3q0 .5.4.7l1-.4.7-.7v.4s-.5.8-1 1q-.3.4-.8.3-.5 0-.7-.6-.3.3-.7.2-1 0-1.4-.8-.5.5-1.1.5a2 2 0 0 1-1.2-.6l-1 .4a2 2 0 0 1-1.3-.6 1.6 1.6 0 0 1-2.4.2 2 2 0 0 1-1.2.6l-1.1-.5q-.4.8-1.4.8l-.7-.2q-.2.5-.7.6t-.9-.2l-1-1 .1-.5.8.7q.8.5.9.4.4-.1.4-.7v-.3l.2.2q.3.5.9.5a1 1 0 0 0 1-1v-.9l.4.4v.1q.1 1 1 1t1.1-.9v-.3l.2.3q.3.6 1 .7 1 0 1.1-1v-.3h.3z"/>
<path fill="#c8b100" d="M134.6 213.3q-4.5 0-7.3 1l-.3-.2.1-.3a27 27 0 0 1 7.5-1q4.6 0 7.6 1l.1.3-.3.2a27 27 0 0 0-7.4-1"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M134.6 213.3q-4.5 0-7.3 1l-.3-.2.1-.3a27 27 0 0 1 7.5-1q4.6 0 7.6 1l.1.3-.3.2a27 27 0 0 0-7.4-1z"/>
<path fill="#fff" d="M131.8 214.4q0-.4.5-.4a.4.4 0 0 1 .4.4q0 .3-.4.4a.4.4 0 0 1-.5-.4"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M131.8 214.4q0-.4.5-.4a.4.4 0 0 1 .4.4q0 .3-.4.4a.4.4 0 0 1-.5-.4z"/>
<path fill="#ad1519" d="M134.7 214.5h-1q-.2 0-.3-.3l.3-.3h2l.2.3-.3.3h-1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M134.7 214.5h-1q-.2 0-.3-.3l.3-.3h2l.2.3-.3.3h-1"/>
<path fill="#058e6e" d="M130 214.9h-.7q-.2 0-.3-.2l.2-.3.7-.1.7-.1q.3 0 .4.2a.3.3 0 0 1-.3.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M130 214.9h-.7q-.2 0-.3-.2l.2-.3.7-.1.7-.1q.3 0 .4.2a.3.3 0 0 1-.3.4h-.7"/>
<path fill="#ad1519" d="m127.3 215.3.3-.4h.7l-.4.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m127.3 215.3.3-.4h.7l-.4.6-.6-.2"/>
<path fill="#fff" d="M136.6 214.4q0-.4.4-.4a.4.4 0 0 1 .5.4.4.4 0 0 1-.5.4.4.4 0 0 1-.4-.4"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M136.6 214.4q0-.4.4-.4a.4.4 0 0 1 .5.4.4.4 0 0 1-.5.4.4.4 0 0 1-.4-.4z"/>
<path fill="#058e6e" d="M139.3 214.9h.6a.3.3 0 0 0 .4-.2l-.3-.3-.6-.1-.7-.1q-.3 0-.4.2 0 .3.3.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M139.3 214.9h.6a.3.3 0 0 0 .4-.2l-.3-.3-.6-.1-.7-.1q-.3 0-.4.2 0 .3.3.4h.7"/>
<path fill="#ad1519" d="m142 215.4-.3-.5h-.7l.3.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m142 215.4-.3-.5h-.7l.3.6.6-.1"/>
<path fill="#ad1519" d="M134.6 217.1a25 25 0 0 1-6-.6 26 26 0 0 1 12.1 0q-2.5.6-6 .6"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M134.6 217.1a25 25 0 0 1-6-.6 26 26 0 0 1 12.1 0q-2.5.6-6 .6z"/>
<path fill="#c8b100" d="m142 212-.1-.3q-.3 0-.4.2 0 .4.2.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m142 212-.1-.3q-.3 0-.4.2 0 .4.2.4z"/>
<path fill="#c8b100" d="M137.3 211.2q0-.3-.2-.4l-.2.3q0 .3.2.4l.3-.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M137.3 211.2q0-.3-.2-.4l-.2.3q0 .3.2.4l.3-.3z"/>
<path fill="#c8b100" d="m132 211.2.1-.4q.3 0 .3.3t-.2.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m132 211.2.1-.4q.3 0 .3.3t-.2.4z"/>
<path fill="#c8b100" d="m127.3 212 .1-.3q.3 0 .4.2 0 .4-.2.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m127.3 212 .1-.3q.3 0 .4.2 0 .4-.2.4z"/>
<path fill="#c8b100" d="m134.6 208.5-.8.5.6 1.3.2.1.2-.1.7-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m134.6 208.5-.8.5.6 1.3.2.1.2-.1.7-1.3-.9-.5"/>
<path fill="#c8b100" d="m132.8 210.5.4.5 1.3-.4.1-.2-.1-.2-1.3-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m132.8 210.5.4.5 1.3-.4.1-.2-.1-.2-1.3-.3-.4.6"/>
<path fill="#c8b100" d="m136.4 210.5-.3.5-1.3-.4-.2-.2.2-.2 1.3-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m136.4 210.5-.3.5-1.3-.4-.2-.2.2-.2 1.3-.3.3.6"/>
<path fill="#c8b100" d="m129.3 209-.7.7.9 1 .2.1.1-.1.3-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m129.3 209-.7.7.9 1 .2.1.1-.1.3-1.3-.8-.3"/>
<path fill="#c8b100" d="m128 211.2.4.5 1.2-.6v-.2l-.1-.2-1.3-.1-.3.6"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m128 211.2.4.5 1.2-.6v-.2l-.1-.2-1.3-.1-.3.6"/>
<path fill="#c8b100" d="m131.5 210.5-.3.6H130l-.2-.2.1-.3 1.2-.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m131.5 210.5-.3.6H130l-.2-.2.1-.3 1.2-.6.5.5"/>
<path fill="#c8b100" d="M126.6 211.4v.6l-1.4.2-.2-.1v-.2l1-.9z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M126.6 211.4v.6l-1.4.2-.2-.1v-.2l1-.9.6.4"/>
<path fill="#c8b100" d="M129.2 210.9q0-.5.5-.5t.5.5l-.5.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M129.2 210.9q0-.5.5-.5t.5.5l-.5.4z"/>
<path fill="#c8b100" d="m140 209 .7.7-.9 1-.2.1-.1-.1-.3-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m140 209 .7.7-.9 1-.2.1-.1-.1-.3-1.3.8-.3"/>
<path fill="#c8b100" d="m141.4 211.2-.5.5-1.2-.6v-.2l.1-.2 1.3-.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m141.4 211.2-.5.5-1.2-.6v-.2l.1-.2 1.3-.1.3.6"/>
<path fill="#c8b100" d="m137.8 210.5.3.6h1.3l.2-.2-.1-.3-1.2-.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m137.8 210.5.3.6h1.3l.2-.2-.1-.3-1.2-.6-.5.5"/>
<path fill="#c8b100" d="m142.5 211.4.1.6 1.3.2.2-.1v-.2l-1-.9z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m142.5 211.4.1.6 1.3.2.2-.1v-.2l-1-.9-.6.4"/>
<path fill="#c8b100" d="m134.2 210.4.4-.4q.5 0 .5.4l-.5.5z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m134.2 210.4.4-.4q.5 0 .5.4l-.5.5z"/>
<path fill="#c8b100" d="M139.1 210.9q.1-.5.5-.5l.5.5-.5.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M139.1 210.9q.1-.5.5-.5l.5.5-.5.4z"/>
<path fill="#c8b100" d="m124.8 212.2-.6-.7c-.2-.2-.7-.3-.7-.3q0-.2.6-.3l.4.2v-.2s.3 0 .4.3v1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m124.8 212.2-.6-.7c-.2-.2-.7-.3-.7-.3q0-.2.6-.3l.4.2v-.2s.3 0 .4.3v1z"/>
<path fill="#c8b100" d="M124.8 212q.3-.3.5 0t.2.5l-.5-.1q-.3-.2-.2-.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M124.8 212q.3-.3.5 0t.2.5l-.5-.1q-.3-.2-.2-.5z"/>
<path fill="#c8b100" d="m144.3 212.2.6-.7.7-.3q0-.2-.6-.3l-.4.2v-.2s-.3 0-.4.3v.7z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m144.3 212.2.6-.7.7-.3q0-.2-.6-.3l-.4.2v-.2s-.3 0-.4.3v.7z"/>
<path fill="#c8b100" d="M144.3 212q-.1-.3-.5 0-.3.2-.1.5l.5-.1q.3-.2.1-.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M144.3 212q-.1-.3-.5 0-.3.2-.1.5l.5-.1q.3-.2.1-.5z"/>
<path fill="#c8b100" d="M124 223h21.4v-5.5H124z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M124 223h21.4v-5.5H124z"/>
<path fill="#c8b100" d="M126.2 226.8h16.9a1.4 1.4 0 0 1-1-1.2q.1-1 1-1.3h-16.9q1 .3 1 1.3a1.3 1.3 0 0 1-1 1.2"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M126.2 226.8h16.9a1.4 1.4 0 0 1-1-1.2q.1-1 1-1.3h-16.9q1 .3 1 1.3a1.3 1.3 0 0 1-1 1.2z"/>
<path fill="#c8b100" d="M126.6 226.8h16q1 0 1 .7t-1 .8h-16q-.9-.1-1-.8 0-1 1-.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M126.6 226.8h16q1 0 1 .7t-1 .8h-16q-.9-.1-1-.8 0-1 1-.8z"/>
<path fill="#c8b100" d="M126.6 223h16q1 .1 1 .7t-1 .6h-16q-.9 0-1-.6.1-.5 1-.6"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M126.6 223h16q1 .1 1 .7t-1 .6h-16q-.9 0-1-.6.1-.5 1-.6z"/>
<path fill="#005bbf" d="M149.6 317.4q-2.2 0-3.7-.8a8 8 0 0 0-3.8-.8q-2.1 0-3.7.8a8 8 0 0 1-3.8.8q-2.3 0-3.7-.8a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8 8 8 0 0 1-3.8.8v2.4q2.3 0 3.8-.9a8 8 0 0 1 3.7-.8q2.2 0 3.7.8c1.5.8 2.2.9 3.7.9a8 8 0 0 0 3.8-.9q1.6-.8 3.7-.8 2.3 0 3.8.8c1.5.8 2.2.9 3.7.9z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M149.6 317.4q-2.2 0-3.7-.8a8 8 0 0 0-3.8-.8q-2.1 0-3.7.8a8 8 0 0 1-3.8.8q-2.3 0-3.7-.8a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8 8 8 0 0 1-3.8.8v2.4q2.3 0 3.8-.9a8 8 0 0 1 3.7-.8q2.2 0 3.7.8c1.5.8 2.2.9 3.7.9a8 8 0 0 0 3.8-.9q1.6-.8 3.7-.8 2.3 0 3.8.8c1.5.8 2.2.9 3.7.9z"/>
<path fill="#ccc" d="M149.6 319.8a8 8 0 0 1-3.7-.9 8 8 0 0 0-3.8-.8q-2.1 0-3.7.8c-1.6.8-2.3.9-3.8.9s-2.8-.4-3.7-.9a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v2.3q2.3 0 3.8-.9a8 8 0 0 1 3.7-.7q2.2 0 3.7.7a8 8 0 0 0 7.5 0 9 9 0 0 1 7.5.1 8 8 0 0 0 3.7.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M149.6 319.8a8 8 0 0 1-3.7-.9 8 8 0 0 0-3.8-.8q-2.1 0-3.7.8c-1.6.8-2.3.9-3.8.9s-2.8-.4-3.7-.9a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v2.3q2.3 0 3.8-.9a8 8 0 0 1 3.7-.7q2.2 0 3.7.7a8 8 0 0 0 7.5 0 9 9 0 0 1 7.5.1 8 8 0 0 0 3.7.8v-2.3"/>
<path fill="#005bbf" d="M149.6 322a7 7 0 0 1-3.7-.8 8 8 0 0 0-3.8-.7q-2.1 0-3.7.7-1.5.9-3.8.9c-2.3 0-2.8-.4-3.7-.9a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v2.3q2.3 0 3.8-.9a10 10 0 0 1 7.4 0 7 7 0 0 0 3.7.9 8 8 0 0 0 3.8-.8q1.6-.8 3.7-.8 2.3 0 3.8.8c1.5.8 2.2.8 3.7.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M149.6 322a7 7 0 0 1-3.7-.8 8 8 0 0 0-3.8-.7q-2.1 0-3.7.7-1.5.9-3.8.9c-2.3 0-2.8-.4-3.7-.9a8 8 0 0 0-3.7-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v2.3q2.3 0 3.8-.9a10 10 0 0 1 7.4 0 7 7 0 0 0 3.7.9 8 8 0 0 0 3.8-.8q1.6-.8 3.7-.8 2.3 0 3.8.8c1.5.8 2.2.8 3.7.8V322"/>
<path fill="#ccc" d="M149.6 326.7a8 8 0 0 1-3.7-.8q-1.6-.8-3.7-.8a8 8 0 0 0-3.8.8q-1.5.8-3.8.8a7 7 0 0 1-3.7-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.7.8c-1.5.8-2.3.8-3.8.8v-2.3a8 8 0 0 0 3.8-.9 10 10 0 0 1 7.4 0 8 8 0 0 0 3.7.9 8 8 0 0 0 3.8-.8q1.5-.8 3.8-.8t3.7.8c1.5.8 2.3.8 3.7.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M149.6 326.7a8 8 0 0 1-3.7-.8q-1.6-.8-3.7-.8a8 8 0 0 0-3.8.8q-1.5.8-3.8.8a7 7 0 0 1-3.7-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.7.8c-1.5.8-2.3.8-3.8.8v-2.3a8 8 0 0 0 3.8-.9 10 10 0 0 1 7.4 0 8 8 0 0 0 3.7.9 8 8 0 0 0 3.8-.8q1.5-.8 3.8-.8t3.7.8c1.5.8 2.3.8 3.7.8v2.3"/>
<path fill="#005bbf" d="M149.6 329a8 8 0 0 1-3.7-.8q-1.6-.8-3.7-.8a8 8 0 0 0-3.8.8q-1.5.8-3.8.8a7 7 0 0 1-3.7-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.7.8c-1.5.8-2.3.8-3.8.8v-2.3a8 8 0 0 0 3.8-.8q1.5-.8 3.7-.8t3.7.7a8 8 0 0 0 7.5 0q1.5-.6 3.8-.7t3.7.8c1.5.8 2.2.8 3.7.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M149.6 329a8 8 0 0 1-3.7-.8q-1.6-.8-3.7-.8a8 8 0 0 0-3.8.8q-1.5.8-3.8.8a7 7 0 0 1-3.7-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.7.8c-1.5.8-2.3.8-3.8.8v-2.3a8 8 0 0 0 3.8-.8q1.5-.8 3.7-.8t3.7.7a8 8 0 0 0 7.5 0q1.5-.6 3.8-.7t3.7.8c1.5.8 2.2.8 3.7.8z"/>
<path fill="#c8b100" d="m126.2 308 .2.5c0 1.5-1.3 2.6-2.7 2.6h22a3 3 0 0 1-2.7-2.6v-.5z"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="m126.2 308 .2.5c0 1.5-1.3 2.6-2.7 2.6h22a3 3 0 0 1-2.7-2.6v-.5z"/>
<path fill="#c8b100" d="M126.6 306.5h16q1 0 1 .8 0 .6-1 .7h-16q-.9 0-1-.8.1-.6 1-.7"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M126.6 306.5h16q1 0 1 .8 0 .6-1 .7h-16q-.9 0-1-.8.1-.6 1-.7z"/>
<path fill="#c8b100" d="M123.7 316.7h22V311h-22z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M123.7 316.7h22V311h-22z"/>
<path fill="#ad1519" d="M122 286.7c-2.2 1.2-3.7 2.5-3.4 3.2q.2.8 1.8 1.6c1.5 1.1 2.5 3 1.7 4a5.5 5.5 0 0 0-.1-8.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M122 286.7c-2.2 1.2-3.7 2.5-3.4 3.2q.2.8 1.8 1.6c1.5 1.1 2.5 3 1.7 4a5.5 5.5 0 0 0-.1-8.8z"/>
<path fill="#ccc" d="M126.8 305.6h15.6V229h-15.6v76.5z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M138 229.2v76.3m1.7-76.3v76.3m-12.9 0h15.6v-76.4h-15.6v76.5z"/>
<path fill="#ad1519" d="M158.4 257.7a50 50 0 0 0-23.3-2c-9.4 1.6-16.5 5.3-15.9 8.4v.2l-3.5-8.2c-.6-3.3 7.2-7.5 17.6-9.2a43 43 0 0 1 9.2-.7c6.6 0 12.4.8 15.8 2.1v9.4"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M158.4 257.7a50 50 0 0 0-23.3-2c-9.4 1.6-16.5 5.3-15.9 8.4v.2l-3.5-8.2c-.6-3.3 7.2-7.5 17.6-9.2a43 43 0 0 1 9.2-.7c6.6 0 12.4.8 15.8 2.1v9.4"/>
<path fill="#ad1519" d="M126.8 267.3c-4.3-.3-7.3-1.4-7.6-3.2q-.3-2.2 3.8-4.5l3.8.3z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M126.8 267.3c-4.3-.3-7.3-1.4-7.6-3.2q-.3-2.2 3.8-4.5l3.8.3v7.4"/>
<path fill="#ad1519" d="M142.5 261.5q4.1.6 5.7 1.9l.1.2c.5 1-1.9 3-5.9 5.4v-7.5"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M142.5 261.5q4.1.6 5.7 1.9l.1.2c.5 1-1.9 3-5.9 5.4v-7.5"/>
<path fill="#ad1519" d="M117.1 282c-.4-1.2 3.8-3.6 9.8-5.8l7.8-3.2c8.3-3.7 14.4-7.9 13.6-9.4v-.2c.4.4 1 8 1 8 .8 1.3-4.8 5.5-12.4 9.1-2.5 1.2-7.6 3-10 4-4.4 1.4-8.7 4.3-8.3 5.3l-1.5-7.7"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M117.1 282c-.4-1.2 3.8-3.6 9.8-5.8l7.8-3.2c8.3-3.7 14.4-7.9 13.6-9.4v-.2c.4.4 1 8 1 8 .8 1.3-4.8 5.5-12.4 9.1-2.5 1.2-7.6 3-10 4-4.4 1.4-8.7 4.3-8.3 5.3l-1.5-7.7z"/>
<path fill="#c8b100" d="M125.8 254c1.9-.6 3.1-1.5 2.5-3q-.6-1.3-2.8-.6l-2.6 1 2.3 5.8.8-.3.8-.3zm-1.2-2.7.7-.3q1-.3 1.4.8.4.8-.5 1.5l-.6.3zm7.3-2.5-.9.3h-.8l1.3 6.1 4.3-.8-.2-.4v-.4l-2.5.6zm8.4 5.2q1.2-3.3 2.7-6.4h-1l-1.8 4.6-2.4-4.3-1 .1h-1l3.5 6zm8.8-4.7.4-.9a3 3 0 0 0-1.7-.6q-2.6 0-2.8 1.7c-.2 2.1 3.2 2 3 3.4q-.2 1-1.4.8-1.3 0-1.4-1.2h-.3l-.4 1.1a4 4 0 0 0 1.8.6q2.7.2 3.2-1.7c.2-2-3.3-2.1-3.1-3.4q0-.8 1.3-.7 1 0 1.2.9z"/>
<path fill="#ad1519" d="M277.9 211.6s-.7.8-1.3.9c-.5 0-1.1-.5-1.1-.5s-.5.5-1 .6-1.4-.6-1.4-.6l-1 1-1.1-.3c-.1-.1-.3.4-.7.6h-.4l-.6-.4-.7-.7-.5-.3-.4-1v-.5q0-1 2.2-1.7a4 4 0 0 1 2 0q.9-.8 3-.8c2.1 0 2.4.3 3 .7a6 6 0 0 1 2.9-.7q2.1 0 3 .8.8-.3 2 0 2.2.6 2.2 1.7v.5l-.4 1-.6.3-.6.7-.6.3s-.3.2-.4 0q-.6-.3-.7-.5c-.1-.2-.6.4-1 .2s-1-1-1-1-.9.8-1.4.7c-.6-.1-1-.6-1-.6s-.7.6-1.2.5-1.2-.9-1.2-.9"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.9 211.6s-.7.8-1.3.9c-.5 0-1.1-.5-1.1-.5s-.5.5-1 .6-1.4-.6-1.4-.6l-1 1-1.1-.3c-.1-.1-.3.4-.7.6h-.4l-.6-.4-.7-.7-.5-.3-.4-1v-.5q0-1 2.2-1.7a4 4 0 0 1 2 0q.9-.8 3-.8c2.1 0 2.4.3 3 .7a6 6 0 0 1 2.9-.7q2.1 0 3 .8.8-.3 2 0 2.2.6 2.2 1.7v.5l-.4 1-.6.3-.6.7-.6.3s-.3.2-.4 0q-.6-.3-.7-.5c-.1-.2-.6.4-1 .2s-1-1-1-1-.9.8-1.4.7c-.6-.1-1-.6-1-.6s-.7.6-1.2.5-1.2-.9-1.2-.9z"/>
<path fill="#c8b100" d="M276.5 207.6c0-1 .6-2 1.3-2s1.3 1 1.3 2-.5 1.8-1.3 1.8q-1.2-.1-1.3-1.9"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M276.5 207.6c0-1 .6-2 1.3-2s1.3 1 1.3 2-.5 1.8-1.3 1.8q-1.2-.1-1.3-1.9z"/>
<path fill="#c8b100" d="M277.3 207.6q0-1.7.5-1.8.6.2.7 1.8c.1 1.6-.3 1.7-.6 1.7q-.6-.2-.6-1.8"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.3 207.6q0-1.7.5-1.8.6.2.7 1.8c.1 1.6-.3 1.7-.6 1.7q-.6-.2-.6-1.8z"/>
<path fill="#c8b100" d="m271 215.3-.5-1a27 27 0 0 1 14.8 0l-.6.8-.3.8a23 23 0 0 0-6.6-.8c-2.6 0-5.2.3-6.6.8z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m271 215.3-.5-1a27 27 0 0 1 14.8 0l-.6.8-.3.8a23 23 0 0 0-6.6-.8c-2.6 0-5.2.3-6.6.8l-.2-.6"/>
<path fill="#c8b100" d="M277.8 217.7c2.4 0 5-.4 5.9-.6q1-.3 1-.8 0-.3-.4-.4a24 24 0 0 0-6.5-.8c-2.5 0-5 .3-6.4.8q-.3 0-.4.3 0 .6 1 .9c1 .2 3.5.6 5.8.6"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.8 217.7c2.4 0 5-.4 5.9-.6q1-.3 1-.8 0-.3-.4-.4a24 24 0 0 0-6.5-.8c-2.5 0-5 .3-6.4.8q-.3 0-.4.3 0 .6 1 .9c1 .2 3.5.6 5.8.6z"/>
<path fill="#fff" d="M283.5 208.4q0-.3.4-.4.5 0 .5.4t-.5.4a.4.4 0 0 1-.4-.4"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M283.5 208.4q0-.3.4-.4.5 0 .5.4t-.5.4a.4.4 0 0 1-.4-.4zm-.2-1.4a.4.4 0 0 1 .4-.4q.4 0 .4.4t-.4.4a.4.4 0 0 1-.4-.4zm-1.1-1q0-.3.4-.3t.4.4-.4.4a.4.4 0 0 1-.4-.5zm-1.4-.4q0-.3.4-.4.5 0 .5.4t-.4.4q-.5-.1-.5-.4zm-1.4 0q0-.3.5-.3t.4.4q0 .3-.4.4a.4.4 0 0 1-.5-.4z"/>
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".3" d="m287.8 211.2.2-1a2.7 2.7 0 0 0-2.7-2.8q-.8 0-1.3.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m283 209.2.2-.8q-.2-1.8-2.5-2-1 0-1.6.4"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M288.2 210q0-.5.4-.5t.4.4-.4.4q-.5 0-.4-.4zm-.2-1.6q0-.3.4-.4a.4.4 0 0 1 .5.4q0 .3-.4.4-.5 0-.5-.4zm-1-1.1a.4.4 0 0 1 .5-.4q.3 0 .4.4a.4.4 0 0 1-.4.4.4.4 0 0 1-.5-.4zm-1.3-.7q0-.4.5-.4t.4.4q0 .5-.4.5a.4.4 0 0 1-.5-.5zm-1.4.1q0-.3.5-.4.6 0 .4.4t-.4.4q-.4-.1-.5-.4z"/>
<path fill="#c8b100" d="m285.3 213.2-.5-.5s-.6.3-1.3.2c-.6 0-.9-1-.9-1s-.7.7-1.3.7-1-.6-1-.6-.7.5-1.3.4-1.2-.8-1.2-.8-.6.8-1.2.8c-.6.1-1-.5-1-.5s-.3.6-1.1.7-1.4-.6-1.4-.6-.4.7-1 1c-.5 0-1.2-.4-1.2-.4l-.1.5-.3.1.1.5a27 27 0 0 1 7.3-.9q4.3 0 7.3 1l.2-.6"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m285.3 213.2-.5-.5s-.6.3-1.3.2c-.6 0-.9-1-.9-1s-.7.7-1.3.7-1-.6-1-.6-.7.5-1.3.4-1.2-.8-1.2-.8-.6.8-1.2.8c-.6.1-1-.5-1-.5s-.3.6-1.1.7-1.4-.6-1.4-.6-.4.7-1 1c-.5 0-1.2-.4-1.2-.4l-.1.5-.3.1.1.5a27 27 0 0 1 7.3-.9q4.3 0 7.3 1l.2-.6z"/>
<path fill="#fff" d="M271.3 208.4q0-.3.4-.4.5 0 .4.4a.4.4 0 0 1-.4.4.4.4 0 0 1-.4-.4"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M271.3 208.4q0-.3.4-.4.5 0 .4.4a.4.4 0 0 1-.4.4.4.4 0 0 1-.4-.4zm.2-1.4q0-.4.4-.4t.5.4q0 .5-.5.4a.4.4 0 0 1-.4-.4zm1-1q.1-.3.5-.3t.5.4q0 .3-.5.4a.4.4 0 0 1-.4-.5zm1.4-.4q0-.3.5-.4.6 0 .4.4t-.4.4q-.4-.1-.5-.4zm1.4 0q0-.3.5-.3.3 0 .4.4 0 .3-.4.4a.4.4 0 0 1-.5-.4z"/>
<path fill="none" stroke="#000" stroke-linecap="round" stroke-width=".3" d="m267.8 211.2-.2-1a2.7 2.7 0 0 1 2.7-2.8q.8 0 1.4.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m272.7 209.2-.3-.8c0-1 1.2-2 2.6-2a3 3 0 0 1 1.5.4"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M266.6 210q0-.5.4-.5t.4.4a.4.4 0 0 1-.4.4q-.3 0-.4-.4zm.1-1.6q.1-.3.5-.4.5 0 .4.4t-.4.4q-.4-.1-.4-.4zm1-1.1q0-.4.5-.4a.4.4 0 0 1 .4.4.4.4 0 0 1-.4.4.4.4 0 0 1-.5-.4zm1.3-.7q0-.4.5-.4.3 0 .4.4 0 .5-.4.5a.4.4 0 0 1-.5-.5zm1.4.1q0-.3.5-.4a.4.4 0 0 1 .4.4.4.4 0 0 1-.4.4q-.5 0-.5-.4z"/>
<path fill="#c8b100" d="M277.9 210.7h.2v.4q.1 1 1 1a1 1 0 0 0 1-.7l.2-.3v.4q.3.8 1.1.8 1 0 1-1v-.1l.4-.4.2.5-.1.4a1 1 0 0 0 1 1q.6 0 .9-.5l.2-.2v.3q0 .5.4.7l1-.4c1-.5.7-.7.7-.7v.4s-.5.8-1 1q-.3.4-.8.3-.5 0-.7-.6l-.7.2q-1 0-1.4-.8l-1.1.5q-.8 0-1.2-.6l-1 .4q-.9 0-1.4-.6-.4.6-1.2.6-.6 0-1-.4a2 2 0 0 1-1.3.6q-.6 0-1.1-.5-.4.8-1.4.8-.4 0-.7-.2-.2.5-.7.6t-.9-.2l-1-1 .1-.5.8.7q.8.5.9.4.4-.1.4-.7v-.3l.2.2q.3.5.9.5a1 1 0 0 0 1-1v-.9l.4.4v.1q.1 1 1 1t1.1-.9v-.3l.2.3q.4.6 1 .7 1 0 1.1-1v-.3h.2"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.9 210.7h.2v.4q.1 1 1 1a1 1 0 0 0 1-.7l.2-.3v.4q.3.8 1.1.8 1 0 1-1v-.1l.4-.4.2.5-.1.4a1 1 0 0 0 1 1q.6 0 .9-.5l.2-.2v.3q0 .5.4.7l1-.4c1-.5.7-.7.7-.7v.4s-.5.8-1 1q-.3.4-.8.3-.5 0-.7-.6l-.7.2q-1 0-1.4-.8l-1.1.5q-.8 0-1.2-.6l-1 .4q-.9 0-1.4-.6-.4.6-1.2.6-.6 0-1-.4a2 2 0 0 1-1.3.6q-.6 0-1.1-.5-.4.8-1.4.8-.4 0-.7-.2-.2.5-.7.6t-.9-.2l-1-1 .1-.5.8.7q.8.5.9.4.4-.1.4-.7v-.3l.2.2q.3.5.9.5a1 1 0 0 0 1-1v-.9l.4.4v.1q.1 1 1 1t1.1-.9v-.3l.2.3q.4.6 1 .7 1 0 1.1-1v-.3h.2z"/>
<path fill="#c8b100" d="M277.8 213.3q-4.5 0-7.3 1l-.3-.2.1-.3q3-1 7.5-1t7.6 1l.1.3-.3.2a27 27 0 0 0-7.4-1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.8 213.3q-4.5 0-7.3 1l-.3-.2.1-.3q3-1 7.5-1t7.6 1l.1.3-.3.2a27 27 0 0 0-7.4-1z"/>
<path fill="#fff" d="M275 214.4q0-.4.5-.4a.4.4 0 0 1 .4.4.4.4 0 0 1-.4.4q-.5 0-.5-.4"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M275 214.4q0-.4.5-.4a.4.4 0 0 1 .4.4.4.4 0 0 1-.4.4q-.5 0-.5-.4z"/>
<path fill="#ad1519" d="M277.9 214.5h-1q-.2 0-.3-.3l.3-.3h2l.2.3-.3.3h-1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.9 214.5h-1q-.2 0-.3-.3l.3-.3h2l.2.3-.3.3h-1"/>
<path fill="#058e6e" d="M273.2 214.9h-.6a.3.3 0 0 1-.4-.2l.3-.3.6-.1.7-.1q.3 0 .4.2a.3.3 0 0 1-.3.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M273.2 214.9h-.6a.3.3 0 0 1-.4-.2l.3-.3.6-.1.7-.1q.3 0 .4.2a.3.3 0 0 1-.3.4h-.7"/>
<path fill="#ad1519" d="m270.5 215.3.3-.4h.7l-.4.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m270.5 215.3.3-.4h.7l-.4.6-.6-.2"/>
<path fill="#fff" d="M279.8 214.4q0-.4.4-.4t.5.4q0 .3-.5.4a.4.4 0 0 1-.4-.4"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M279.8 214.4q0-.4.4-.4t.5.4q0 .3-.5.4a.4.4 0 0 1-.4-.4z"/>
<path fill="#058e6e" d="M282.5 214.9h.7l.3-.2-.2-.3-.7-.1-.7-.1q-.3 0-.4.2 0 .3.3.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M282.5 214.9h.7l.3-.2-.2-.3-.7-.1-.7-.1q-.3 0-.4.2 0 .3.3.4h.7"/>
<path fill="#ad1519" d="m285.1 215.4-.2-.5h-.7l.3.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m285.1 215.4-.2-.5h-.7l.3.6.6-.1"/>
<path fill="#ad1519" d="M277.8 217.1a25 25 0 0 1-6-.6 25 25 0 0 1 6-.7q3.6 0 6.1.7-2.5.6-6 .6"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M277.8 217.1a25 25 0 0 1-6-.6 25 25 0 0 1 6-.7q3.6 0 6.1.7-2.5.6-6 .6z"/>
<path fill="#c8b100" d="m285.2 212-.1-.3q-.3 0-.4.2l.1.4q.3 0 .4-.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m285.2 212-.1-.3q-.3 0-.4.2l.1.4q.3 0 .4-.3z"/>
<path fill="#c8b100" d="M280.6 211.2q0-.3-.3-.4l-.2.3q0 .3.2.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M280.6 211.2q0-.3-.3-.4l-.2.3q0 .3.2.4z"/>
<path fill="#c8b100" d="M275.2 211.2q0-.3.2-.4l.3.3-.2.4q-.3 0-.3-.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M275.2 211.2q0-.3.2-.4l.3.3-.2.4q-.3 0-.3-.3z"/>
<path fill="#c8b100" d="m270.5 212 .1-.3q.3 0 .4.2l-.1.4q-.3 0-.4-.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m270.5 212 .1-.3q.3 0 .4.2l-.1.4q-.3 0-.4-.3z"/>
<path fill="#c8b100" d="m277.8 208.5-.8.5.6 1.3.2.1.3-.1.6-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m277.8 208.5-.8.5.6 1.3.2.1.3-.1.6-1.3-.9-.5"/>
<path fill="#c8b100" d="m276 210.5.4.5 1.3-.4.1-.2-.1-.2-1.3-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m276 210.5.4.5 1.3-.4.1-.2-.1-.2-1.3-.3-.4.6"/>
<path fill="#c8b100" d="m279.6 210.5-.3.5-1.3-.4-.1-.2v-.2l1.4-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m279.6 210.5-.3.5-1.3-.4-.1-.2v-.2l1.4-.3.4.6"/>
<path fill="#c8b100" d="m272.5 209-.7.7.9 1 .2.1.2-.1.2-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m272.5 209-.7.7.9 1 .2.1.2-.1.2-1.3-.8-.3"/>
<path fill="#c8b100" d="m271.1 211.2.5.5 1.2-.6v-.2l-.1-.2-1.3-.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m271.1 211.2.5.5 1.2-.6v-.2l-.1-.2-1.3-.1-.3.6"/>
<path fill="#c8b100" d="m274.7 210.5-.3.6h-1.3l-.2-.2.1-.3 1.2-.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m274.7 210.5-.3.6h-1.3l-.2-.2.1-.3 1.2-.6.5.5"/>
<path fill="#c8b100" d="M269.8 211.4v.6l-1.4.2-.2-.1v-.2l1-.9z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M269.8 211.4v.6l-1.4.2-.2-.1v-.2l1-.9.6.4"/>
<path fill="#c8b100" d="M272.4 210.9q0-.5.5-.5l.5.5-.5.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M272.4 210.9q0-.5.5-.5l.5.5-.5.4z"/>
<path fill="#c8b100" d="m283.2 209 .7.7-.9 1-.2.1-.1-.1-.3-1.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m283.2 209 .7.7-.9 1-.2.1-.1-.1-.3-1.3.8-.3"/>
<path fill="#c8b100" d="m284.6 211.2-.5.5-1.2-.6v-.2l.1-.2 1.3-.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m284.6 211.2-.5.5-1.2-.6v-.2l.1-.2 1.3-.1.3.6"/>
<path fill="#c8b100" d="m281 210.5.3.6h1.3l.2-.2-.1-.3-1.2-.6z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m281 210.5.3.6h1.3l.2-.2-.1-.3-1.2-.6-.5.5"/>
<path fill="#c8b100" d="M285.7 211.4v.6l1.4.2.2-.1v-.2l-1-.9z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M285.7 211.4v.6l1.4.2.2-.1v-.2l-1-.9-.6.4"/>
<path fill="#c8b100" d="M277.4 210.4q0-.3.5-.4.4 0 .4.4t-.4.5z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M277.4 210.4q0-.3.5-.4.4 0 .4.4t-.4.5z"/>
<path fill="#c8b100" d="M282.3 210.9q.1-.5.5-.5t.5.5q0 .6-.5.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M282.3 210.9q.1-.5.5-.5t.5.5q0 .6-.5.4z"/>
<path fill="#c8b100" d="M277 205.4q.1-.8.8-.8 1 0 1 .8c0 .8-.5.8-1 .8a1 1 0 0 1-.8-.8"/>
<path fill="#c8b100" d="M278.5 205.1v.6H277v-.6h.4v-1.3h-.5v-.5h.5v-.6h.6v.6h.6v.6h-.6v1.2h.4"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M278.5 205.1v.6H277v-.6h.4v-1.3h-.5v-.5h.5v-.6h.6v.6h.6v.6h-.6v1.2h.4z"/>
<path fill="#c8b100" d="M279 205.1v.6h-2.4v-.6h1v-1.3h-.7v-.5h.6v-.6h.6v.6h.6v.6h-.6v1.2h1"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M278.1 204.6q.6.1.6.8t-.9.8a1 1 0 0 1-.8-.8q0-.6.6-.8"/>
<path fill="#c8b100" d="m268 212.2-.6-.7-.7-.3q0-.2.6-.3.3 0 .4.2v-.2s.3 0 .4.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m268 212.2-.6-.7-.7-.3q0-.2.6-.3.3 0 .4.2v-.2s.3 0 .4.3z"/>
<path fill="#c8b100" d="M268 212q.3-.3.5 0 .4.2.1.5l-.5-.1q-.2-.2 0-.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M268 212q.3-.3.5 0 .4.2.1.5l-.5-.1q-.2-.2 0-.5z"/>
<path fill="#c8b100" d="m287.5 212.2.6-.7c.2-.2.7-.3.7-.3q0-.2-.6-.3l-.4.2v-.2s-.3 0-.4.3v.7z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m287.5 212.2.6-.7c.2-.2.7-.3.7-.3q0-.2-.6-.3l-.4.2v-.2s-.3 0-.4.3v.7z"/>
<path fill="#c8b100" d="M287.5 212q-.2-.3-.5 0t-.1.5l.5-.1q.3-.2.1-.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M287.5 212q-.2-.3-.5 0t-.1.5l.5-.1q.3-.2.1-.5z"/>
<path fill="#c8b100" d="M267.2 223h21.4v-5.5h-21.4z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M267.2 223h21.4v-5.5h-21.4z"/>
<path fill="#c8b100" d="M286.3 226.8h-16.9q1-.4 1-1.2 0-1-1-1.3h17-.1q-1 .3-1 1.3 0 .8 1 1.2"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M286.3 226.8h-16.9q1-.4 1-1.2 0-1-1-1.3h17-.1q-1 .3-1 1.3 0 .8 1 1.2z"/>
<path fill="#c8b100" d="M269.9 226.8h16q1 0 1 .7t-1 .8h-16q-1-.1-1-.8.1-1 1-.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M269.9 226.8h16q1 0 1 .7t-1 .8h-16q-1-.1-1-.8.1-1 1-.8z"/>
<path fill="#c8b100" d="M269.9 223h16q1 .1 1 .7t-1 .6h-16q-1 0-1-.6t1-.6"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M269.9 223h16q1 .1 1 .7t-1 .6h-16q-1 0-1-.6t1-.6z"/>
<path fill="#005bbf" d="M263 317.4q2.1 0 3.7-.8a8 8 0 0 1 3.7-.8q2.2 0 3.8.8c1.6.8 2.3.8 3.7.8q2.3 0 3.8-.8a8 8 0 0 1 3.6-.8 8 8 0 0 1 3.7.8q1.6.8 3.8.8v2.4a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.8q-2.2 0-3.6.8-1.5.8-3.8.9a8 8 0 0 1-3.7-.9 8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v-2.4"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M263 317.4q2.1 0 3.7-.8a8 8 0 0 1 3.7-.8q2.2 0 3.8.8c1.6.8 2.3.8 3.7.8q2.3 0 3.8-.8a8 8 0 0 1 3.6-.8 8 8 0 0 1 3.7.8q1.6.8 3.8.8v2.4a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.8q-2.2 0-3.6.8-1.5.8-3.8.9a8 8 0 0 1-3.7-.9 8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.9v-2.4z"/>
<path fill="#ccc" d="M263 319.8q2.1 0 3.7-.9c1.6-.9 2.3-.8 3.7-.8q2.2 0 3.8.8c1.6.8 2.3.9 3.7.9a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.8q2.3 0 3.7.8 1.6.8 3.8.9v2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.8-3.8.9a7 7 0 0 1-3.7-.9q-1.5-.6-3.8-.7a8 8 0 0 0-3.7.7 8 8 0 0 1-3.8.9v-2.3"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M263 319.8q2.1 0 3.7-.9c1.6-.9 2.3-.8 3.7-.8q2.2 0 3.8.8c1.6.8 2.3.9 3.7.9a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.8q2.3 0 3.7.8 1.6.8 3.8.9v2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.8-3.8.9a7 7 0 0 1-3.7-.9q-1.5-.6-3.8-.7a8 8 0 0 0-3.7.7 8 8 0 0 1-3.8.9v-2.3"/>
<path fill="#005bbf" d="M263 322q2.1 0 3.7-.8 1.6-.6 3.7-.7 2.2 0 3.8.7c1.6.7 2.3.9 3.7.9a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.8 8 8 0 0 1 3.7.8q1.6.8 3.8.9v2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.9-3.8.9t-3.7-.8a8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.8V322"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M263 322q2.1 0 3.7-.8 1.6-.6 3.7-.7 2.2 0 3.8.7c1.6.7 2.3.9 3.7.9a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.8 8 8 0 0 1 3.7.8q1.6.8 3.8.9v2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.9-3.8.9t-3.7-.8a8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.8V322"/>
<path fill="#ccc" d="M263 326.7a8 8 0 0 0 3.7-.8q1.6-.8 3.7-.8 2.2 0 3.8.8c1.6.8 2.3.8 3.7.8q2.3 0 3.8-.9a8 8 0 0 1 3.6-.7q2.3 0 3.7.8a8 8 0 0 0 3.8.8v-2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.8-3.8.9t-3.7-.8a8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.8v2.3"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M263 326.7a8 8 0 0 0 3.7-.8q1.6-.8 3.7-.8 2.2 0 3.8.8c1.6.8 2.3.8 3.7.8q2.3 0 3.8-.9a8 8 0 0 1 3.6-.7q2.3 0 3.7.8a8 8 0 0 0 3.8.8v-2.3a8 8 0 0 1-3.8-.9 8 8 0 0 0-3.7-.7q-2.2 0-3.6.7-1.5.8-3.8.9t-3.7-.8a8 8 0 0 0-3.8-.8 8 8 0 0 0-3.7.8q-1.5.8-3.8.8v2.3"/>
<path fill="#005bbf" d="M263 329a8 8 0 0 0 3.7-.8q1.6-.8 3.7-.8 2.2 0 3.8.8c1.6.8 2.3.8 3.7.8a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.7q2.3 0 3.7.8 1.6.8 3.8.8v-2.3a8 8 0 0 1-3.8-.8 8 8 0 0 0-3.7-.8 8 8 0 0 0-3.6.7 8 8 0 0 1-3.8.9q-2.2 0-3.7-.8t-3.8-.8q-2.1 0-3.7.8c-1.6.8-2.3.8-3.8.8v2.3"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M263 329a8 8 0 0 0 3.7-.8q1.6-.8 3.7-.8 2.2 0 3.8.8c1.6.8 2.3.8 3.7.8a8 8 0 0 0 3.8-.9 8 8 0 0 1 3.6-.7q2.3 0 3.7.8 1.6.8 3.8.8v-2.3a8 8 0 0 1-3.8-.8 8 8 0 0 0-3.7-.8 8 8 0 0 0-3.6.7 8 8 0 0 1-3.8.9q-2.2 0-3.7-.8t-3.8-.8q-2.1 0-3.7.8c-1.6.8-2.3.8-3.8.8v2.3z"/>
<path fill="#c8b100" d="m286.3 308-.1.5c0 1.5 1.2 2.6 2.7 2.6h-22c1.5 0 2.7-1.2 2.7-2.6l-.1-.5z"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="m286.3 308-.1.5c0 1.5 1.2 2.6 2.7 2.6h-22c1.5 0 2.7-1.2 2.7-2.6l-.1-.5z"/>
<path fill="#c8b100" d="M269.9 306.5h16q1 0 1 .8 0 .6-1 .7h-16q-1 0-1-.8.1-.6 1-.7"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M269.9 306.5h16q1 0 1 .8 0 .6-1 .7h-16q-1 0-1-.8.1-.6 1-.7z"/>
<path fill="#c8b100" d="M266.9 316.7h22V311h-22z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M266.9 316.7h22V311h-22z"/>
<path fill="#ad1519" d="M290.6 286.7c2.1 1.2 3.6 2.5 3.4 3.2q-.2.8-1.8 1.6c-1.6 1.1-2.5 3-1.8 4a5.5 5.5 0 0 1 .2-8.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M290.6 286.7c2.1 1.2 3.6 2.5 3.4 3.2q-.2.8-1.8 1.6c-1.6 1.1-2.5 3-1.8 4a5.5 5.5 0 0 1 .2-8.8z"/>
<path fill="#ccc" d="M270.1 305.6h15.6V229h-15.6v76.5z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M281.4 229.1v76.3m1.8-76.3v76.3m-13 .2h15.5V229h-15.6v76.5z"/>
<path fill="#ad1519" d="M254.2 257.7a50 50 0 0 1 23.3-2c9.3 1.6 16.4 5.3 15.9 8.4v.2l3.5-8.2c.6-3.3-7.3-7.5-17.6-9.2a54 54 0 0 0-9.2-.7c-6.7 0-12.4.8-15.9 2.1z"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M254.2 257.7a50 50 0 0 1 23.3-2c9.3 1.6 16.4 5.3 15.9 8.4v.2l3.5-8.2c.6-3.3-7.3-7.5-17.6-9.2a54 54 0 0 0-9.2-.7c-6.7 0-12.4.8-15.9 2.1v9.4"/>
<path fill="#ad1519" d="M285.7 267.3c4.4-.3 7.3-1.4 7.7-3.2q.2-2.2-3.8-4.5l-3.9.3z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M285.7 267.3c4.4-.3 7.3-1.4 7.7-3.2q.2-2.2-3.8-4.5l-3.9.3v7.4"/>
<path fill="#ad1519" d="M270 261.5a13 13 0 0 0-5.7 1.9v.2c-.5 1 1.8 3 5.8 5.4v-7.5"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M270 261.5a13 13 0 0 0-5.7 1.9v.2c-.5 1 1.8 3 5.8 5.4v-7.5"/>
<path fill="#ad1519" d="M295.4 282c.4-1.2-3.8-3.6-9.7-5.8-2.8-1-5-2-7.8-3.2-8.3-3.7-14.4-7.9-13.6-9.4v-.2c-.4.4-1 8-1 8-.8 1.3 4.8 5.5 12.4 9.1 2.4 1.2 7.6 3 10 4 4.3 1.4 8.7 4.3 8.3 5.3l1.4-7.7"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M295.4 282c.4-1.2-3.8-3.6-9.7-5.8-2.8-1-5-2-7.8-3.2-8.3-3.7-14.4-7.9-13.6-9.4v-.2c-.4.4-1 8-1 8-.8 1.3 4.8 5.5 12.4 9.1 2.4 1.2 7.6 3 10 4 4.3 1.4 8.7 4.3 8.3 5.3l1.4-7.7z"/>
<path fill="#c8b100" d="m263.9 254.4 2.1-6.6h-.5l-.5.1-1.4 4.8q-1.6-2-2.7-4.1l-1 .2h-1l4 5.7h.5zm6-6.6h-1.8v6.2h4.2v-.7h-2.6zm6.8 1 2 .3v-.7l-5.8-.5v.8h2l-.4 5.6h1.6l.5-5.4m2.4 6 .8.2.8.2.7-2.9.6 1.2.8 2.1 1 .2q.6 0 1 .3l-.3-.7-1.3-2.9q1.6 0 2.1-1.2.3-.8-.7-1.5-.8-.4-1.7-.5l-2.4-.5zm3-5.2c.7.2 1.5.3 1.5 1v.5q-.5 1.3-2 .9zm8 7-.2 2 .8.5.9.5.5-7-.7-.3-6.1 3.8.5.3.4.2 1.7-1.2 2.3 1.3zm-1.7-1.5 2-1.4-.2 2.3z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M182.2 192.4c0-1 1-2 2-2s2.2 1 2.2 2c0 1.1-1 2-2.1 2a2 2 0 0 1-2.1-2z"/>
<path fill="#ad1519" stroke="#000" stroke-width=".3" d="M205.7 175.4c6.3 0 12 1 15.7 2.4a32 32 0 0 0 14.6 2.3c2.7 0 6.5.8 10.3 2.4a27 27 0 0 1 7.4 4.7l-1.5 1.4-.4 3.8-4.1 4.7-2 1.8-5 3.9-2.5.2-.7 2.1-31.6-3.7-31.7 3.7-.8-2.1-2.5-.2-4.9-4-2-1.7-4.1-4.7-.5-3.8-1.5-1.4a28 28 0 0 1 7.5-4.7 26 26 0 0 1 10.2-2.4q3 .3 6.6-.2a30 30 0 0 0 8-2q5.6-2.4 15.5-2.5z"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M206.2 217.1c-11.8 0-22.4-1.4-29.9-3.6a1 1 0 0 1-.8-1.2q0-.8.8-1.2a109 109 0 0 1 29.9-3.6c11.7 0 22.3 1.4 29.8 3.6a1.3 1.3 0 0 1 0 2.4c-7.5 2.2-18 3.6-29.8 3.6"/>
<path fill="#ad1519" d="M206.1 215.6c-10.6 0-20.2-1.2-27.5-3.1 7.3-2 16.9-3 27.5-3.1a115 115 0 0 1 27.6 3c-7.3 2-17 3.2-27.6 3.2"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M206.9 215.7v-6.3m-1.7 6.3v-6.3"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M203.6 215.7v-6.3m-1.6 6.3v-6.3"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M200.6 215.7v-6.3m-2.8 5.9v-5.7m1.3 5.8v-6m-3.8 5.6v-5.2m1.3 5.4v-5.6"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M192 214.8V210m1 4.7V210m1.2 5v-5m-3.4 4.7v-4.5"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M189.7 214.5v-4.2m-1.2 4.1v-4"/>
<path fill="none" stroke="#000" stroke-width=".6" d="M186 214v-3m1.3 3.2v-3.5m-2.5 3.1V211"/>
<path fill="none" stroke="#000" stroke-width=".7" d="M183.7 213.6v-2.3m-1.3 2v-1.8m-1.2 1.6v-1.3"/>
<path fill="none" stroke="#000" stroke-width=".9" d="M179.8 212.8v-.7"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M213.7 215.3v-5.8m-2.9 6v-6.1m-2.1 6.2v-6.3"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M206 207.4a108 108 0 0 0-30 3.9c.6-.3.5-1-.3-3-1-2.5-2.4-2.4-2.4-2.4 8.3-2.5 20-4 32.8-4a123 123 0 0 1 33 4s-1.5-.1-2.5 2.3q-1.2 2.8-.2 3c-7.5-2.2-18.4-3.7-30.3-3.7"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M206.1 201.9c-12.9 0-24.5 1.5-32.8 4a1 1 0 0 1-1.3-.6 1 1 0 0 1 .7-1.3 121 121 0 0 1 33.4-4.2c13.2 0 25.2 1.7 33.5 4.2q1 .4.7 1.3t-1.3.6c-8.4-2.5-20-4-32.9-4"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".4" d="M206.1 215.6c-10.6 0-20.2-1.2-27.5-3.1 7.3-2 16.9-3 27.5-3.1a115 115 0 0 1 27.6 3c-7.3 2-17 3.2-27.6 3.2z"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M197 204.8q0-.9 1-1 .9.1 1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="M206.1 205.6H203a1 1 0 0 1 0-2h6.4q.9.1 1 1 0 1.2-1 1h-3.2"/>
<path fill="#058e6e" stroke="#000" stroke-width=".4" d="m190.3 206.5-2.3.2q-.9 0-1.2-.8a1 1 0 0 1 1-1.1l2.2-.3 2.4-.3q.9 0 1.1.9 0 .9-.9 1l-2.3.4"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M181 206.7q.1-1 1.1-1t1 1-1 1a1 1 0 0 1-1-1"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="m174 208.5 1.2-1.6 3.3.4-2.6 2-1.8-.8"/>
<path fill="#058e6e" stroke="#000" stroke-width=".4" d="m222 206.5 2.3.2q.9 0 1.1-.8a1 1 0 0 0-.9-1.1l-2.2-.3-2.4-.3a1 1 0 0 0-1.1.9q0 .9.9 1l2.3.4"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M213.3 204.8q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1m15.8 1.9q.1-1 1-1t1.1 1q0 .9-1 1a1 1 0 0 1-1-1"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="m238.2 208.5-1.1-1.6-3.3.4 2.6 2 1.8-.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M177.3 212.8c7.4-2.1 17.6-3.4 28.8-3.4 11.3 0 21.4 1.3 28.9 3.4"/>
<path fill="#c8b100" d="m182.3 183.8 1.4 1 2-3.2a7 7 0 0 1-3.6-7.2c.2-4.1 5.2-7.6 11.7-7.6q5 .1 8.5 2.4l.2-1.8a17 17 0 0 0-8.7-2.1c-7.4 0-13.2 4.1-13.5 9.1a9 9 0 0 0 3 7.6z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="m182.3 183.8 1.4 1 2-3.2a7 7 0 0 1-3.6-7.2c.2-4.1 5.2-7.6 11.7-7.6q5 .1 8.5 2.4l.2-1.8a17 17 0 0 0-8.7-2.1c-7.4 0-13.2 4.1-13.5 9.1a9 9 0 0 0 3 7.6l-1 1.8"/>
<path fill="#c8b100" d="M182.4 183.8a9 9 0 0 1-4-7.3q.2-5 5.3-8a9 9 0 0 0-3.4 6.8 9 9 0 0 0 3 6.7z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M182.4 183.8a9 9 0 0 1-4-7.3q.2-5 5.3-8a9 9 0 0 0-3.4 6.8 9 9 0 0 0 3 6.7l-.9 1.8"/>
<path fill="#c8b100" d="M160.1 187.1a9 9 0 0 1-2.3-5.9q0-2 1-3.8c2-4.2 8.4-7.2 16-7.2q3 0 5.9.6l-1 1.4a26 26 0 0 0-4.9-.4c-7 0-12.8 2.7-14.5 6.3a7 7 0 0 0-.7 3.1 7 7 0 0 0 2.7 5.6l-2.6 4.1-1.3-1z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M160.1 187.1a9 9 0 0 1-2.3-5.9q0-2 1-3.8c2-4.2 8.4-7.2 16-7.2q3 0 5.9.6l-1 1.4a26 26 0 0 0-4.9-.4c-7 0-12.8 2.7-14.5 6.3a7 7 0 0 0-.7 3.1 7 7 0 0 0 2.7 5.6l-2.6 4.1-1.3-1z"/>
<path fill="#c8b100" d="M162.7 173.3a11 11 0 0 0-4 4.1 9 9 0 0 0-.9 3.8q.1 3.5 2.3 5.9l-1.5 2.5a10 10 0 0 1-2.3-6.5c0-4 2.5-7.5 6.4-9.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M162.7 173.3a11 11 0 0 0-4 4.1 9 9 0 0 0-.9 3.8q.1 3.5 2.3 5.9l-1.5 2.5a10 10 0 0 1-2.3-6.5c0-4 2.5-7.5 6.4-9.8z"/>
<path fill="#c8b100" d="M206 164.4c1.7 0 3.2 1.1 3.5 2.6q.4 2.1.4 4.5v1.1c.1 3.3.6 6.3 1.3 8.1l-5.2 5-5.2-5c.7-1.8 1.2-4.8 1.3-8.1v-1.1q0-2.4.4-4.5c.3-1.5 1.8-2.6 3.5-2.6"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M206 164.4c1.7 0 3.2 1.1 3.5 2.6q.4 2.1.4 4.5v1.1c.1 3.3.6 6.3 1.3 8.1l-5.2 5-5.2-5c.7-1.8 1.2-4.8 1.3-8.1v-1.1q0-2.4.4-4.5c.3-1.5 1.8-2.6 3.5-2.6z"/>
<path fill="#c8b100" d="M206 166q1.6.1 1.8 1.4.3 1.8.4 4.2v1q.2 5 1.2 7.7l-3.4 3.2-3.4-3.2q1-2.7 1.2-7.7v-1l.4-4.2a2 2 0 0 1 1.8-1.4"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M206 166q1.6.1 1.8 1.4.3 1.8.4 4.2v1q.2 5 1.2 7.7l-3.4 3.2-3.4-3.2q1-2.7 1.2-7.7v-1l.4-4.2a2 2 0 0 1 1.8-1.4z"/>
<path fill="#c8b100" d="m229.7 183.8-1.3 1-2-3.2a7 7 0 0 0 3.6-6.3v-.9c-.2-4.1-5.3-7.6-11.7-7.6a15 15 0 0 0-8.5 2.4l-.2-1.8a17 17 0 0 1 8.7-2.1c7.4 0 13.2 4.1 13.4 9.1a9 9 0 0 1-3 7.6z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="m229.7 183.8-1.3 1-2-3.2a7 7 0 0 0 3.6-6.3v-.9c-.2-4.1-5.3-7.6-11.7-7.6a15 15 0 0 0-8.5 2.4l-.2-1.8a17 17 0 0 1 8.7-2.1c7.4 0 13.2 4.1 13.4 9.1a9 9 0 0 1-3 7.6l1 1.8"/>
<path fill="#c8b100" d="M229.6 183.8a9 9 0 0 0 4.1-7.3c0-3.2-2.1-6.1-5.3-8a9 9 0 0 1 3.4 6.8 9 9 0 0 1-3.2 6.7z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M229.6 183.8a9 9 0 0 0 4.1-7.3c0-3.2-2.1-6.1-5.3-8a9 9 0 0 1 3.4 6.8 9 9 0 0 1-3.2 6.7l1 1.8"/>
<path fill="#c8b100" d="M252 187.1a9 9 0 0 0 2.2-5.9 9 9 0 0 0-.9-3.8c-2-4.2-8.4-7.2-16-7.2a29 29 0 0 0-6 .6l1 1.4a25 25 0 0 1 5-.4c7 0 12.8 2.7 14.4 6.3q.7 1.5.7 3.1a7 7 0 0 1-2.6 5.6l2.5 4.1 1.3-1z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M252 187.1a9 9 0 0 0 2.2-5.9 9 9 0 0 0-.9-3.8c-2-4.2-8.4-7.2-16-7.2a29 29 0 0 0-6 .6l1 1.4a25 25 0 0 1 5-.4c7 0 12.8 2.7 14.4 6.3q.7 1.5.7 3.1a7 7 0 0 1-2.6 5.6l2.5 4.1 1.3-1z"/>
<path fill="#c8b100" d="M249.3 173.3a11 11 0 0 1 4 4.1 9 9 0 0 1 .9 3.8 9 9 0 0 1-2.3 5.9l1.6 2.5a10 10 0 0 0 2.3-6.5c0-4-2.6-7.5-6.5-9.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M249.3 173.3a11 11 0 0 1 4 4.1 9 9 0 0 1 .9 3.8 9 9 0 0 1-2.3 5.9l1.6 2.5a10 10 0 0 0 2.3-6.5c0-4-2.6-7.5-6.5-9.8z"/>
<path fill="#fff" d="M204.2 181.4q.1-1.7 1.8-1.8c1.7-.1 1.9.8 1.9 1.8s-.9 1.7-1.9 1.7a2 2 0 0 1-1.8-1.7"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M204.2 181.4q.1-1.7 1.8-1.8c1.7-.1 1.9.8 1.9 1.8s-.9 1.7-1.9 1.7a2 2 0 0 1-1.8-1.7z"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M204.2 178q.1-1.7 1.8-1.8c1.7-.1 1.9.8 1.9 1.8s-.9 1.7-1.9 1.7a2 2 0 0 1-1.8-1.7m.4-3.7q.1-1.2 1.4-1.3 1.4.1 1.5 1.3-.1 1.3-1.5 1.4c-1.4.1-1.4-.6-1.4-1.4m.4-3.3q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1m.2-2.8q.1-.8.8-.8t.9.8q0 .7-.9.8a1 1 0 0 1-.8-.8"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="m206.2 191.8 1.2.2a4.6 4.6 0 0 0 4.5 6 5 5 0 0 0 4.4-3c.1 0 .5-1.7.7-1.7s.1 1.8.2 1.7c.3 2.3 2.4 3.8 4.7 3.8a4.6 4.6 0 0 0 4.7-5l1.5-1.5.7 2a4 4 0 0 0-.4 1.9 4.4 4.4 0 0 0 4.5 4.2q2.5 0 3.8-1.9l.9-1.2v1.5c0 1.5.6 2.8 2 3 0 0 1.7.1 4-1.6 2.1-1.7 3.3-3.1 3.3-3.1l.2 1.7s-1.8 2.8-3.8 4c-1 .6-2.7 1.3-4 1q-2.1-.5-3-2.6a7 7 0 0 1-3.3 1 7 7 0 0 1-6.1-3.7 7 7 0 0 1-10.4-.3 7 7 0 0 1-4.6 1.8 7 7 0 0 1-5.7-3 7 7 0 0 1-5.7 3 7 7 0 0 1-4.7-1.8 7 7 0 0 1-10.4.3 7 7 0 0 1-6 3.7 7 7 0 0 1-3.4-1q-.8 2.1-3 2.7c-1.2.2-2.9-.5-4-1.1-2-1.2-3.8-4-3.8-4l.2-1.7s1.2 1.4 3.4 3.1 3.9 1.6 3.9 1.6c1.4-.2 2-1.5 2-3v-1.5l1 1.2a5 5 0 0 0 3.7 2c2.5 0 4.5-2 4.5-4.3a4 4 0 0 0-.4-2l.8-1.9 1.5 1.5v.6c0 2.4 2 4.4 4.6 4.4 2.4 0 4.4-1.5 4.7-3.8 0 0 0-1.6.2-1.7s.6 1.7.7 1.6a5 5 0 0 0 4.5 3.1 4.6 4.6 0 0 0 4.5-6l1.2-.2"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M238.6 197.7q.4-1.4-.6-1.8-.9-.2-1.5 1.1-.4 1.4.6 1.8.9.2 1.5-1.1m-20.5-4c0-.8-.3-1.6-1-1.6q-.8 0-1.2 1.4 0 1.3.9 1.6 1-.1 1.3-1.4m-23.9 0c0-.8.4-1.6 1-1.6q1 0 1.2 1.4 0 1.3-.9 1.6-1-.1-1.2-1.4m-20.6 4q-.3-1.4.6-1.8 1-.2 1.5 1.1t-.5 1.8q-1 .2-1.6-1.1"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M182.7 184a5 5 0 0 1 2.2 2.9s0-.3.6-.6 1-.3 1-.3l-.1 1.3-.3 2.2-.7 1.6a2 2 0 0 0-1.5-.4 2 2 0 0 0-1.2.9s-.7-.6-1.2-1.3l-1.1-2-.7-1.1s.5-.2 1.1 0q.9.1.8.2a5 5 0 0 1 1-3.4m.4 9.8-.6-1q0-.7.3-1.2s-.9-.5-1.8-.7c-.7-.2-2-.2-2.3-.2h-1l.2.5.5.7a5 5 0 0 0-3 2 5 5 0 0 0 3.5 1l-.2.8v.6l1-.4c.3-.1 1.5-.5 2-1 .8-.4 1.5-1.1 1.5-1.1m2.7-.5.2-1.1-.6-1 1.4-1.3 2-.9 1.1-.4v.6l-.2.8a5 5 0 0 1 3.4 1 5 5 0 0 1-2.9 2l.7 1.2h-1c-.4 0-1.6 0-2.3-.2l-1.8-.7"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="M182.2 192.4c0-1 1-2 2-2s2.2 1 2.2 2c0 1.1-1 2-2.1 2a2 2 0 0 1-2.1-2"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M206.1 180.8a6 6 0 0 1 1.9 3.7s.2-.3.9-.5q1.1-.3 1.2-.2l-.5 1.4-.8 2.4-1 1.7a2 2 0 0 0-1.7-.7q-1 0-1.6.7-.2-.1-1-1.7l-.8-2.4-.5-1.4 1.2.2q1 .4.9.5.2-2.2 1.8-3.7"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M204.6 191.8a2 2 0 0 1-.5-1.2q0-.8.4-1.3s-.8-.7-1.8-1c-.7-.4-2-.7-2.5-.7l-1.2-.2.2.6.4.9a6 6 0 0 0-3.7 1.7q1.6 1.5 3.7 1.6l-.4 1-.2.6 1.2-.2c.4-.1 1.8-.4 2.5-.7 1-.4 1.9-1 1.9-1m3 0a2 2 0 0 0 .1-2.6s.9-.7 1.8-1a8 8 0 0 1 2.5-.7l1.2-.3-.1.7-.4.9q2.2.2 3.6 1.7a6 6 0 0 1-3.6 1.6l.5 1.6-1.2-.2-2.5-.7c-1-.4-1.8-1-1.8-1m22-8a5 5 0 0 0-2.2 3l-.7-.6-1-.3.2 1.3c0 .3 0 1.3.3 2.2q.4 1.6.6 1.6a2 2 0 0 1 1.5-.4q.9.2 1.3.9l1.1-1.3q1-1.4 1.1-2l.7-1.1s-.4-.2-1 0l-1 .2a5 5 0 0 0-1-3.4m-.3 9.8q.5-.4.6-1l-.2-1.2s.8-.5 1.7-.7c.7-.2 2-.2 2.3-.2h1.1l-.3.5-.4.7a5 5 0 0 1 2.9 2 5 5 0 0 1-3.5 1l.2.8v.6l-1-.4c-.3-.1-1.4-.5-2-1-.8-.4-1.4-1.1-1.4-1.1m-2.8-.5-.2-1.1q0-.7.6-1s-.6-.8-1.4-1.3c-.6-.4-1.7-.8-2-.9l-1-.4v.6l.2.8a5 5 0 0 0-3.5 1q1 1.4 3 2l-.5.7-.3.5h1c.4 0 1.7 0 2.3-.2l1.8-.7"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="M226 192.4c0-1 1-2 2-2s2.1 1 2.1 2a2 2 0 0 1-2 2 2 2 0 0 1-2.1-2m23.2 4.4c-.4-.5-1.4-.4-2.2.2q-1.2 1.2-.5 2.2 1 .8 2.3-.3c.7-.6 1-1.6.5-2"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="m246.3 198 .7-1c.7-.6 1.8-.7 2.3-.2l.1.2s1-2 2.3-2.6 3.4-.5 3.4-.5a3 3 0 0 0-2.9-2.8 3 3 0 0 0-2.4 1l-.2-1s-1.3.3-1.9 1.8 0 3.6 0 3.6-.3-.9-.7-1.5a8 8 0 0 0-2.4-1.6l-1.3-.7-.1.5v.8a8 8 0 0 0-3.7.5 5 5 0 0 0 2.5 2.2l-.8.7-.4.5 1.3.2 2.5.2 1.7-.2m-80.3 0q0-.6-.7-1c-.7-.7-1.7-.8-2.2-.3l-.2.3s-1-2-2.3-2.7-3.3-.5-3.3-.5a3 3 0 0 1 2.8-2.8q1.6 0 2.4 1l.2-1s1.3.3 2 1.8c.5 1.5-.1 3.6-.1 3.6s.3-.9.8-1.5a8 8 0 0 1 2.4-1.6l1.3-.7v1.3a8 8 0 0 1 3.7.5 5 5 0 0 1-2.5 2.2l.8.7.4.5-1.2.2-2.6.2-1.7-.2"/>
<path fill="#ad1519" stroke="#000" stroke-width=".4" d="M163 196.8q1-.7 2.4.3 1.2 1 .4 2c-.5.6-1.5.5-2.2-.2s-1-1.6-.5-2m41-6.3q.2-1.8 2-2c1.8-.2 2.1.9 2.1 2 0 1-1 2-2 2a2 2 0 0 1-2.1-2"/>
<path fill="#005bbf" stroke="#000" stroke-width=".3" d="M201.8 160.6c0-2.2 1.9-4 4.3-4s4.2 1.8 4.2 4-1.9 4-4.3 4a4 4 0 0 1-4.2-4"/>
<path fill="#c8b100" stroke="#000" stroke-width=".3" d="M205 149.3v2.2h-2.4v2.2h2.3v6.3H202l-.2.6q0 .9.3 1.6h7.9q.3-.7.3-1.6l-.2-.6h-2.8v-6.3h2.3v-2.2h-2.3v-2.2z"/>
<path fill="#ccc" d="M206.5 330.6a82 82 0 0 1-35.5-8.2 23 23 0 0 1-12.8-20.4v-32h96.4v32a23 23 0 0 1-12.8 20.4 81 81 0 0 1-35.3 8.2"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M206.5 330.6a82 82 0 0 1-35.5-8.2 23 23 0 0 1-12.8-20.4v-32h96.4v32a23 23 0 0 1-12.8 20.4 81 81 0 0 1-35.3 8.2z"/>
<path fill="#ccc" d="M206.3 270h48.3v-53.5h-48.3z"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M206.3 270h48.3v-53.5h-48.3z"/>
<path fill="#ad1519" d="M206.3 302c0 12.6-10.7 22.9-24 22.9s-24.2-10.3-24.2-23v-32h48.2v32"/>
<path fill="#c8b100" stroke="#000" stroke-width=".5" d="M168.6 320.9c1.5.8 3.6 2 5.8 2.6l-.1-54.7h-5.7z"/>
<path fill="#c8b100" stroke="#000" stroke-linejoin="round" stroke-width=".5" d="M158 301.6a24 24 0 0 0 5.5 15v-47.5h-5.4z"/>
<path fill="#c7b500" stroke="#000" stroke-width=".5" d="M179.4 324.7a27 27 0 0 0 5.6 0v-55.9h-5.6v56z"/>
<path fill="#c8b100" stroke="#000" stroke-width=".5" d="M190 323.5a19 19 0 0 0 5.8-2.5v-52.2H190z"/>
<path fill="#ad1519" d="M158.1 270h48.2v-53.5H158z"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M158.1 270h48.2v-53.5H158z"/>
<path fill="#c8b100" stroke="#000" stroke-width=".5" d="M201 316c2.4-2 4.6-6.8 5.4-12.2l.1-35H201l.1 47.3z"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M206.3 302c0 12.6-10.7 22.9-24 22.9s-24.2-10.3-24.2-23v-32h48.2v32"/>
<path fill="#ad1519" d="M254.6 270v32c0 12.6-10.8 22.9-24.1 22.9s-24.2-10.3-24.2-23v-32h48.3"/>
<path fill="none" stroke="#000" stroke-width=".5" d="M254.6 270v32c0 12.6-10.8 22.9-24.1 22.9s-24.2-10.3-24.2-23v-32h48.3"/>
<path fill="#c8b100" d="m215.1 294.1.1.5q-.1 1-1.1 1a1 1 0 0 1-1.1-1v-.5h-1.5a2.5 2.5 0 0 0 1.8 2.9v3.9h1.6V297a3 3 0 0 0 1.7-1.6h4.4v-1.2zm21.8 0v1.2h-4l-.3.6 4.6 5.2-1.2 1-4.6-5.3-.2.1v8.7h-1.6V297h-.2l-4.8 5.2-1.2-1 4.7-5.3-.2-.4h-4V294h13zm2.6 0v1.2h4.4q.5 1.2 1.7 1.6v3.9h1.6V297a3 3 0 0 0 1.8-2.4v-.5h-1.6l.1.5q-.1 1-1 1-1.1 0-1.2-1l.1-.5zm-6.7 22.1a16 16 0 0 0 3.7-1l.8 1.4a18 18 0 0 1-4.3 1.2 3 3 0 0 1-2.6 2 3 3 0 0 1-2.5-2 18 18 0 0 1-4.6-1.2l.8-1.4q2 .8 4 1a3 3 0 0 1 1.5-1.3v-6.7h1.6v6.7q1.1.3 1.6 1.4zm-11-2.2-.8 1.4a17 17 0 0 1-3.6-3.1q-1.4.3-2.5-.5a2.4 2.4 0 0 1-.3-3.5l.1-.1a15 15 0 0 1-1.3-4.8h1.7a13 13 0 0 0 1 4q.8 0 1.4.2l4.1-4.5 1.3 1-4.1 4.5q.8 1.5-.1 2.8a15 15 0 0 0 3.1 2.6m-6-4.8q.6-.7 1.5 0c.9.7.5 1 .1 1.4a1 1 0 0 1-1.6.1 1 1 0 0 1 0-1.5m-2.2-4.5-1.6-.3-.3-4.3 1.7-.6v2.5q0 1.4.2 2.7m1.4-5.3 1.7.4v2.2c0-.8.3 2.1.3 2.1l-1.7.6-.3-2.7zm5.6 13.7a16 16 0 0 0 4.8 2.6l.4-1.6a14 14 0 0 1-4-2zm-.8 1.4a17 17 0 0 0 4.8 2.6l-1.2 1.1a19 19 0 0 1-4-2zm2.2-9.4 1.6.7 3-3.3-1-1.4zm-1.3-1-1-1.4 3-3.3 1.6.7zm18.1 9.9.8 1.4a17 17 0 0 0 3.6-3.1q1.4.3 2.5-.5a2.4 2.4 0 0 0 .3-3.5l-.1-.1a15 15 0 0 0 1.3-4.8h-1.7a13 13 0 0 1-1 4l-1.4.2-4.1-4.5-1.3 1 4.1 4.5a2.4 2.4 0 0 0 .1 2.8 15 15 0 0 1-3.1 2.6m6-4.8a1 1 0 0 0-1.5 0 1 1 0 0 0-.1 1.4 1 1 0 0 0 1.6.1 1 1 0 0 0 0-1.5m2.2-4.5 1.6-.3.3-4.3-1.7-.6v2.5q0 1.5-.2 2.8zm-1.4-5.3-1.7.4v2.2c0-.8-.3 2.1-.3 2.1l1.7.6.3-2.7zm-5.6 13.7a16 16 0 0 1-4.8 2.6l-.4-1.6a14 14 0 0 0 4-2zm.8 1.4a17 17 0 0 1-4.8 2.6l1.2 1.1a19 19 0 0 0 4-2zm-2.2-9.4-1.6.7-2.9-3.3 1-1.4zm1.3-1 1-1.4-3-3.3-1.6.7zm-20.1-8.7.5 1.6h4.5l.5-1.6zm21.1 0-.5 1.6h-4.5l-.5-1.6zm-11.6 21.9q.1-1 1.1-1a1 1 0 0 1 1.1 1q-.1 1-1 1a1 1 0 0 1-1.2-1m1.9-7.8 1.7-.4v-4.3l-1.7-.5zm-1.6 0-1.7-.4v-4.3l1.7-.5z"/>
<path fill="#c8b100" d="M211.5 294.2q.4-1.5 1.8-2V287h1.6v5.3q1.3.5 1.7 1.6h4.4v.3h-6a1 1 0 0 0-1-.6q-.6 0-1 .6zm12.2 0v-.3h4.1l.2-.3-5-5.7 1.2-1 5 5.6.2-.1V285h1.6v7.3h.3l4.9-5.5 1.2 1-4.9 5.5.3.6h4v.3zm21.6 0a1 1 0 0 1 1-.6q.7 0 1 .6h1.6q-.4-1.5-1.8-2V287h-1.6v5.3q-1.2.4-1.7 1.6h-4.4v.3zm-30.2-15 6 6.8 1.3-1-6.1-6.7.3-.6h4.4V276h-4.4a3 3 0 0 0-2.5-1.7 2.6 2.6 0 0 0-2.7 2.5 3 3 0 0 0 1.8 2.4v5.2h1.6v-5.2zm32 0v5.3h-1.7v-5.2l-.4-.2-6 6.8-1.3-1 6.2-6.9-.1-.3h-4.5V276h4.5a3 3 0 0 1 2.4-1.7 2.6 2.6 0 0 1 2.7 2.5 2.5 2.5 0 0 1-1.9 2.4zm-16.1 0v3.3h-1.7v-3.2a3 3 0 0 1-1.7-1.6h-4V276h4a3 3 0 0 1 2.5-1.7q2 .1 2.5 1.7h4v1.6h-4a3 3 0 0 1-1.6 1.6m-17.8 4-1.7.4v4.3l1.7.5zm1.6 0 1.7.4v4.3l-1.7.5zm30.6 0-1.7.4v4.3l1.7.5zm1.6 0 1.7.4v4.3l-1.7.5zm-25.5.8 1.6-.7 2.9 3.3-1 1.4zm-1.3 1-1 1.4 3 3.3 1.6-.7zm18.5-1.1-1.6-.7-3 3.3 1 1.4zm1.2 1 1 1.4-3 3.3-1.5-.7zm-20.3 9 .5-1.6h4.5l.5 1.6zm-6.7-17q0-1 1.2-1a1 1 0 0 1 1 1q0 1-1 1a1 1 0 0 1-1.2-1m12.1.8-.5 1.6H220l-.5-1.6zm0-1.6-.5-1.6H220l-.5 1.6zm15.7 17.8-.5-1.6h-4.5l-.5 1.6zm4.4-17q.1-1 1.1-1a1 1 0 0 1 1.1 1q-.1 1-1 1a1 1 0 0 1-1.2-1m-16.1 0q.1-1 1.1-1a1 1 0 0 1 1.1 1q-.1 1-1.1 1a1 1 0 0 1-1.1-1m6.2.8.5 1.6h4.6l.5-1.6zm0-1.6.5-1.6h4.6l.5 1.6zm-5.9 5-1.7.5v4.3l1.7.5V281m1.7 0 1.6.5v4.3l-1.6.5z"/>
<path fill="none" stroke="#c8b100" stroke-width=".3" d="M232.7 316.3a16 16 0 0 0 3.7-1.1l.8 1.4a18 18 0 0 1-4.3 1.2 3 3 0 0 1-2.6 2 3 3 0 0 1-2.5-2 18 18 0 0 1-4.6-1.2l.8-1.4q2 .8 4 1a3 3 0 0 1 1.5-1.3v-6.7h1.6v6.7q1.1.3 1.6 1.4zm-4.7-20.4-.2-.5h-4V294h4l.2-.4-5-5.6 1.2-1 5 5.5h.2V285h1.7v7.3h.2l4.9-5.5 1.2 1-4.9 5.5.3.6h4v1.5h-4q0 .3-.3.5l4.7 5.3-1.3 1-4.6-5.3-.2.1v8.7h-1.6V297l-.2-.1-4.8 5.3-1.2-1 4.7-5.3m-12.8-16.7 6 6.8 1.3-1-6.1-6.7.3-.6h4.4V276h-4.4a3 3 0 0 0-2.5-1.7 2.6 2.6 0 0 0-2.6 2.5 3 3 0 0 0 1.7 2.4v5.2h1.6v-5.2zm6.5 34.8-.8 1.4a17 17 0 0 1-3.6-3.1q-1.4.3-2.5-.5a2.4 2.4 0 0 1-.3-3.5l.1-.1a15 15 0 0 1-1.2-4.8h1.6a13 13 0 0 0 1 4q.8 0 1.4.2l4.1-4.5 1.3 1-4.1 4.5q.8 1.5-.1 2.8a15 15 0 0 0 3.1 2.6zm-8.4-13.1V297a3 3 0 0 1-1.8-2.4q.2-1.6 1.8-2.4V287h1.6v5.3q1.3.4 1.7 1.6h4.4v1.5h-4.4a3 3 0 0 1-1.6 1.6v3.9h-1.7m2.3 8.3q.7-.7 1.6 0c.9.7.5 1 .1 1.4a1 1 0 0 1-1.6.1 1 1 0 0 1 0-1.5zm-2-4.5-1.7-.3-.3-4.3 1.7-.6v2.5q0 1.4.3 2.7zm1.4-5.3 1.6.4v2.2c0-.8.3 2.1.3 2.1l-1.7.6-.3-2.7v-2.6zm5.5 13.7a16 16 0 0 0 4.8 2.6l.4-1.6a14 14 0 0 1-4-2l-1.2 1m-.8 1.4a17 17 0 0 0 4.8 2.6l-1.2 1.1a19 19 0 0 1-4-2l.4-1.7"/>
<path fill="none" stroke="#c8b100" stroke-width=".3" d="m221.9 305.1 1.6.7 3-3.3-1-1.4-3.6 4m-1.3-1-1-1.4 3-3.3 1.6.7-3.6 4m-7.6-9.5q.1-1 1-1 1.1.1 1.2 1-.1 1-1.1 1.1a1 1 0 0 1-1.1-1zm25.7 19.4.8 1.4a17 17 0 0 0 3.6-3.1q1.4.3 2.6-.5a2.4 2.4 0 0 0 .2-3.5l-.1-.1a15 15 0 0 0 1.3-4.8h-1.7a13 13 0 0 1-1 4l-1.4.2-4.1-4.5-1.3 1 4.1 4.5a2.4 2.4 0 0 0 .1 2.8 15 15 0 0 1-3 2.6zm8.4-13.1V297a3 3 0 0 0 1.8-2.4q0-1.6-1.8-2.4V287h-1.6v5.3q-1.2.4-1.7 1.6h-4.4v1.5h4.4q.5 1.2 1.7 1.6v3.9zm-2.3 8.3a1 1 0 0 0-1.6 0 1 1 0 0 0-.1 1.4 1 1 0 0 0 1.6.1 1 1 0 0 0 0-1.5zm2-4.5 1.7-.3.3-4.3-1.7-.6v2.5q0 1.4-.2 2.7zm-1.3-5.3-1.7.4v2.2c0-.8-.3 2.1-.3 2.1l1.7.6.3-2.7v-2.6m1.6-20.1v5.2h-1.6v-5.2l-.4-.2-6 6.8-1.2-1 6-7v-.2h-4.5V276h4.4a3 3 0 0 1 2.5-1.7 2.6 2.6 0 0 1 2.6 2.5 3 3 0 0 1-1.8 2.4zm-16 0v3.2h-1.7v-3.2a3 3 0 0 1-1.7-1.6h-4V276h4q.7-1.6 2.5-1.7c1.8-.1 2.2.7 2.5 1.7h4v1.6h-4a3 3 0 0 1-1.6 1.6zm8.8 33.8a16 16 0 0 1-4.8 2.6l-.4-1.6a14 14 0 0 0 4-2l1.2 1m.8 1.4a17 17 0 0 1-4.8 2.6l1.2 1.1a19 19 0 0 0 4-2l-.4-1.7m-27.4-31.4-1.7.5v4.3l1.7.5v-5.2m1.7 0 1.6.4v4.3l-1.6.5V283m30.5 0-1.7.5v4.3l1.7.5V283"/>
<path fill="none" stroke="#c8b100" stroke-width=".3" d="m247.1 283.1 1.7.5v4.3l-1.7.5V283m-8.6 22-1.6.7-2.9-3.3 1-1.4 3.5 4m1.3-1 1-1.4-3-3.3-1.6.7 3.6 4m-18.2-20 1.6-.7 3 3.3-1 1.4-3.6-4m-1.3 1-1 1.4 3 3.3 1.6-.7-3.6-4m18.5-1.1-1.6-.7-3 3.3 1 1.4 3.6-4m1.2 1 1 1.4-3 3.2-1.5-.6 3.5-4m-20.3 9 .5-1.6h4.5l.5 1.6h-5.5m0 1.5.5 1.6h4.5l.5-1.6h-5.5M213 277q0-1 1.2-1 1 0 1 1c0 1-.4 1-1 1a1 1 0 0 1-1.2-1zm12.1.8-.5 1.6h-4.5l-.5-1.6h5.5m0-1.6-.5-1.6h-4.5l-.5 1.6h5.5m20.1 18.5q.1-.9 1.1-1 1 .1 1.1 1-.1 1-1 1.1a1 1 0 0 1-1.2-1zm-4.4-.7-.5-1.6h-4.5l-.5 1.6h5.5m0 1.5-.5 1.6h-4.5l-.5-1.6h5.5m-11.6 21.9q.1-1 1.1-1t1.1 1c.1 1-.5 1-1 1a1 1 0 0 1-1.2-1zm1.9-7.8 1.7-.4v-4.3l-1.7-.5v5.2m-1.6 0-1.7-.4v-4.3l1.7-.5v5.2m15.7-32.6q.1-1 1.1-1a1 1 0 0 1 1.1 1q-.1 1-1 1a1 1 0 0 1-1.2-1zm-16.1 0q.1-1 1.1-1a1 1 0 0 1 1.1 1q-.1 1-1 1a1 1 0 0 1-1.2-1zm6.2.8.5 1.6h4.6l.5-1.6h-5.5m0-1.6.4-1.6h4.6l.5 1.6h-5.5m-6 5-1.6.5v4.3l1.6.5V281m1.7 0 1.6.5v4.3l-1.6.5V281"/>
<path fill="#058e6e" d="M227.7 294.7a2.6 2.6 0 0 1 2.6-2.5 2.6 2.6 0 0 1 2.6 2.5 2.6 2.6 0 0 1-2.6 2.4c-1.4 0-2.6-1-2.6-2.4"/>
<path fill="#db4446" d="M230.9 229.7v-.6l.1-.3-2.3-.1a6 6 0 0 1-2.3-1.2q-1-1-1.6-1.2c-1.3-.2-2.3.4-2.3.4s1 .4 1.7 1.3 1.5 1.3 1.8 1.4c.6.2 2.6 0 3.1.1z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M230.9 229.7v-.6l.1-.3-2.3-.1a6 6 0 0 1-2.3-1.2q-1-1-1.6-1.2c-1.3-.2-2.3.4-2.3.4s1 .4 1.7 1.3 1.5 1.3 1.8 1.4c.6.2 2.6 0 3.1.1z"/>
<path fill="#ed72aa" stroke="#000" stroke-width=".4" d="M238.1 227.5v1.4c.2.6-.1 1.2 0 1.5q0 .5.3.9l.2.9-.7-.5-.6-.4v1l.6 1.1 1 1.3c.2.5.1 1.4.1 1.4s-.4-.7-.8-.8l-1.2-.7s.7.8.7 1.5l-.3 1.6c-.1.2-.3-.7-.8-1.1l-1-.9s.4 1.2.4 2v2.3l-.9-1-1-.7c0-.2.5.6.6 1.1s.3 2.3 1.8 4.5c1 1.3 2.3 3.6 5.3 2.9s1.9-4.8 1.3-6.7a17 17 0 0 1-1-4.6c0-.8.6-2.9.5-3.3a8 8 0 0 1 .2-3.1l.9-2.3.4-1.3.1-1.3.7 1.3.1 1.5s.1-1 1-1.6 1.8-1.1 2-1.4l.3-.5c-.1 0 0 1.8-.6 2.6l-1.7 2s.7-.3 1.2-.3h.9s-.6.4-1.4 1.6c-.8 1-.5 1.2-1 2.1s-1 1-1.7 1.5c-1 .8-.5 4.2-.4 4.7.2.5 2 4.5 2 5.5s.2 3.2-1.5 4.6c-1.1 1-3 1-3.4 1.2-.4.3-1.2 1.1-1.2 2.8s.6 2 1 2.4c.6.5 1.2.2 1.3.6l.5.7q.3.3.2.8c0 .3-.8 1.1-1.1 1.7l-.8 2.4c0 .2-.1 1 .1 1.3 0 0 .9 1 .3 1.2-.4.2-.8-.2-1-.2l-.9.5q-.3-.1-.4-.8l-.1-.7q-.3 0-.4.5c0 .2 0 .8-.3.8l-.8-.5c-.2 0-.8-.2-.8-.4q.1-.6.7-1 .8-.1.5-.5t-.7 0c-.3.4-.8 0-.7-.2v-.8c0-.2-.4-.5.1-.8s.8.2 1.4.1q.8 0 1-.6.3-.6-.2-1.4l-.9-.8-.3-.9v2.2l-.7-.8c-.3-.3-.6-1.3-.6-1.3v1.3l.2.8c-.1.1-.8-.7-1-.8l-1-1-.4-1.4v-1.5l.4-1h-1.4c-.7 0-1.2-.3-1.5.2q-.4.8.2 2.8.5 1.8.4 2.1l-.7.8h-.9l-1.2-.3h-1.3l-1.1-.3c-.3.1-.8.3-.6.7q.1.8-.5.7l-.9-.2q-.8 0-.8-.4t.4-.7q.3-.5 0-.5h-.6q-.3.4-.8.4-.3-.1-.4-1c-.1-.9-.7-1.2 0-1.1.5 0 1.3.4 1.4 0q.2-.4-.2-.7c-.4-.3-.8-.4-.3-.7l.7-.5c.1-.2.4-.8.7-.6.6.2 0 .7.6 1.3s1 1 2 .8q1.4 0 1.3-.5l-.1-1v-1s-.4.3-.5.6l-.4.8v-2l-.2-.8-.3.9-.1 1s-.7-.5-.5-1.5l.1-2c.2-.3.7-1.5 2-1.6h2.6l2-.3s-2.8-1.4-3.5-1.9a10 10 0 0 1-2-2l-.6-1.6s-.5 0-1 .3l-1.2 1-.7 1 .1-1.2v-.8s-.4 1.2-1 1.7l-1.4 1v-.8l.2-1s-.4.8-1.1 1c-.7 0-1.8 0-1.9.4q.2.8 0 1.4c0 .3-.4.5-.4.5l-.8-.4-.7.2c-.1.1-.3-.4-.2-.7s.7-.6.5-.8l-.8.2c-.3.1-.8.3-.8-.2q.2-.6 0-1 0-.4.2-.6l1.2-.1q0-.4-.8-.6-1-.2-.5-.8.3-.2.5-.6c.1-.2.2-.7.7-.5.5.3.4.8 1 1a4 4 0 0 0 2-.2l1.5-1 1.5-1-1-.8q-.5-.7-1-1l-1.8-.6-1.7-.5.8-.3q.4-.5.8-.6h.3-1.4c-.3-.1-1-.6-1.3-.6l-.8.1s.8-.4 1.4-.5l1-.1s-.9-.3-1.1-.6l-.6-1q-.2-.3-.6-.5l-1 .3q-.6 0-.6-.6l-.1-.5c-.2-.3-.6-.8-.2-1h1.4q-.2-.4-.8-.8c-.4-.2-1-.5-.7-.8l.8-.5c.2-.3.3-1 .7-.7.4.2.8 1.2 1.1 1.1s.3-.8.3-1c0-.4 0-1 .2-.9l1 .5q.8-.1 1 .2 0 .5-.6 1-.4.6-.3 1.4.4 1 1.2 1.4t1.7.9c.5.3 1.7 1.2 2.1 1.3l.8.4s.5-.2 1.1-.2 2.1 0 2.6-.2 1.3-.6 1-1c-.1-.6-1.3-1-1.2-1.4q0-.5 1.2-.4c.8 0 1.8.1 2-1q.5-1.4-.8-1.8c-1-.2-1.8-.2-2-1q-.4-1-.2-1.1.3-.3 1.4-.4 1.3 0 1.9-.2l.6-.9c.3-.2 1.4-.4 1.4-.4s1.4.7 2.7 1.7l2.2 2.1"/>
<path d="m228.1 226.8-.2-.6v-.3s.8 0 .7.3q0 .2-.3.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m228.1 226.8-.2-.6v-.3s.8 0 .7.3q0 .2-.3.3z"/>
<path d="M232 225.4v-.4s.7 0 1 .3c.5.4.9 1 .9 1l-.8-.4h-.5l-.3-.1v-.3z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M232 225.4v-.4s.7 0 1 .3c.5.4.9 1 .9 1l-.8-.4h-.5l-.3-.1v-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m237.3 231.3-.4-.7-.3-.4"/>
<path fill="#db4446" d="M217.4 226.6s.5.4.8.4h.8s.2-.5.1-.8c-.2-1.2-1.2-1.4-1.2-1.4s.3.7.1 1z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M217.4 226.6s.5.4.8.4h.8s.2-.5.1-.8c-.2-1.2-1.2-1.4-1.2-1.4s.3.7.1 1z"/>
<path fill="#db4446" d="M215.2 227.6s-.4-.7-1.3-.6-1.4.8-1.4.8h1.2c.3.3.4 1 .4 1l.7-.6z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M215.2 227.6s-.4-.7-1.3-.6-1.4.8-1.4.8h1.2c.3.3.4 1 .4 1l.7-.6z"/>
<path fill="#db4446" d="M214.2 230.6s-.8.1-1.2.6-.3 1.3-.3 1.3.4-.5.9-.5l1 .2-.1-.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M214.2 230.6s-.8.1-1.2.6-.3 1.3-.3 1.3.4-.5.9-.5l1 .2-.1-.8z"/>
<path d="m228.2 230.5.3-.5.3.5z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m228.2 230.5.3-.5.3.5h-.7"/>
<path d="m229 230.5.3-.5.4.5z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m229 230.5.3-.5.4.5h-.8"/>
<path d="m228.6 227.3.8.3-.7.4-.1-.6"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m228.6 227.3.8.3-.7.4-.1-.6"/>
<path d="m229.5 227.6.7.2-.5.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m229.5 227.6.7.2-.5.4-.2-.6"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M224.2 233.7s-.7.2-1 .6q-.4.9-.3 1c.1.1.6-.5 1.5-.3l1.2.3 1.3-.3s-.7.8-.7 1.3l.2 1.1c0 .7-.6 1.6-.6 1.6l1-.3 1.7-.8.9-1s-.2 1 0 1.4l.2 1.6.8-.6q.5-.2.9-.7l.3-1s0 .8.4 1.3l.6 1.6s.3-.8.6-1.1l.7-1-.1-.9.4.8m-11 .6s.5-.8 1-1l1.1-.8.9-.4m1 5 1.3-.8 1-1"/>
<path fill="#db4446" d="M216.6 240.4s-.4-.5-1.1-.3c-.7 0-1.2.9-1.2.9s.6-.2 1-.1.6.4.6.4l.4-.4z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M216.6 240.4s-.4-.5-1.1-.3c-.7 0-1.2.9-1.2.9s.6-.2 1-.1.6.4.6.4l.4-.4z"/>
<path fill="#db4446" d="M215.8 243.2s-.6 0-1.1.3c-.5.4-.5 1.2-.5 1.2s.4-.4.8-.3l.9.2v-.6c.2-.4-.1-.8-.1-.8"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M215.8 243.2s-.6 0-1.1.3c-.5.4-.5 1.2-.5 1.2s.4-.4.8-.3l.9.2v-.6c.2-.4-.1-.8-.1-.8z"/>
<path fill="#db4446" d="M217.2 245.8s0 .8.3 1.3c.4.5 1.1.5 1.1.5l-.3-.7.3-.8q.1-.1-.7-.3z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M217.2 245.8s0 .8.3 1.3c.4.5 1.1.5 1.1.5l-.3-.7.3-.8q.1-.1-.7-.3zm16 1.3s2 1.2 1.9 2.2c0 1-1 2.3-1 2.3"/>
<path fill="#db4446" d="M224.2 252.6s-.4-.6-1.1-.6-1.4.7-1.4.7.8-.1 1 .2l.5.6.5-.3z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M224.2 252.6s-.4-.6-1.1-.6-1.4.7-1.4.7.8-.1 1 .2l.5.6.5-.3z"/>
<path fill="#db4446" d="M222.2 255.3s-1-.1-1.4.3-.4 1.3-.4 1.3.6-.6 1-.5q.9.1 1 .3v-.7l-.3-.7"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M222.2 255.3s-1-.1-1.4.3-.4 1.3-.4 1.3.6-.6 1-.5q.9.1 1 .3v-.7l-.3-.7z"/>
<path fill="#db4446" d="M224 258.1s-.3.7 0 1.1 1 .8 1 .8-.3-.4-.2-.8c.1-.3.7-.8.7-.8l-1.4-.2"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M224 258.1s-.3.7 0 1.1 1 .8 1 .8-.3-.4-.2-.8c.1-.3.7-.8.7-.8l-1.4-.2z"/>
<path fill="#db4446" d="M236 259.3s-.8-.2-1.2 0c-.5.3-.8 1.4-.8 1.4s.7-.6 1.2-.5q.9.1 1 .3v-.8z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M236 259.3s-.8-.2-1.2 0c-.5.3-.8 1.4-.8 1.4s.7-.6 1.2-.5q.9.1 1 .3v-.8z"/>
<path fill="#db4446" d="M236.4 262.2s-.6.6-.4 1.1l.6 1s0-.7.2-1l1-.3-.7-.5z"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M236.4 262.2s-.6.6-.4 1.1l.6 1s0-.7.2-1l1-.3-.7-.5z"/>
<path fill="#db4446" d="M239.4 263s-.3.8.2 1.3l1 .5q-.2-.3-.2-1.1.3-.7.5-.7l-.8-.2-.7.3"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M239.4 263s-.3.8.2 1.3l1 .5q-.2-.3-.2-1.1.3-.7.5-.7l-.8-.2-.7.3z"/>
<path fill="#ffd691" stroke="#000" stroke-width=".5" d="M208.8 316.4q3 1 3 3.8c0 2.3-2.2 4-5 4-3 0-5.3-1.7-5.3-4 0-1.7 1-3.6 3-3.8l-.2-.4-.7-.7h1.2l.8.5.5-.7.6-.5.6.6.3.5.7-.4.8-.3s0 .4-.2.7l-.1.7"/>
<path fill="#058e6e" stroke="#000" stroke-width=".5" d="M206.3 326.7s-3.8-2.6-5.5-3c-2-.4-4.5 0-5.5 0 0 0 1.2.8 1.8 1.4.5.5 2.3 1.5 3.3 1.8 3 .8 6-.2 6-.2m1 .2s2.4-2.5 5-2.9c3-.4 5 .3 6.2.6l-1.5.8c-.5.3-2 1.5-4 1.6-2 0-4.4-.3-4.8-.2l-.9.1"/>
<path fill="#ad1519" stroke="#000" stroke-width=".5" d="M206.7 323.8a5 5 0 0 1 0-7.1 5 5 0 0 1 1.5 3.5 5 5 0 0 1-1.5 3.6"/>
<path fill="#058e6e" stroke="#000" stroke-width=".5" d="M205.7 329s.6-1.5.6-2.7l-.1-2.1h.8s.3 1.1.3 2l-.1 2.4-.7.1-.8.3"/>
<path fill="#fff" d="M254 190.7q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M254 190.7q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M255.4 188.2q.1-1 1.1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M255.4 188.2q.1-1 1.1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M256.4 185.2q.1-.9 1-1 1 .1 1.1 1c.1.9-.5 1-1 1a1 1 0 0 1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M256.4 185.2q.1-.9 1-1 1 .1 1.1 1c.1.9-.5 1-1 1a1 1 0 0 1-1.1-1z"/>
<path fill="#fff" d="M256.5 182q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M256.5 182q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M255.7 179q.1-1 1-1 1.1 0 1.2 1c.1 1-.5 1-1.1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M255.7 179q.1-1 1-1 1.1 0 1.2 1c.1 1-.5 1-1.1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M254.1 176.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M254.1 176.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M252 173.8q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M252 173.8q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M249.4 171.8q.1-.9 1.1-1a1 1 0 0 1 0 2q-1 0-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M249.4 171.8q.1-.9 1.1-1a1 1 0 0 1 0 2q-1 0-1-1z"/>
<path fill="#fff" d="M246.5 170.3q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M246.5 170.3q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M243.3 169.1q.1-.9 1.1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M243.3 169.1q.1-.9 1.1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M239.9 168.5q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M239.9 168.5q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M236.6 168.3q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M236.6 168.3q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M233.3 168.5q.1-1 1-1t1.1 1c.1 1-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M233.3 168.5q.1-1 1-1t1.1 1c.1 1-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M230.1 168.5q.1-1 1-1t1.1 1c.1 1-.5 1-1 1a1 1 0 0 1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M230.1 168.5q.1-1 1-1t1.1 1c.1 1-.5 1-1 1a1 1 0 0 1-1.1-1z"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M231.7 171.2q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1m.6 3.1q0-1 1-1c1 0 1 .4 1 1q0 .9-1 1a1 1 0 0 1-1-1m0 3q.2-.9 1.1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1m-1 2.8q.1-.9 1-1 1 .1 1.1 1 0 1-1 1a1 1 0 0 1-1-1m-1.9 2.6q.1-.9 1-1 1.1.1 1.2 1 0 1.2-1.1 1-1 0-1-1"/>
<path fill="#fff" d="M227.6 166.5q.1-.9 1.1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M227.6 166.5q.1-.9 1.1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M224.8 165q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M224.8 165q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M221.6 164q.1-1 1-1t1.1 1q-.1.9-1 1-1-.1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M221.6 164q.1-1 1-1t1.1 1q-.1.9-1 1-1-.1-1.1-1z"/>
<path fill="#fff" d="M218.3 163.4q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M218.3 163.4q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1.1-1z"/>
<path fill="#fff" d="M215 163.5q.1-1 1.1-1t1 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M215 163.5q.1-1 1.1-1t1 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M211.7 164q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M211.7 164q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M208.6 165.1q.1-.9 1-1 1 .1 1.1 1c.1.9-.5 1-1 1a1 1 0 0 1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M208.6 165.1q.1-.9 1-1 1 .1 1.1 1c.1.9-.5 1-1 1a1 1 0 0 1-1.1-1z"/>
<path fill="#fff" d="M156 190.7q0-.9 1-1c1-.1 1 .5 1 1q0 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M156 190.7q0-.9 1-1c1-.1 1 .5 1 1q0 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M154.5 188.2q.1-1 1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M154.5 188.2q.1-1 1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M153.5 185.2q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M153.5 185.2q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M153.4 182q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M153.4 182q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M154.2 179q.1-1 1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M154.2 179q.1-1 1-1t1 1c0 1-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M155.8 176.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M155.8 176.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M158 173.8q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M158 173.8q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M160.5 171.8q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M160.5 171.8q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M163.5 170.3q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M163.5 170.3q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M166.6 169.1q.1-.9 1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M166.6 169.1q.1-.9 1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M170 168.5q.1-.9 1.1-1a1 1 0 0 1 0 2q-1 0-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M170 168.5q.1-.9 1.1-1a1 1 0 0 1 0 2q-1 0-1-1z"/>
<path fill="#fff" d="M173.4 168.3q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M173.4 168.3q0-.9 1-1c1-.1 1 .5 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M176.6 168.5q.1-1 1-1t1.1 1c.1 1-.5 1-1 1a1 1 0 0 1-1.1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M176.6 168.5q.1-1 1-1t1.1 1c.1 1-.5 1-1 1a1 1 0 0 1-1.1-1z"/>
<path fill="#fff" d="M179.8 168.5q.1-1 1-1 1.1 0 1.2 1c.1 1-.5 1-1.1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M179.8 168.5q.1-1 1-1 1.1 0 1.2 1c.1 1-.5 1-1.1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" stroke="#000" stroke-width=".4" d="M178.2 171.2q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1m-.7 3.1q0-1 1-1c1 0 1 .4 1 1q0 .9-1 1a1 1 0 0 1-1-1m-.2 3q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1m.9 2.8q.1-.9 1-1 1 .1 1.1 1-.1 1-1 1a1 1 0 0 1-1.1-1m1.8 2.6q.1-.9 1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1"/>
<path fill="#fff" d="M182.3 166.5q.1-.9 1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M182.3 166.5q.1-.9 1-1a1 1 0 0 1 0 2 1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M185.2 165q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M185.2 165q0-1 1-1c1 0 1 .4 1 1s-.4 1-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M188.3 164q.1-1 1-1t1.1 1q0 .9-1 1c-1 .1-1-.5-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M188.3 164q.1-1 1-1t1.1 1q0 .9-1 1c-1 .1-1-.5-1-1z"/>
<path fill="#fff" d="M191.6 163.4q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M191.6 163.4q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M194.9 163.5q0-1 1-1c1 0 1 .4 1 1q0 .9-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M194.9 163.5q0-1 1-1c1 0 1 .4 1 1q0 .9-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M198.2 164q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M198.2 164q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#fff" d="M201.3 165.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1"/>
<path fill="none" stroke="#000" stroke-width=".4" d="M201.3 165.1q.1-.9 1-1 1 .1 1.1 1 0 1.2-1 1a1 1 0 0 1-1-1z"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M174.7 228.9h-1v-1h-1.5v3.6h1.6v2.5h-3.4v7h1.8v14.3h-3.5v7.3h27.2v-7.3h-3.5V241h1.8v-7h-3.4v-2.5h1.6V228h-1.6v.9h-.8v-1h-1.6v1h-1.1v-1h-1.6v3.6h1.6v2.5H184v-7.8h1.7v-3.5H184v.9h-1v-1h-1.5v1h-.9v-1H179v3.6h1.7v7.8h-3.3v-2.5h1.6V228h-1.6v.9h-.9v-1h-1.8zm-6 33.7H196m-27.3-1.8H196m-27.3-1.8H196m-27.3-1.7H196m-27.3-2H196m-23.8-1.6h20.2m-20.2-1.8h20.2m-20.2-2h20.2m-20.2-1.7h20.2m-20.2-1.8h20.2m-20.2-1.8h20.2m-20.2-1.7h20.2m-22-1.8h23.8m-23.8-1.8h23.8m-23.8-1.8h23.8m-23.8-1.8h23.8m-20.4-1.7h17m-10.2-1.8h3.4m-3.4-1.8h3.4m-3.4-1.8h3.4m-3.4-1.7h3.4m-5.1-2.2h6.8m-12 7.5h3.6m-5-2.2h6.6m-6.7 32.6v-1.8m0-1.8v-1.7m-1.8 1.7v1.8m3.4 0V259m1.7 3.6v-1.8m0-1.8v-1.7m0-2v-1.6m0-1.8v-2m-1.7 7.4v-2m-3.4 2v-2m7 0v2m1.5-2v-1.6m-5.1-1.8v1.8m3.5-1.8v1.8m3.3-1.8v1.8M179 252v-2m1.7-1.7v1.7m0-5.3v1.8m-1.7-3.6v1.8m1.7-3.5v1.7m-3.3-1.7v1.7m-3.5-1.7v1.7m-1.6-3.5v1.8m3.3-1.8v1.8m3.4-1.8v1.8m1.7-3.6v1.8m-3.3-1.8v1.8m-3.5-1.8v1.8m-1.6-3.6v1.8m6.7-1.8v1.8m-3.4-5.3v1.8m15.3-1.8h-3.5m5-2.2h-6.6m6.7 32.6v-1.8m0-1.8v-1.7m1.8 1.7v1.8m-3.4 0V259m-1.7 3.6v-1.8m0-1.8v-1.7m0-2v-1.6m0-1.8v-2m1.7 7.4v-2m3.4 2v-2m-7 0v2m-1.5-2v-1.6m5.1-1.8v1.8m-3.5-1.8v1.8m-3.3-1.8v1.8m1.7-1.8v-2m-1.7-1.7v1.7m0-5.3v1.8m1.7-3.6v1.8m-1.7-3.5v1.7m3.3-1.7v1.7m3.5-1.7v1.7m1.6-3.5v1.8m-3.3-1.8v1.8m-3.4-1.8v1.8m-1.7-3.6v1.8m3.3-1.8v1.8m3.5-1.8v1.8m1.6-3.6v1.8m-6.7-1.8v1.8m3.4-5.3v1.8m-7 18v-2m0-5.4v-1.8m0 5.4v-1.8m0-5.3v-1.8m0-1.8v-1.7m0-3.6v-1.8m0-1.7v-1.8m-8.3 4.6h3.5m3.3-5.3h3.4m3.3 5.3h3.5"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="M186.8 262.6v-4.7c0-.8-.4-3.5-4.6-3.5-4 0-4.4 2.7-4.4 3.5v4.7z"/>
<path fill="#c8b100" stroke="#000" stroke-width=".4" d="m179.3 258.2-2.2-.3c0-.9.2-2.2.9-2.6l2 1.5c-.3.2-.7 1-.7 1.4zm6 0 2.2-.3c0-.9-.2-2.2-.9-2.6l-2 1.5c.3.2.7 1 .7 1.4zm-2.2-2.3 1-2-2-.4-1.7.4 1.1 2zm-4.2-5.5v-4.9c0-1.3-1-2.4-2.5-2.4s-2.4 1-2.4 2.4v4.9zm6.8 0v-4.9c0-1.3 1-2.4 2.5-2.4s2.4 1 2.4 2.4v4.9zm-1.7-12 .4-4.4h-4.2l.2 4.4zm3.3 0-.4-4.4h4.4l-.5 4.4zm-10 0 .2-4.4h-4.2l.5 4.4z"/>
<path fill="#0039f0" d="M185.3 262.6v-4c0-.7-.5-2.7-3.1-2.7-2.4 0-2.9 2-2.9 2.7v4zm-6.9-12.7v-4.2c0-1-.6-2.2-2-2.2s-2 1.1-2 2.2v4.3h4zm7.8 0v-4.2c0-1 .7-2.2 2-2.2s2 1.1 2 2.2v4.3h-4z"/>
<path fill="#ad1519" d="M190.8 269.8c0-9.7 7-17.6 15.6-17.6s15.6 7.9 15.6 17.6-7 17.5-15.6 17.5-15.6-7.8-15.6-17.5"/>
<path fill="none" stroke="#000" stroke-width=".6" d="M190.8 269.8c0-9.7 7-17.6 15.6-17.6s15.6 7.9 15.6 17.6-7 17.5-15.6 17.5-15.6-7.8-15.6-17.5z"/>
<path fill="#005bbf" d="M195.4 269.7c0-7 5-12.8 11-12.8s11 5.7 11 12.8-5 13-11 13-11-5.8-11-13"/>
<path fill="none" stroke="#000" stroke-width=".6" d="M195.4 269.7c0-7 5-12.8 11-12.8s11 5.7 11 12.8-5 13-11 13-11-5.8-11-13z"/>
<path fill="#c8b100" d="M201.2 260.9s-1.3 1.4-1.3 2.7a6 6 0 0 0 .6 2.4q-.4-.8-1.4-.8-1.3.1-1.4 1.3l.2.8.5.9q.2-.5 1-.5 1.1 0 1 1v.2h-1.2v1h1l-.8 1.5 1-.4.8.9.8-.9 1 .4-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.6 0 1 .6l.4-1 .2-.7a1.4 1.4 0 0 0-1.4-1.3q-1 0-1.4.9s.6-1.2.6-2.5-1.4-2.7-1.4-2.7"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M201.2 260.9s-1.3 1.4-1.3 2.7a6 6 0 0 0 .6 2.4q-.4-.8-1.4-.8-1.3.1-1.4 1.3l.2.8.5.9q.2-.5 1-.5 1.1 0 1 1v.2h-1.2v1h1l-.8 1.5 1-.4.8.9.8-.9 1 .4-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.6 0 1 .6l.4-1 .2-.7a1.4 1.4 0 0 0-1.4-1.3q-1 0-1.4.9s.6-1.2.6-2.5-1.4-2.7-1.4-2.7z"/>
<path fill="#c8b100" d="M199.2 269.9h4.1v-1h-4.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M199.2 269.9h4.1v-1h-4.1z"/>
<path fill="#c8b100" d="M211.4 260.9s-1.3 1.4-1.3 2.7.6 2.4.6 2.4q-.3-.8-1.4-.8-1.3.1-1.4 1.3l.2.8.5.9q.3-.5 1-.5a1 1 0 0 1 1 1v.2h-1.2v1h1l-.8 1.5 1-.4.8.9.8-.9 1 .4-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.7 0 1 .6l.4-1 .2-.7a1.4 1.4 0 0 0-1.4-1.3q-1 0-1.4.9s.6-1.2.6-2.5-1.4-2.7-1.4-2.7"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M211.4 260.9s-1.3 1.4-1.3 2.7.6 2.4.6 2.4q-.3-.8-1.4-.8-1.3.1-1.4 1.3l.2.8.5.9q.3-.5 1-.5a1 1 0 0 1 1 1v.2h-1.2v1h1l-.8 1.5 1-.4.8.9.8-.9 1 .4-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.7 0 1 .6l.4-1 .2-.7a1.4 1.4 0 0 0-1.4-1.3q-1 0-1.4.9s.6-1.2.6-2.5-1.4-2.7-1.4-2.7z"/>
<path fill="#c8b100" d="M209.4 269.9h4.1v-1h-4.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M209.4 269.9h4.1v-1h-4.1z"/>
<path fill="#c8b100" d="M206.3 269.6s-1.3 1.5-1.3 2.8.6 2.4.6 2.4q-.3-.8-1.4-.9-1.3.1-1.4 1.4l.2.7.5 1q.2-.6 1-.6a1 1 0 0 1 1 1v.3h-1.2v1h1l-.8 1.5 1-.4.8.9.8-1 1 .5-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.6 0 .9.6l.5-1 .2-.7a1.4 1.4 0 0 0-1.4-1.4q-1 0-1.4 1s.6-1.2.6-2.5-1.4-2.7-1.4-2.7"/>
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-width=".3" d="M206.3 269.6s-1.3 1.5-1.3 2.8.6 2.4.6 2.4q-.3-.8-1.4-.9-1.3.1-1.4 1.4l.2.7.5 1q.2-.6 1-.6a1 1 0 0 1 1 1v.3h-1.2v1h1l-.8 1.5 1-.4.8.9.8-1 1 .5-.7-1.5h1v-1h-1.1v-.3a1 1 0 0 1 1-1q.6 0 .9.6l.5-1 .2-.7a1.4 1.4 0 0 0-1.4-1.4q-1 0-1.4 1s.6-1.2.6-2.5-1.4-2.7-1.4-2.7z"/>
<path fill="#c8b100" d="M204.3 278.6h4.1v-1h-4.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M204.3 278.6h4.1v-1h-4.1z"/>
<path fill="#c8b100" d="M237.6 223.4h-.3l-.3.4q-.4.3-.8 0l-.1-.4h-.5q-.4-.3-.1-.7v-.5h-.3l-.1.2q-.3.4-.7.2v-.2h-.3c-.5.2-.7-1-.7-1.2l-.2.2s.2.7.1 1.2l-.3 1.2a9 9 0 0 1 2.9 1.6 9 9 0 0 1 2.2 2.3l1.2-.5c.6-.2 1.3-.2 1.3-.2l.2-.2c-.3 0-1.5.1-1.5-.4v-.2h-.2q-.3-.3 0-.7l.2-.1v-.3h-.3l-.2.1q-.4.5-.8 0l-.1-.4h-.5q-.4-.3 0-.8l.2-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M237.6 223.4h-.3l-.3.4q-.4.3-.8 0l-.1-.4h-.5q-.4-.3-.1-.7v-.5h-.3l-.1.2q-.3.4-.7.2v-.2h-.3c-.5.2-.7-1-.7-1.2l-.2.2s.2.7.1 1.2l-.3 1.2a9 9 0 0 1 2.9 1.6 9 9 0 0 1 2.2 2.3l1.2-.5c.6-.2 1.3-.2 1.3-.2l.2-.2c-.3 0-1.5.1-1.5-.4v-.2h-.2q-.3-.3 0-.7l.2-.1v-.3h-.3l-.2.1q-.4.5-.8 0l-.1-.4h-.5q-.4-.3 0-.8l.2-.3z"/>
<path d="M235.4 224h.2v.3h-.1q-.1-.1 0-.2"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M235.4 224h.2v.3h-.1q-.1-.1 0-.2z"/>
<path d="m236.3 224.8-.3-.2v-.2h.1l.4.3.3.2v.2h-.2z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="m236.3 224.8-.3-.2v-.2h.1l.4.3.3.2v.2h-.2l-.3-.3"/>
<path d="m234.6 223.7-.2-.2s-.1 0 0-.1l.3.1.3.1v.2h-.1z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="m234.6 223.7-.2-.2s-.1 0 0-.1l.3.1.3.1v.2h-.1l-.3-.1"/>
<path d="M233.7 223h.2v.2h-.2s-.1-.1 0-.2"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M233.7 223h.2v.2h-.2s-.1-.1 0-.2z"/>
<path d="M237.3 225.5v-.2h-.3l.1.2z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M237.3 225.5v-.2h-.3l.1.2z"/>
<path d="m237.9 226.2.2.2h.1v-.2l-.2-.2-.2-.2h-.1v.2z"/>
<path fill="none" stroke="#000" stroke-width=".1" d="m237.9 226.2.2.2h.1v-.2l-.2-.2-.2-.2h-.1v.2l.2.2"/>
<path d="M238.8 227v-.3h-.3v.2h.3"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M238.8 227v-.3h-.3v.2h.3z"/>
<path fill="#c8b100" d="M236.2 221.1h-.6l-.1.9v.1h.2l.7-.5-.3-.5"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M236.2 221.1h-.6l-.1.9v.1h.2l.7-.5-.3-.5"/>
<path fill="#c8b100" d="M234.6 221.6v.5l.9.1h.1v-.2l-.5-.7z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M234.6 221.6v.5l.9.1h.1v-.2l-.5-.7-.5.3"/>
<path fill="#c8b100" d="m236.4 222.6-.4.3-.6-.7v-.1h1.1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m236.4 222.6-.4.3-.6-.7v-.1h1.1v.5"/>
<path fill="#c8b100" d="M235.3 222h.4v.3h-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M235.3 222h.4v.3h-.3z"/>
<path fill="#c8b100" d="m233.2 221.1-.2-.7-.4-.4s.4-.2.8.1 0 .9 0 .9z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m233.2 221.1-.2-.7-.4-.4s.4-.2.8.1 0 .9 0 .9z"/>
<path fill="#c8b100" d="m234.2 221.4-.4.4-.6-.6v-.2h1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m234.2 221.4-.4.4-.6-.6v-.2h1v.4"/>
<path fill="#c8b100" d="m233.1 221 .3-.1v.3q0 .3-.2.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m233.1 221 .3-.1v.3q0 .3-.2.2z"/>
<path fill="#c8b100" d="M238.3 222.5h-.5l-.3.7v.2h.2l.8-.4z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M238.3 222.5h-.5l-.3.7v.2h.2l.8-.4-.2-.5"/>
<path fill="#c8b100" d="M236.7 222.8v.5l.8.2h.1v-.2l-.4-.7z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M236.7 222.8v.5l.8.2h.1v-.2l-.4-.7-.5.2"/>
<path fill="#c8b100" d="m238.4 224-.5.2-.4-.7v-.2h.1l.9.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m238.4 224-.5.2-.4-.7v-.2h.1l.9.2-.1.5"/>
<path fill="#c8b100" d="M237.3 223.2h.4v.4h-.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M237.3 223.2h.4v.4h-.3z"/>
<path fill="#c8b100" d="m240.2 224.3.1.5-.8.3h-.2v-.2l.4-.8z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m240.2 224.3.1.5-.8.3h-.2v-.2l.4-.8.5.2"/>
<path fill="#c8b100" d="m240 225.8-.5.1-.3-.8v-.1h.2l.8.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m240 225.8-.5.1-.3-.8v-.1h.2l.8.3-.1.5"/>
<path fill="#c8b100" d="m238.6 224.3-.2.5.9.3h.1v-.1l-.3-.8z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m238.6 224.3-.2.5.9.3h.1v-.1l-.3-.8-.5.1"/>
<path fill="#c8b100" d="M239.5 225.2v-.3h-.4v.3z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M239.5 225.2v-.3h-.4v.3z"/>
<path fill="#c8b100" d="M240.8 227h.8l.5.3s.1-.4-.3-.7c-.3-.3-.8.2-.8.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M240.8 227h.8l.5.3s.1-.4-.3-.7c-.3-.3-.8.2-.8.2z"/>
<path fill="#c8b100" d="m240.3 226.1-.3.5.8.5v-.1h.2l-.1-1z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="m240.3 226.1-.3.5.8.5v-.1h.2l-.1-1-.6.1"/>
<path fill="#c8b100" d="M241 227s.1-.1 0-.2h-.3q-.3 0-.1.2z"/>
<path fill="none" stroke="#000" stroke-width=".3" d="M241 227s.1-.1 0-.2h-.3q-.3 0-.1.2zm38-21.9v.6h-2.4v-.6h1v-1.3h-.7v-.5h.6v-.6h.6v.6h.6v.6h-.6v1.2h1"/>
<path fill="none" d="M134.4 217.1v-1.2m-.4 1.2v-1.2m-.2 1.2v-1.2m-.3 1.2v-1.2"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M133.2 217.1v-1.2m-.5 1.1v-1m.2 1v-1m-.7 1v-1m.2 1v-1m-.9 1v-1m.2 1v-1m.3 1v-1m-.7 1v-1m-.3.9v-.8m-.1.8v-.8m-.5.7v-.6m.2.6v-.6m-.4.5v-.5m-.2.5v-.4m-.3.3v-.3m-.3.3v-.2"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M129.2 216.6v-.2"/>
<path fill="none" d="M135.7 217v-1m-.5 1v-1m-.4 1.2V216m143 1.1V216m-.4 1.1V216m-.3 1.1V216m-.3 1.2V216"/>
<path fill="none" stroke="#000" stroke-width=".1" d="M276.6 217.1V216m-.6 1v-1m.3 1v-1m-.8 1v-1m.3 1v-1m-.9 1v-1m.2 1v-1m.2 1v-1m-.6 1v-1m-.3.9v-.8m-.2.8v-.8m-.4.7v-.6m.2.6v-.6m-.5.6v-.6m-.2.5v-.4m-.3.4v-.4m-.2.3v-.2"/>
<path fill="none" stroke="#000" stroke-width=".2" d="M272.6 216.6v-.2"/>
<path fill="none" d="M279.1 217v-1m-.6 1v-1m-.4 1.1V216"/>
</svg>

After

Width:  |  Height:  |  Size: 79 KiB

5
docs/assets/flags/fi.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-fi" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v480H0z"/>
<path fill="#002f6c" d="M0 174.5h640v131H0z"/>
<path fill="#002f6c" d="M175.5 0h130.9v480h-131z"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

5
docs/assets/flags/fr.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-fr" viewBox="0 0 640 480">
<path fill="#000091" d="M0 0h213.3v480H0z"/>
<path fill="#fff" d="M213.3 0h213.4v480H213.3z"/>
<path fill="#e1000f" d="M426.7 0H640v480H426.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

7
docs/assets/flags/hu.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-hu" viewBox="0 0 640 480">
<g fill-rule="evenodd">
<path fill="#d43516" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#388d00" d="M0 320h640v160H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 264 B

4
docs/assets/flags/id.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-id" viewBox="0 0 640 480">
<path fill="#e70011" d="M0 0h640v240H0Z"/>
<path fill="#fff" d="M0 240h640v240H0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 178 B

14
docs/assets/flags/il.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-il" viewBox="0 0 640 480">
<defs>
<clipPath id="il-a">
<path fill-opacity=".7" d="M-87.6 0H595v512H-87.6z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#il-a)" transform="translate(82.1)scale(.94)">
<path fill="#fff" d="M619.4 512H-112V0h731.4z"/>
<path fill="#0038b8" d="M619.4 115.2H-112V48h731.4zm0 350.5H-112v-67.2h731.4zm-483-275 110.1 191.6L359 191.6z"/>
<path fill="#fff" d="m225.8 317.8 20.9 35.5 21.4-35.3z"/>
<path fill="#0038b8" d="M136 320.6 246.2 129l112.4 190.8z"/>
<path fill="#fff" d="m225.8 191.6 20.9-35.5 21.4 35.4zM182 271.1l-21.7 36 41-.1-19.3-36zm-21.3-66.5 41.2.3-19.8 36.3zm151.2 67 20.9 35.5-41.7-.5zm20.5-67-41.2.3 19.8 36.3zm-114.3 0L189.7 256l28.8 50.3 52.8 1.2 32-51.5-29.6-52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 834 B

25
docs/assets/flags/in.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-in" viewBox="0 0 640 480">
<path fill="#f93" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#128807" d="M0 320h640v160H0z"/>
<g transform="matrix(3.2 0 0 3.2 320 240)">
<circle r="20" fill="#008"/>
<circle r="17.5" fill="#fff"/>
<circle r="3.5" fill="#008"/>
<g id="in-d">
<g id="in-c">
<g id="in-b">
<g id="in-a" fill="#008">
<circle r=".9" transform="rotate(7.5 -8.8 133.5)"/>
<path d="M0 17.5.6 7 0 2l-.6 5z"/>
</g>
<use xlink:href="#in-a" width="100%" height="100%" transform="rotate(15)"/>
</g>
<use xlink:href="#in-b" width="100%" height="100%" transform="rotate(30)"/>
</g>
<use xlink:href="#in-c" width="100%" height="100%" transform="rotate(60)"/>
</g>
<use xlink:href="#in-d" width="100%" height="100%" transform="rotate(120)"/>
<use xlink:href="#in-d" width="100%" height="100%" transform="rotate(-120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

219
docs/assets/flags/ir.svg Normal file
View File

@@ -0,0 +1,219 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ir" viewBox="0 0 640 480">
<defs>
<clipPath id="ir-a">
<path fill-opacity=".7" d="M-85.3 0h682.7v512H-85.3z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#ir-a)" transform="translate(80)scale(.9375)">
<path fill="#fff" d="M-192 0h896v512h-896z"/>
<path fill="#da0000" d="M-192 343.8h896V512h-896z"/>
<g fill="#fff" stroke-width="1pt">
<path d="M-21.6 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm41.9 0v3.3h-9.8v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M52.4 367.7v3.4H33.8v-3.4zm-34.6-7.9H21v11.3h-3.3z"/>
<path d="M49.6 351H53v20h-3.4zm-8.4 0h3.3v20h-3.3zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M17.8 359.9H21V371h-3.3z"/>
<path d="M17.8 359.9H21V371h-3.3z"/>
<path d="M17.8 359.9H21V371h-3.3zm-39.3 0h3.3V371h-3.3zm28.8 0h3.4V371H7.3zm-14.3 0h3.4V371H-7z"/>
<path d="M9.6 367.7v3.4H-5.5v-3.4zm1-8.7v3.4H1V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M-102.2 351h49v3.3h-49zm7.3 16.8h3.4v3.3H-95zm41.9 0v3.3h-9.8v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M-28.2 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.3v11.3h-3.3z"/>
<path d="M-31 351h3.4v20H-31zm-8.4 0h3.3v20h-3.3zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M-62.8 359.9h3.3V371h-3.3z"/>
<path d="M-62.8 359.9h3.3V371h-3.3z"/>
<path d="M-62.8 359.9h3.3V371h-3.3zm-39.3 0h3.3V371h-3.3zm28.8 0h3.3V371h-3.3zm-14.3 0h3.4V371h-3.4z"/>
<path d="M-71 367.7v3.4h-15v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M58.3 351h49v3.3h-49zm7.3 16.8H69v3.3h-3.4zm41.9 0v3.3h-9.8v-3.4zm5.3-16.8h3.4v20h-3.4z"/>
<path d="M132.3 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M129.5 351h3.4v20h-3.4zm-8.4 0h3.4v20H121zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M97.7 359.9h3.4V371h-3.4z"/>
<path d="M97.7 359.9h3.4V371h-3.4z"/>
<path d="M97.7 359.9h3.4V371h-3.4zm-39.3 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371h-3.4z"/>
<path d="M89.6 367.7v3.4H74.4v-3.4zm1-8.7v3.4H81V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M622.7 351h49v3.3h-49zm7.3 16.8h3.4v3.3H630zm41.9 0v3.3H662v-3.4zm5.3-16.8h3.3v20h-3.4z"/>
<path d="M696.7 367.7v3.4H678v-3.4zm-34.6-7.9h3.4v11.3H662z"/>
<path d="M694 351h3.3v20h-3.4zm-8.5 0h3.4v20h-3.4zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M662 359.9h3.5V371H662z"/>
<path d="M662 359.9h3.5V371H662z"/>
<path d="M662 359.9h3.5V371H662zm-39.2 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371h-3.4z"/>
<path d="M654 367.7v3.4h-15.2v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M138.7 351h49.1v3.3h-49zm7.4 16.8h3.3v3.3h-3.3zm41.8 0v3.3h-9.8v-3.4zm5.3-16.8h3.4v20h-3.4z"/>
<path d="M212.8 367.7v3.4h-18.6v-3.4zm-34.7-7.9h3.4v11.3h-3.4z"/>
<path d="M210 351h3.4v20H210zm-8.5 0h3.4v20h-3.4zm-44.8 8v3.4h-17.9V359zm39.3 0v3.4h-17.9V359z"/>
<path d="M178.1 359.9h3.4V371h-3.4z"/>
<path d="M178.1 359.9h3.4V371h-3.4z"/>
<path d="M178.1 359.9h3.4V371h-3.4zm-39.3 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.2 0h3.3V371h-3.3z"/>
<path d="M170 367.7v3.4h-15.1v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M219.5 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm41.9 0v3.3h-9.8v-3.4zM274 351h3.3v20H274z"/>
<path d="M293.5 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M290.7 351h3.4v20h-3.4zm-8.4 0h3.4v20h-3.4zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M258.9 359.9h3.4V371h-3.4z"/>
<path d="M258.9 359.9h3.4V371h-3.4z"/>
<path d="M258.9 359.9h3.4V371h-3.4zm-39.3 0h3.3V371h-3.3zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371H234z"/>
<path d="M250.8 367.7v3.4h-15.2v-3.4zm1-8.7v3.4H242V359z"/>
</g>
<path fill="#239f40" d="M-192 0h896v168.2h-896z"/>
<g fill="#fff" stroke-width="1pt">
<path d="M300.7 351h49v3.3h-49zm7.3 16.8h3.4v3.3H308zm41.9 0v3.3H340v-3.4zm5.3-16.8h3.3v20h-3.3z"/>
<path d="M374.7 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3H340z"/>
<path d="M372 351h3.3v20H372zm-8.5 0h3.4v20h-3.4zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M340 359.9h3.5V371H340z"/>
<path d="M340 359.9h3.5V371H340z"/>
<path d="M340 359.9h3.5V371H340zm-39.2 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371h-3.4z"/>
<path d="M332 367.7v3.4h-15.2v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M381.4 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm42 0v3.3h-9.9v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M455.4 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M452.7 351h3.3v20h-3.3zm-8.5 0h3.4v20h-3.4zm-44.8 8v3.4h-17.9V359zm39.3 0v3.4h-17.9V359z"/>
<path d="M420.8 359.9h3.4V371h-3.4z"/>
<path d="M420.8 359.9h3.4V371h-3.4z"/>
<path d="M420.8 359.9h3.4V371h-3.4zm-39.3 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371h-3.3z"/>
<path d="M412.7 367.7v3.4h-15.1v-3.4zm1-8.7v3.4H404V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M462.2 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm41.9 0v3.3h-9.8v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M536.2 367.7v3.4h-18.6v-3.4zm-34.7-7.9h3.4v11.3h-3.4z"/>
<path d="M533.4 351h3.4v20h-3.4zm-8.4 0h3.3v20H525zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M501.6 359.9h3.3V371h-3.3z"/>
<path d="M501.6 359.9h3.3V371h-3.3z"/>
<path d="M501.6 359.9h3.3V371h-3.3zm-39.4 0h3.4V371h-3.4zm28.9 0h3.3V371h-3.3zm-14.3 0h3.4V371h-3.4z"/>
<path d="M493.4 367.7v3.4h-15.1v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M543.4 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm41.9 0v3.3h-9.8v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M617.4 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.3v11.3h-3.3z"/>
<path d="M614.6 351h3.4v20h-3.4zm-8.4 0h3.3v20h-3.3zm-44.8 8v3.4h-18V359zm39.3 0v3.4h-18V359z"/>
<path d="M582.8 359.9h3.3V371h-3.3z"/>
<path d="M582.8 359.9h3.3V371h-3.3z"/>
<path d="M582.8 359.9h3.3V371h-3.3zm-39.3 0h3.3V371h-3.3zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371H558z"/>
<path d="M574.6 367.7v3.4h-15.1v-3.4zm1-8.7v3.4H566V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M-183.8 351h49v3.3h-49zm7.3 16.8h3.4v3.3h-3.4zm42 0v3.3h-9.9v-3.4zm5.2-16.8h3.4v20h-3.4z"/>
<path d="M-109.8 367.7v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M-112.5 351h3.3v20h-3.3zm-8.5 0h3.4v20h-3.4zm-44.8 8v3.4h-17.9V359zm39.3 0v3.4h-17.9V359z"/>
<path d="M-144.4 359.9h3.4V371h-3.4z"/>
<path d="M-144.4 359.9h3.4V371h-3.4z"/>
<path d="M-144.4 359.9h3.4V371h-3.4zm-39.3 0h3.4V371h-3.4zm28.8 0h3.4V371h-3.4zm-14.3 0h3.4V371h-3.4z"/>
<path d="M-152.5 367.7v3.4h-15.2v-3.4zm1-8.7v3.4h-9.6V359z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M-21.6 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm41.9-.2v3.4h-9.8v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M52.4 160.2v3.4H33.8v-3.4zm-34.6-7.9H21v11.3h-3.3z"/>
<path d="M49.6 143.4H53v20.2h-3.4zm-8.4 0h3.3v20.2h-3.3zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M17.8 152.3H21v11.3h-3.3z"/>
<path d="M17.8 152.3H21v11.3h-3.3z"/>
<path d="M17.8 152.3H21v11.3h-3.3zm-39.3 0h3.3v11.3h-3.3zm28.8 0h3.4v11.3H7.3zm-14.3 0h3.4v11.3H-7z"/>
<path d="M9.6 160.2v3.4H-5.5v-3.4zm1-8.7v3.3H1v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M-102.2 143.4h49v3.4h-49zm7.3 17h3.4v3.2H-95zm41.9-.2v3.4h-9.8v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M-28.2 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.3v11.3h-3.3z"/>
<path d="M-31 143.4h3.4v20.2H-31zm-8.4 0h3.3v20.2h-3.3zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M-62.8 152.3h3.3v11.3h-3.3z"/>
<path d="M-62.8 152.3h3.3v11.3h-3.3z"/>
<path d="M-62.8 152.3h3.3v11.3h-3.3zm-39.3 0h3.3v11.3h-3.3zm28.8 0h3.3v11.3h-3.3zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M-71 160.2v3.4h-15v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M58.3 143.4h49v3.4h-49zm7.3 17H69v3.2h-3.4zm41.9-.2v3.4h-9.8v-3.4zm5.3-16.8h3.4v20.2h-3.4z"/>
<path d="M132.3 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M129.5 143.4h3.4v20.2h-3.4zm-8.4 0h3.4v20.2H121zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M97.7 152.3h3.4v11.3h-3.4z"/>
<path d="M97.7 152.3h3.4v11.3h-3.4z"/>
<path d="M97.7 152.3h3.4v11.3h-3.4zm-39.3 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M89.6 160.2v3.4H74.4v-3.4zm1-8.7v3.3H81v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M622.7 143.4h49v3.4h-49zm7.3 17h3.4v3.2H630zm41.9-.2v3.4H662v-3.4zm5.3-16.8h3.3v20.2h-3.4z"/>
<path d="M696.7 160.2v3.4H678v-3.4zm-34.6-7.9h3.4v11.3H662z"/>
<path d="M694 143.4h3.3v20.2h-3.4zm-8.5 0h3.4v20.2h-3.4zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M662 152.3h3.5v11.3H662z"/>
<path d="M662 152.3h3.5v11.3H662z"/>
<path d="M662 152.3h3.5v11.3H662zm-39.2 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M654 160.2v3.4h-15.2v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M138.7 143.4h49.1v3.4h-49zm7.4 17h3.3v3.2h-3.3zm41.8-.2v3.4h-9.8v-3.4zm5.3-16.8h3.4v20.2h-3.4z"/>
<path d="M212.8 160.2v3.4h-18.6v-3.4zm-34.7-7.9h3.4v11.3h-3.4z"/>
<path d="M210 143.4h3.4v20.2H210zm-8.5 0h3.4v20.2h-3.4zm-44.8 8v3.4h-17.9v-3.3zm39.3 0v3.4h-17.9v-3.3z"/>
<path d="M178.1 152.3h3.4v11.3h-3.4z"/>
<path d="M178.1 152.3h3.4v11.3h-3.4z"/>
<path d="M178.1 152.3h3.4v11.3h-3.4zm-39.3 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.2 0h3.3v11.3h-3.3z"/>
<path d="M170 160.2v3.4h-15.1v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M219.5 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm41.9-.2v3.4h-9.8v-3.4zm5.3-16.8h3.3v20.2H274z"/>
<path d="M293.5 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M290.7 143.4h3.4v20.2h-3.4zm-8.4 0h3.4v20.2h-3.4zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M258.9 152.3h3.4v11.3h-3.4z"/>
<path d="M258.9 152.3h3.4v11.3h-3.4z"/>
<path d="M258.9 152.3h3.4v11.3h-3.4zm-39.3 0h3.3v11.3h-3.3zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3H234z"/>
<path d="M250.8 160.2v3.4h-15.2v-3.4zm1-8.7v3.3H242v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M300.7 143.4h49v3.4h-49zm7.3 17h3.4v3.2H308zm41.9-.2v3.4H340v-3.4zm5.3-16.8h3.3v20.2h-3.3z"/>
<path d="M374.7 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3H340z"/>
<path d="M372 143.4h3.3v20.2H372zm-8.5 0h3.4v20.2h-3.4zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M340 152.3h3.5v11.3H340z"/>
<path d="M340 152.3h3.5v11.3H340z"/>
<path d="M340 152.3h3.5v11.3H340zm-39.2 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M332 160.2v3.4h-15.2v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M381.4 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm42-.2v3.4h-9.9v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M455.4 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M452.7 143.4h3.3v20.2h-3.3zm-8.5 0h3.4v20.2h-3.4zm-44.8 8v3.4h-17.9v-3.3zm39.3 0v3.4h-17.9v-3.3z"/>
<path d="M420.8 152.3h3.4v11.3h-3.4z"/>
<path d="M420.8 152.3h3.4v11.3h-3.4z"/>
<path d="M420.8 152.3h3.4v11.3h-3.4zm-39.3 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3h-3.3z"/>
<path d="M412.7 160.2v3.4h-15.1v-3.4zm1-8.7v3.3H404v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M462.2 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm41.9-.2v3.4h-9.8v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M536.2 160.2v3.4h-18.6v-3.4zm-34.7-7.9h3.4v11.3h-3.4z"/>
<path d="M533.4 143.4h3.4v20.2h-3.4zm-8.4 0h3.3v20.2H525zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M501.6 152.3h3.3v11.3h-3.3z"/>
<path d="M501.6 152.3h3.3v11.3h-3.3z"/>
<path d="M501.6 152.3h3.3v11.3h-3.3zm-39.4 0h3.4v11.3h-3.4zm28.9 0h3.3v11.3h-3.3zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M493.4 160.2v3.4h-15.1v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M543.4 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm41.9-.2v3.4h-9.8v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M617.4 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.3v11.3h-3.3z"/>
<path d="M614.6 143.4h3.4v20.2h-3.4zm-8.4 0h3.3v20.2h-3.3zm-44.8 8v3.4h-18v-3.3zm39.3 0v3.4h-18v-3.3z"/>
<path d="M582.8 152.3h3.3v11.3h-3.3z"/>
<path d="M582.8 152.3h3.3v11.3h-3.3z"/>
<path d="M582.8 152.3h3.3v11.3h-3.3zm-39.3 0h3.3v11.3h-3.3zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3H558z"/>
<path d="M574.6 160.2v3.4h-15.1v-3.4zm1-8.7v3.3H566v-3.3z"/>
</g>
<g fill="#fff" stroke-width="1pt">
<path d="M-183.8 143.4h49v3.4h-49zm7.3 17h3.4v3.2h-3.4zm42-.2v3.4h-9.9v-3.4zm5.2-16.8h3.4v20.2h-3.4z"/>
<path d="M-109.8 160.2v3.4h-18.6v-3.4zm-34.6-7.9h3.4v11.3h-3.4z"/>
<path d="M-112.5 143.4h3.3v20.2h-3.3zm-8.5 0h3.4v20.2h-3.4zm-44.8 8v3.4h-17.9v-3.3zm39.3 0v3.4h-17.9v-3.3z"/>
<path d="M-144.4 152.3h3.4v11.3h-3.4z"/>
<path d="M-144.4 152.3h3.4v11.3h-3.4z"/>
<path d="M-144.4 152.3h3.4v11.3h-3.4zm-39.3 0h3.4v11.3h-3.4zm28.8 0h3.4v11.3h-3.4zm-14.3 0h3.4v11.3h-3.4z"/>
<path d="M-152.5 160.2v3.4h-15.2v-3.4zm1-8.7v3.3h-9.6v-3.3z"/>
</g>
<path fill="#d90000" d="M-68.8 339.5h6V350h-6zm160.5 0h6V350h-6zm-283.7 0h6V350h-6zm81.5 0h6V350h-6zm80.9 0h6V350h-6zm40 0h6V350h-6zm40.9 0h6V350h-6zm80.4 0h6V350h-6zm203 0h6.1V350h-6zm-162.1 0h6V350h-6zm40 0h6V350h-6zm40.5 0h6V350h-6zm40.4 0h6V350h-6zm323.2 0h6V350h-6zm-242.7 0h6V350h-6zm40.8 0h6V350h-6zm41.3 0h6V350h-6zm38.8 0h6V350h-6zm41.3 0h6V350h-6zm40.4 0h6V350h-6zm119.7 0h6V350h-6zm-38.8 0h6V350h-6zm-808.9 0h6V350h-6z"/>
<path fill="#239e3f" d="M-68.8 162.6h6v10.5h-6zm160.5 0h6v10.5h-6zm-283.7 0h6v10.5h-6zm81.5 0h6v10.5h-6zm80.9 0h6v10.5h-6zm40 0h6v10.5h-6zm40.9 0h6v10.5h-6zm80.4 0h6v10.5h-6zm203 0h6.1v10.5h-6zm-162.1 0h6v10.5h-6zm40 0h6v10.5h-6zm40.5 0h6v10.5h-6zm40.4 0h6v10.5h-6zm323.2 0h6v10.5h-6zm-242.7 0h6v10.5h-6zm40.8 0h6v10.5h-6zm41.3 0h6v10.5h-6zm38.8 0h6v10.5h-6zm41.3 0h6v10.5h-6zm40.4 0h6v10.5h-6zm119.7 0h6v10.5h-6zm-38.8 0h6v10.5h-6zm-808.9 0h6v10.5h-6z"/>
<g fill="#da0000">
<path d="M279.8 197.5c8.4 10.4 34.5 67.6-15.7 105.2-23.7 17.8-9 18.6-8.3 21.6 38-20.1 50.3-47.5 50-72-.2-24.4-13.2-46-26-54.8"/>
<path d="M284.8 194.8a73.3 73.3 0 0 1 15.7 112.4c27.2-6 62-86.4-15.7-112.4m-57.6 0a73.3 73.3 0 0 0-15.6 112.4c-27.3-6-62-86.4 15.6-112.4"/>
<path d="M232.2 197.5c-8.4 10.4-34.5 67.6 15.7 105.2 23.6 17.8 9 18.6 8.3 21.6-38-20.1-50.3-47.5-50-72 .2-24.4 13.2-46 26-54.8"/>
<path d="M304.2 319.1c-14.9.2-33.6-2-47.5-9.3 2.3 4.5 4.2 7.3 6.5 11.7 13.2 1.3 31.5 2.8 41-2.4m-95 0c14.9.2 33.6-2 47.5-9.3-2.3 4.5-4.2 7.3-6.5 11.7-13.2 1.3-31.5 2.8-41-2.4m27.3-138.7c3 8 10.9 9.2 19.3 4.5 6.2 3.6 15.7 3.9 19-4.1 2.5 19.8-18.3 15-19 11.2-7.8 7.5-22.2 3.2-19.3-11.6"/>
<path d="m256.4 331.6 7.8-9 1.1-120.1-9.3-8.2-9.3 7.8 1.9 121z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

7
docs/assets/flags/it.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-it" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#fff" d="M0 0h640v480H0z"/>
<path fill="#009246" d="M0 0h213.3v480H0z"/>
<path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 289 B

11
docs/assets/flags/jp.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-jp" viewBox="0 0 640 480">
<defs>
<clipPath id="jp-a">
<path fill-opacity=".7" d="M-88 32h640v480H-88z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#jp-a)" transform="translate(88 -32)">
<path fill="#fff" d="M-128 32h720v480h-720z"/>
<circle cx="523.1" cy="344.1" r="194.9" fill="#bc002d" transform="translate(-168.4 8.6)scale(.76554)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 470 B

24
docs/assets/flags/kr.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-kr" viewBox="0 0 640 480">
<defs>
<clipPath id="kr-a">
<path fill-opacity=".7" d="M-95.8-.4h682.7v512H-95.8z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#kr-a)" transform="translate(89.8 .4)scale(.9375)">
<path fill="#fff" d="M-95.8-.4H587v512H-95.8Z"/>
<g transform="rotate(-56.3 361.6 -101.3)scale(10.66667)">
<g id="kr-c">
<path id="kr-b" fill="#000001" d="M-6-26H6v2H-6Zm0 3H6v2H-6Zm0 3H6v2H-6Z"/>
<use xlink:href="#kr-b" width="100%" height="100%" y="44"/>
</g>
<path stroke="#fff" d="M0 17v10"/>
<path fill="#cd2e3a" d="M0-12a12 12 0 0 1 0 24Z"/>
<path fill="#0047a0" d="M0-12a12 12 0 0 0 0 24A6 6 0 0 0 0 0Z"/>
<circle cy="-6" r="6" fill="#cd2e3a"/>
</g>
<g transform="rotate(-123.7 191.2 62.2)scale(10.66667)">
<use xlink:href="#kr-c" width="100%" height="100%"/>
<path stroke="#fff" d="M0-23.5v3M0 17v3.5m0 3v3"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

26
docs/assets/flags/my.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-my" viewBox="0 0 640 480">
<g clip-path="url(#my-a)">
<path fill="#C00" d="M0 0h640v480H0z"/>
<path fill="#C00" d="M0 0h640v34.3H0z"/>
<path fill="#fff" d="M0 34.3h640v34.3H0z"/>
<path fill="#C00" d="M0 68.6h640v34.3H0z"/>
<path fill="#fff" d="M0 102.9h640V137H0z"/>
<path fill="#C00" d="M0 137.1h640v34.3H0z"/>
<path fill="#fff" d="M0 171.4h640v34.3H0z"/>
<path fill="#C00" d="M0 205.7h640V240H0z"/>
<path fill="#fff" d="M0 240h640v34.3H0z"/>
<path fill="#C00" d="M0 274.3h640v34.3H0z"/>
<path fill="#fff" d="M0 308.6h640v34.3H0z"/>
<path fill="#C00" d="M0 342.9h640V377H0z"/>
<path fill="#fff" d="M0 377.1h640v34.3H0z"/>
<path fill="#C00" d="M0 411.4h640v34.3H0z"/>
<path fill="#fff" d="M0 445.7h640V480H0z"/>
<path fill="#006" d="M0 .5h320v274.3H0z"/>
<path fill="#FC0" d="m207.5 73.8 6 40.7 23-34-12.4 39.2 35.5-20.8-28.1 30 41-3.2-38.3 14.8 38.3 14.8-41-3.2 28.1 30-35.5-20.8 12.3 39.3-23-34.1-6 40.7-5.9-40.7-23 34 12.4-39.2-35.5 20.8 28-30-41 3.2 38.4-14.8-38.3-14.8 41 3.2-28.1-30 35.5 20.8-12.4-39.3 23 34.1zm-33.3 1.7a71 71 0 0 0-100 65 71 71 0 0 0 100 65 80 80 0 0 1-83.2 6.2 80 80 0 0 1-43.4-71.2 80 80 0 0 1 126.6-65"/>
</g>
<defs>
<clipPath id="my-a">
<path fill="#fff" d="M0 0h640v480H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

5
docs/assets/flags/nl.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-nl" viewBox="0 0 640 480">
<path fill="#ae1c28" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#21468b" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

7
docs/assets/flags/no.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-no" viewBox="0 0 640 480">
<path fill="#ed2939" d="M0 0h640v480H0z"/>
<path fill="#fff" d="M180 0h120v480H180z"/>
<path fill="#fff" d="M0 180h640v120H0z"/>
<path fill="#002664" d="M210 0h60v480h-60z"/>
<path fill="#002664" d="M0 210h640v60H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

6
docs/assets/flags/ph.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ph" viewBox="0 0 640 480">
<path fill="#0038a8" d="M0 0h640v240H0z"/>
<path fill="#ce1126" d="M0 240h640v240H0z"/>
<path fill="#fff" d="M415.7 240 0 480V0"/>
<path fill="#fcd116" d="M26.7 42.4 41 55l16.6-9.2-7.4 17.5 14 13-19-1.6-8.1 17.2-4.3-18.5L14 71l16.3-10zm323.8 172.3.4 19 18 6.3-18 6.2-.4 19-11.5-15.1-18.2 5.5 10.8-15.6-10.8-15.6 18.2 5.5zM37.2 388.1l8 17.2 19-1.6-13.9 13 7.4 17.5-16.6-9.1-14.4 12.4 3.6-18.7L14 409l18.9-2.4zm114.2-249-6.2 6.2 3.1 47-3 .3-5.7-42.9-5.1 5 7.6 38.4a48 48 0 0 0-17.2 7.1l-21.7-32.4H96l26.4 34.3-2.4 2-31.1-35.5h-8.8v8.8l35.4 31-2 2.5-34.3-26.3v7.1l32.5 21.7q-5.2 7.8-7.1 17.2L66.3 223l-5.1 5 42.9 5.7q-.3 1.6-.3 3.1l-47-3-6.2 6.2 6.2 6.2 47-3.1.3 3.1-42.9 5.7 5 5 38.4-7.6a48 48 0 0 0 7.1 17.2l-32.5 21.7v7.2l34.3-26.3 2 2.4-35.4 31v8.8H89l31-35.4 2.5 2L96 312.2h7.2l21.7-32.5q7.8 5.2 17.2 7.1l-7.6 38.4 5 5 5.7-42.9q1.5.3 3.1.3l-3 47 6.1 6.2 6.3-6.2-3.1-47 3-.3 5.7 43 5.1-5.1-7.6-38.4a48 48 0 0 0 17.2-7.1l21.7 32.5h7.2l-26.4-34.3 2.4-2 31.1 35.4h8.8v-8.8l-35.4-31 2-2.4 34.3 26.3v-7.2l-32.5-21.7q5.2-7.8 7.1-17.2l38.3 7.6 5.1-5-42.9-5.7q.3-1.5.3-3.1l47 3 6.2-6.1-6.2-6.2-47 3-.3-3 42.9-5.7-5-5-38.4 7.5a48 48 0 0 0-7.1-17.2l32.5-21.7v-7.1l-34.3 26.3-2-2.4 35.4-31v-8.9H214l-31 35.5-2.5-2 26.4-34.3h-7.2L178 200.2q-7.8-5.2-17.2-7.1l7.6-38.3-5-5-5.7 42.8-3.1-.3 3-47z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

15
docs/assets/flags/pk.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-pk" viewBox="0 0 640 480">
<defs>
<clipPath id="pk-a">
<path fill-opacity=".7" d="M-52.3 0h682.6v512H-52.3z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#pk-a)" transform="translate(49)scale(.9375)">
<path fill="#0c590b" d="M-95 0h768v512H-95z"/>
<path fill="#fff" d="M-95 0H97.5v512H-95z"/>
<g fill="#fff">
<path d="m403.7 225.4-31.2-6.6-16.4 27.3-3.4-31.6-31-7.2 29-13-2.7-31.7 21.4 23.6 29.3-12.4-15.9 27.6 21 24z"/>
<path d="M415.4 306a121 121 0 0 1-161.3 59.4 122 122 0 0 1-59.5-162.1A119 119 0 0 1 266 139a156 156 0 0 0-11.8 10.9A112.3 112.3 0 0 0 415.5 306z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

6
docs/assets/flags/pl.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-pl" viewBox="0 0 640 480">
<g fill-rule="evenodd">
<path fill="#fff" d="M0 0h640v240H0z"/>
<path fill="#dc143c" d="M0 240h640v240H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 215 B

57
docs/assets/flags/pt.svg Normal file
View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-pt" viewBox="0 0 640 480">
<path fill="red" d="M256 0h384v480H256z"/>
<path fill="#060" d="M0 0h256v480H0z"/>
<g fill="#ff0" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width=".6">
<path d="M339.5 306.2c-32.3-1-180-93.2-181-108l8.1-13.5c14.7 21.3 165.7 111 180.6 107.8z"/>
<path d="M164.9 182.8c-2.9 7.8 38.6 33.4 88.4 63.8s92.9 49 96 46.4l1.5-2.8q-.9 1.6-4.3.6c-13.5-3.9-48.6-20-92.1-46.4-43.6-26.4-81.4-50.7-87.3-61a6 6 0 0 1-.6-3.1h-.2l-1.2 2.2zm175.3 123.8q-.7 1.3-3.5.8c-12-1.3-48.6-19.1-91.9-45-50.4-30.2-92-57.6-87.4-64.8l1.2-2.2.2.1c-4 12.2 82.1 61.4 87.2 64.6 49.8 30.8 91.8 48.9 95.5 44.2z"/>
<path d="M256.2 207.2c32.2-.3 72-4.4 95-13.6l-5-8c-13.5 7.5-53.5 12.5-90.3 13.2-43.4-.4-74.1-4.5-89.5-14.8l-4.6 8.6c28.2 12 57.2 14.5 94.4 14.6"/>
<path d="M352.5 193.8c-.8 1.3-15.8 6.4-37.8 10.2a381 381 0 0 1-58.6 4.3 416 416 0 0 1-56.2-3.6c-23.1-3.6-35-8.6-39.5-10.4l1.1-2.2c12.7 5 24.7 8 38.7 10.2A412 412 0 0 0 256 206a392 392 0 0 0 58.3-4.3c22.5-3.7 34.8-8.4 36.6-10.5zm-4.4-8.1c-2.4 2-14.6 6.3-36 9.7a388 388 0 0 1-55.8 4c-22 0-40.1-1.6-53.8-3.6-21.8-2.8-33.4-8-37.6-9.4l1.3-2.2c3.3 1.7 14.4 6.2 36.5 9.3a385 385 0 0 0 53.6 3.4 384 384 0 0 0 55.4-4c21.5-3 33.1-8.4 34.9-9.8zM150.3 246c19.8 10.7 63.9 16 105.6 16.4 38 .1 87.4-5.8 105.9-15.6l-.5-10.7c-5.8 9-58.8 17.7-105.8 17.4s-90.7-7.6-105.3-17v9.5"/>
<path d="M362.8 244.5v2.5c-2.8 3.4-20.2 8.4-42 12a434 434 0 0 1-65.4 4.4 400 400 0 0 1-62-4.3 155 155 0 0 1-44.4-12v-2.9c9.7 6.4 35.9 11.2 44.7 12.6 15.8 2.4 36.1 4.2 61.7 4.2 26.9 0 48.4-1.9 65-4.4 15.7-2.3 38-8.2 42.4-12.1m0-9v2.5c-2.8 3.3-20.2 8.3-42 11.9a434 434 0 0 1-65.4 4.5 414 414 0 0 1-62-4.3 155 155 0 0 1-44.4-12v-3c9.7 6.5 36 11.2 44.7 12.6a408 408 0 0 0 61.7 4.3c26.9 0 48.5-2 65-4.5 15.7-2.2 38-8.1 42.4-12m-107 68.8c-45.6-.2-84.7-12.4-93-14.4l6 9.4a250 250 0 0 0 87.4 14.3c34.7-1 65-3.7 86.3-14.1l6.2-9.8c-14.5 6.9-64 14.6-93 14.6"/>
<path d="m344.9 297.3-2.8 4c-10 3.6-26 7.4-32.6 8.4a296 296 0 0 1-53.7 5c-40.4-.6-73.5-8.5-89-15.3l-1.3-2.1.2-.4 2.1.9a287 287 0 0 0 88.2 14.5c18.8 0 37.5-2.1 52.6-4.8 23.2-4.7 32.6-8.2 35.5-9.8l.7-.4zm5.3-8.8-2 3.5c-5.4 2-20 6.2-41.3 9.2-14 1.9-22.7 3.8-50.6 4.3a347 347 0 0 1-94.2-14L161 289a390 390 0 0 0 95.4 14c25.5-.5 36.4-2.4 50.3-4.3 24.8-3.8 37.3-8 41-9.1v-.2l2.6-1z"/>
<path d="M350.8 237.6c.1 30-15.3 57-27.6 68.8a99 99 0 0 1-67.8 28.2c-30.3.5-58.8-19.2-66.5-27.9a101 101 0 0 1-27.5-67.4c1.8-32.8 14.7-55.6 33.3-71.3a100 100 0 0 1 64.2-22.7 98 98 0 0 1 71 35.6c12.5 15.2 18 31.7 20.9 56.7M255.6 135a106 106 0 0 1 106 105.2 105.6 105.6 0 1 1-211.4 0c-.1-58 47.3-105.2 105.4-105.2"/>
<path d="M255.9 134.5c58.2 0 105.6 47.4 105.6 105.6S314.1 345.7 256 345.7s-105.6-47.4-105.6-105.6S197.8 134.5 256 134.5zM152.6 240c0 56.8 46.7 103.3 103.3 103.3S359.2 296.8 359.2 240s-46.7-103.3-103.3-103.3S152.6 183.2 152.6 240"/>
<path d="M256 143.3a97 97 0 0 1 96.7 96.7 97 97 0 0 1-96.7 96.8c-53 0-96.7-43.6-96.7-96.8a97 97 0 0 1 96.7-96.7M161.6 240c0 52 42.6 94.4 94.4 94.4s94.4-42.5 94.4-94.4-42.6-94.4-94.4-94.4a95 95 0 0 0-94.4 94.4"/>
<path d="M260.3 134h-9.1v212.3h9z"/>
<path d="M259.3 132.8h2.3v214.7h-2.2V132.8zm-9 0h2.4v214.7h-2.3z"/>
<path d="M361.6 244.2v-7.8l-6.4-6-36.3-9.6-52.2-5.3-63 3.2-44.8 10.6-9 6.7v7.9l22.9-10.3 54.4-8.5h52.3l38.4 4.2 26.6 6.4z"/>
<path d="M256 223.8c24.9 0 49 2.3 68.3 6 19.8 4 33.7 9 38.5 14.5v2.8c-5.8-7-24.5-12-39-15-19-3.6-43-6-67.9-6-26.1 0-50.5 2.6-69.3 6.2-15 3-35.1 9-37.6 14.8v-2.9c1.3-4 16.3-10 37.3-14.3 18.9-3.7 43.3-6.1 69.6-6.1zm0-9.1a383 383 0 0 1 68.3 6c19.8 4 33.7 9 38.5 14.6v2.7c-5.8-6.9-24.5-12-39-14.9-19-3.7-43-6-67.9-6a376 376 0 0 0-69.2 6.2c-14.5 2.7-35.4 8.9-37.7 14.7v-2.8c1.4-4 16.6-10.3 37.3-14.3 19-3.7 43.3-6.2 69.7-6.2m-.6-46.2c39.3-.2 73.6 5.5 89.3 13.5l5.7 10c-13.6-7.4-50.6-15-94.9-14-36.1.3-74.7 4-94 14.4l6.8-11.4c15.9-8.3 53.3-12.5 87.1-12.5"/>
<path d="M256 176.7a354 354 0 0 1 61.3 4.3c16 3 31.3 7.4 33.5 9.8l1.7 3c-5.3-3.4-18.6-7.3-35.6-10.5s-38.7-4.3-61-4.2c-25.3-.1-45 1.2-61.8 4.2a109 109 0 0 0-33.3 10.3l1.7-3.1c6-3 15.3-6.7 31.1-9.6 17.5-3.2 37.4-4.1 62.4-4.2m0-9c21.4-.2 42.6 1 59.1 4a96 96 0 0 1 30.6 10l2.5 4c-4.2-4.7-20-9.2-34.1-11.6-16.4-2.9-36.7-4-58.1-4.2a361 361 0 0 0-59.5 4.4 97 97 0 0 0-29.6 9.1l2.2-3.3c5.8-3 15.2-5.8 27-8.1a357 357 0 0 1 59.9-4.4zM308.4 284a276 276 0 0 0-52.5-4c-65.5.8-86.6 13.5-89.2 17.3l-5-8c16.8-12 52.4-18.8 94.6-18.2q32.9.5 56.6 5l-4.5 8"/>
<path d="M255.6 278.9c18.2.3 36 1 53.3 4.2l-1.2 2.2c-16-3-33.2-4-52-4-24.3-.2-48.7 2.1-70 8.2-6.7 1.9-17.8 6.2-19 9.8l-1.2-2c.4-2.2 7-6.6 19.6-10 24.4-7 47.2-8.3 70.5-8.4m.8-9.2a327 327 0 0 1 57.3 5l-1.3 2.3a299 299 0 0 0-56-4.9c-24.2 0-49.9 1.8-73.3 8.6-7.5 2.2-20.6 7-21 10.7l-1.2-2.2c.2-3.4 11.5-7.9 21.7-10.8 23.5-6.9 49.3-8.6 73.8-8.7"/>
<path d="m349.4 290.5-7.8 12.3-22.7-20.1-58.6-39.5-66.2-36.3-34.3-11.7 7.3-13.6 2.5-1.3 21.3 5.3 70.4 36.3 40.6 25.6L336 272l13.9 16z"/>
<path d="M158.6 195.5c6-4 50.2 15.6 96.6 43.6 46.1 28 90.3 59.6 86.3 65.5l-1.3 2.1-.6.5c.1-.1.8-1 0-3.1-2-6.5-33.4-31.5-85.3-62.9-50.7-30.1-92.9-48.3-97-43.1zM351 290.4c3.8-7.6-37.2-38.5-88.1-68.6-52-29.5-89.6-46.9-96.5-41.7L165 183l.4-.5c1.2-1 3.3-1 4.2-1 11.8.2 45.5 15.7 92.8 42.8 20.8 12 87.6 55 87.3 67 0 1 .1 1.2-.3 1.8l1.7-2.6z"/>
</g>
<g transform="translate(0 26.7)scale(1.06667)">
<path fill="#fff" stroke="#000" stroke-width=".7" d="M180.6 211a59 59 0 0 0 17.5 41.7 59 59 0 0 0 41.8 17.6 59 59 0 0 0 42-17.4 59 59 0 0 0 17.4-41.8v-79.2l-118.7-.2z"/>
<path fill="red" stroke="#000" stroke-width=".5" d="M183 211.1a56 56 0 0 0 16.8 40 57 57 0 0 0 40.2 16.8 57 57 0 0 0 40.2-16.6 56 56 0 0 0 16.7-40v-77H183v76.8m91-53.7v48.9l-.1 5.1a33 33 0 0 1-10 24 34 34 0 0 1-24 10c-9.4 0-17.7-4-23.9-10.2a34 34 0 0 1-10-24v-54z"/>
<g id="pt-e">
<g id="pt-d" fill="#ff0" stroke="#000" stroke-width=".5">
<path stroke="none" d="M190.2 154.4c.1-5.5 4-6.8 4-6.8.1 0 4.3 1.4 4.3 6.9z"/>
<path d="m186.8 147.7-.7 6.3h4.2c0-5.2 4-6 4-6 .1 0 4 1.1 4.1 6h4.2l-.8-6.4zm-1 6.4h17q.5 0 .6.7 0 .8-.6.8h-17q-.5 0-.6-.8 0-.7.7-.7z"/>
<path d="M192 154c0-3.3 2.3-4.2 2.3-4.2s2.3 1 2.3 4.2H192m-5.8-9h16.3q.5.1.6.8 0 .5-.6.6h-16.3q-.5 0-.6-.7 0-.5.6-.6zm.4 1.5H202q.5 0 .6.7t-.6.7h-15.5q-.6 0-.6-.7t.6-.7zm5-10.6h1.2v.8h.9v-.8h1.3v.9h.9v-1h1.2v2q0 .6-.5.6h-4.4q-.5 0-.6-.5zm4.6 2.7.3 6.4h-4.3l.3-6.5h3.7"/>
<path id="pt-a" d="M191 141.6v3.4h-4v-3.4z"/>
<use xlink:href="#pt-a" width="100%" height="100%" x="10.6"/>
<path id="pt-b" d="M186.3 139h1.2v1h.9v-1h1.2v1h.9v-1h1.2v2q0 .6-.5.6h-4.3l-.6-.6z"/>
<use xlink:href="#pt-b" width="100%" height="100%" x="10.6"/>
<path fill="#000001" stroke="none" d="M193.9 140.6c0-.6.9-.6.9 0v1.6h-.9z"/>
<path id="pt-c" fill="#000001" stroke="none" d="M188.6 142.8c0-.6.8-.6.8 0v1.2h-.8z"/>
<use xlink:href="#pt-c" width="100%" height="100%" x="10.6"/>
</g>
<use xlink:href="#pt-d" width="100%" height="100%" y="46.3"/>
<use xlink:href="#pt-d" width="100%" height="100%" transform="rotate(-45.2 312.8 180)"/>
</g>
<use xlink:href="#pt-d" width="100%" height="100%" x="45.7"/>
<use xlink:href="#pt-e" width="100%" height="100%" transform="matrix(-1 0 0 1 479.8 0)"/>
<g id="pt-f" fill="#fff">
<path fill="#039" d="M232.6 202.4a8 8 0 0 0 2.2 5.7 7 7 0 0 0 5.3 2.4q3.2-.2 5.3-2.4a8 8 0 0 0 2.2-5.7v-10.8h-15z"/>
<circle cx="236.1" cy="195.7" r="1.5"/>
<circle cx="244.4" cy="195.7" r="1.5"/>
<circle cx="240.2" cy="199.7" r="1.5"/>
<circle cx="236.1" cy="203.9" r="1.5"/>
<circle cx="244.4" cy="203.9" r="1.5"/>
</g>
<use xlink:href="#pt-f" width="100%" height="100%" y="-26"/>
<use xlink:href="#pt-f" width="100%" height="100%" x="-20.8"/>
<use xlink:href="#pt-f" width="100%" height="100%" x="20.8"/>
<use xlink:href="#pt-f" width="100%" height="100%" y="25.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

7
docs/assets/flags/ro.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ro" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#00319c" d="M0 0h213.3v480H0z"/>
<path fill="#ffde00" d="M213.3 0h213.4v480H213.3z"/>
<path fill="#de2110" d="M426.7 0H640v480H426.7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 302 B

5
docs/assets/flags/ru.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#0039a6" d="M0 160h640v160H0z"/>
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

25
docs/assets/flags/sa.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-sa" viewBox="0 0 640 480">
<defs>
<clipPath id="sa-a">
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#sa-a)" transform="translate(80)scale(.9375)">
<path fill="#165d31" d="M-128 0h768v512h-768z"/>
<path fill="#fff" d="M65.5 145.1c-.8 12-2 33 8.3 35.2 12.3 1.2 5.5-20.8 10-24.8.8-2 2.3-2 2.4.5v18.7c0 6 4 7.8 7 9 3.2-.2 5.4 0 6.6 3l1.6 32.3s7.4 2.2 7.8-18.1c.3-12-2.4-21.9-.8-24.2 0-2.3 3-2.4 5-1.3 3.2 2.2 4.6 5 9.6 4 7.6-2.2 12.2-5.9 12.3-11.7a47 47 0 0 0-3.5-16.6c.4-1-1.4-3.7-1-4.7 1.3 2.2 3.4 2 3.8 0-1.3-4.2-3.3-8.3-6.5-10-2.7-2.4-6.7-2-8 3-.8 5.7 2 12.4 6.1 18 .9 2.1 2.1 5.7 1.6 8.9q-3.4 1.8-6.3-1.2s-6-4.5-6-5.6c1.6-10.2.3-11.4-.6-14.3-.6-3.9-2.5-5.2-4-7.8-1.5-1.6-3.5-1.6-4.5 0-2.7 4.6-1.4 14.5.5 19 1.4 4.1 3.5 6.7 2.5 6.7-.8 2.3-2.5 1.7-3.8-1a67 67 0 0 1-2.1-17.4c-.5-4.6-1.1-14.4-4.2-17-1.8-2.4-4.5-1.2-5.5 1a82 82 0 0 0 .3 13.4c2 7.4 2.7 14 3.7 21.5.3 10.1-5.8 4.4-5.5-.7a45 45 0 0 0-.3-19.4c-1-2.6-2.1-3.2-4.6-2.8-1.9 0-6.8 5.3-8.2 14.3 0 0-1.2 4.6-1.7 8.7-.7 4.6-3.7 8-5.9-.6-1.8-6.3-3-21.6-6-18z"/>
<path fill="#fff" d="m99 194.2-32 15.4c.3-7.3 15.1-20.4 25.3-20.5 6.5.1 4.9 2.5 6.6 5.1z"/>
<path fill="#fff" d="M93.3 204.2c-16.8 43.5 39.5 49.6 45.8 1.8.6-2 3-3.9 3.4-.7-1.3 43.3-43.6 46.2-50.8 32.6a42 42 0 0 1-2.5-14.6c-1-8.5-5.5-5.2-6.2 3.2-.7 4.7-.5 6-.5 10.5 2.2 34.2 56.7 19.5 65.6-8.7 4.7-15.6-.8-27.1 1.7-27.1 5.4 5.8 13 .8 14.7-1.2.7-1 2.5-1.7 3.7-.4 4.2 3 11.6 1.6 13.2-3.7q1.4-8 1.8-16.2c-3.5 1-6 1.7-6.3 3.2l-.7 4.6c-.3 1.5-3.2 1.5-3.4-.4-1.3-6-6.7-6.7-10 2.5-2.1 1.8-6.1 2.2-6.5-.5.5-6.2-2-7-7-4.1l-4.8-36.2c2 0 4 1.5 5.9-.9-2-6.5-6.5-19.7-9-20.7-1.1-1.4-2.1-.5-3.7-.1-2.6.8-5 3-4.2 7.4 3 18.8 5 33.1 8.1 52 .5 2.1-1.3 5-3.7 4.7-4-2.7-5-8.2-12-8-5 0-10.6 5.5-11.3 10.7-.9 4.2-1.2 8.7 0 12.3 3.5 4.2 7.7 3.8 11.4 2.9 3-1.3 5.5-4.3 6.6-3.6.7.9.1 10.9-14.3 18.5-8.7 4-15.7 4.8-19.4-2.3-2.3-4.5.2-21.4-5.6-17.5"/>
<path fill="#fff" d="M165 160c3.3-1.2 19.3-19.6 19.3-19.6l-2.4-2q-1.3-1.1 0-2.2c4-2.4 2.7-7.4.7-9.8a10 10 0 0 0-8.7.1c-2.8 2.7-3.4 7-1.2 9.6 2.1 1 4.2 3.2 2.8 4.4-6.6 7-24.5 19.1-22.4 19.5.4.6 11.5.6 11.8 0zm-97 65c-6 9.6-6.5 23.9-3.2 28.2 1.8 2 4.7 2.9 6.8 2.2 3.8-1.6 5.5-9.3 4.6-12q-1.8-3-3.6-.7c-2.6 5.4-3.7 1.7-4-1.3a70 70 0 0 1 .8-15.2c.7-4.2 0-3-1.4-1.2m257.1-15.3c-5.8-12.6-13.9-25-16.4-29.7a558 558 0 0 0-24.8-36c-6.2-7.4 10.2 3.1-2-11.7l-8.9-7.5c-2-1.4-6.8-4-7.6.2-.4 3.8-.2 5.8.4 8.9.5 2 3.5 5.5 5 7.5a565 565 0 0 1 53.8 86.5c2.6-1.3 2-16.1.5-18.2"/>
<path fill="#fff" d="M299.6 251.5c-1.2 1.3 2.8 6.8 8 6.8 8.6-1 16.2-5.8 23.2-18.6a33 33 0 0 0 5.3-14.2 317 317 0 0 0-5.8-72.4c-.3-2 0-4.4.2-5 .6-.7 2.5 0 3.5-1.7 1.5-1.5-4-14-7-18.7-1-2.2-1.5-3.6-3.3.2a27 27 0 0 0-3 13.6c4.1 28.5 5.4 53.4 8 81.9.3 2.8-.1 6.8-2 8.4a80 80 0 0 1-27.1 19.7m116.5-.1c-6.2 3.6-6.2 7.7-1.2 7.8 8.6-1 18.8-1.7 25.8-12.3a41 41 0 0 0 4.2-16 303 303 0 0 0-4.7-71.4c-.2-2-1.1-6.7-.8-7.3.6-1.4 3.4.1 4.4-1.5 1.4-1.5-7.3-12.7-10.4-17.5-1-2.2-1.4-3.6-3.3.2a22 22 0 0 0-1.8 13.6c4.6 31 8 54.2 8.7 81.6-.4 2.6-.5 4-1.7 7.3-2.7 3.4-5.7 7.8-8.5 9.9-2.8 2-8.8 4-10.7 5.6"/>
<path fill="#fff" d="M420.7 223.7q.1-10.9-.1-19a34 34 0 0 0-3-13.5c-1.8-4.1-.7-7.4-1.6-11.8-.8-4.4-.6-11-1.8-16.1-.4-2-1.4-8.5-1.1-9.2.5-1.4 2.4 0 3.4-1.6 1.4-1.5-5-18-8.2-22.7-1.1-2.1-3.3-1.4-5.8 2-2.5 2.3-1.6 7.4-.6 12.3 6.1 32.3 10.8 61.6 9.8 92.3-.4 2.6 9-7.8 9-12.7m-45.7-40c-3.9-.2-12-7.7-14.4-12a8 8 0 0 1 .4-6.5c1.5-1 3.7-2 5.4-1 0 0 1.7 2.4 1.4 2.7 2 1 3 .5 3.2-.4.1-1.5-.6-2.4-.6-4 .9-4.6 6-5.3 8-2.4 1.4 1.8 2 5.5 2.1 8 0 1.3-2-.2-3.3 0-1.1.4-1.4 1.8-1.5 3-.2 3.3-.6 8.6-.7 12.5zm-71.8 48c1-9.8-.4-27.3-.5-33.1A477 477 0 0 0 299 154c-1.2-8.4 3.4.9 2.8-4-1.5-8.3-6.1-14-11.6-21.5-1.7-2.5-1.7-3-4.4.6-3 6.7-.4 11.4.4 16.7 3.9 17.2 6.2 33 7.3 48.7a393 393 0 0 1 .4 49c3 .1 7.6-4.7 9.3-11.8"/>
<path fill="#fff" d="M434 216c-6.9-11.6-17.2-24-20-28.7a658 658 0 0 0-29.2-37.8c-8.5-9 4-1.5-1.6-8.5-4.7-5.1-6-6.8-10.1-9.9-2-1.3-3.2-3.8-4 .5a83 83 0 0 0-.2 11.2c0 1.7 1.8 5 3.4 7 20.7 25.5 43.4 51.5 61.6 84.2 2.6-1.3 1.7-16 0-18z"/>
<path fill="#165d31" d="M122.6 194.7c-.5.9-1.6 2-1.2 3.1q1 1.4 2.6 1.3c1.1 0 2.7.3 3-.3q1-1.2.6-3.3c-1.2-3-4.4-1.8-5-.8"/>
<path fill="#fff" d="M354.2 362.5c9.2.4 15.2.5 23.3 1.4l9.6-1c10.6-1 11 15.1 11 15.1 0 9.5-3.7 10-8.4 11-2.7.4-4-1.6-5.5-3.6a14 14 0 0 1-7 .4l-11.5-.5c-4-.3-6.2.5-10.3.1-.8 1.3-2 3.1-4.4 2.6-2-.3-4.5-6-3.8-10.5 1.5-3.2 1-2.1 1-3.5-37.6-1-75.5-2.7-112.3-2.2-28.8.1-57.2 1.3-85.7 2.5-15.2-.2-26.8-2.6-34.8-14.3.8 0 38.8 2.1 49.9 1.4 20.5-.2 39.3-1.9 60.2-2.5 41.2.7 82.1.7 123.3 3.6-4-2.7-4-9 2-10.6.5-.4.8 3.1 1.7 3 4.9-.3 2.7 6.3 1.7 7.6M188.6 135.3c-6.2 17.8 3.6 37.4 10.4 35.5 5 2 8-7.4 10-17.6 1.5-2.9 2.5-3.2 3.2-1.7-.2 13.6 1 16.7 4.5 20.8 7.8 6 14.3.8 14.8.3l6-6.1q2.2-2.2 5.1-.3c1.9 1.7 1.6 4.6 5.6 6.6 3.4 1.4 10.5.4 12.2-2.5 2.2-3.9 2.8-5.2 3.8-6.6 1.6-2.1 4.3-1.2 4.3-.5-.3 1.2-1.9 2.3-.8 4.5 2 1.4 2.4.5 3.5.2 4-2 7-10.6 7-10.6.1-3.2-1.7-3-2.9-2.2l-3.1 2.1c-2 .3-5.7 1.6-7.6-1.3-1.9-3.4-1.9-8.3-3.3-11.8 0-.2-2.6-5.5-.2-5.8 1.2.2 3.7.9 4.1-1.2 1.2-2.1-2.6-8-5.3-11-2.3-2.5-5.5-2.8-8.6-.2-2.2 2-1.9 4.2-2.3 6.3a10 10 0 0 0 2 8.7c2.2 4.2 6.1 9.7 4.8 17.5 0 0-2.3 3.6-6.3 3.1-1.7-.3-4.4-1-5.8-11.8-1.1-8 .2-19.4-3.2-24.7-1.3-3.3-2.2-6.4-5.2-.9-.8 2.2-4.3 5.5-1.8 12.2a36 36 0 0 1 2 19c-1.5 2.2-1.8 2.9-3.7 5-2.6 3-5.5 2.2-7.7 1.1-2-1.3-3.6-2-4.6-6.5.2-7 .6-18.5-.7-20.9-1.9-3.8-5-2.4-6.3-1.2a48 48 0 0 0-11.5 23.5c-1.8 5.8-3.7 4.1-5 1.8-3.2-3-3.5-26.7-7.4-22.8"/>
<path fill="#fff" d="M207.4 174.1c2.9-2 1.6-3.4 5.8.8a72 72 0 0 1 9.2 31.3c-.2 2.6 1.6 4.2 2.5 3.6.4-6 15.1-14.4 28.6-15.6 2-.5 1-4.4 1.3-6.4-.8-7.5 4.2-14.3 11.2-14.8 9.6 1.4 12.8 6.5 13 14.2-1.1 15-16.7 17.5-25.4 18.7-1.3.5-1.9 1.1 0 1.8l36.6.2 1.9 1c.2 1-.6.2-2 2.6a30 30 0 0 0-3.7 11.5c-10.9 3.6-22.2 5-33.6 6.5-4 2-6 4.7-5.2 7.7 1.4 3.3 10.2 6.7 10.2 6.8 1.7 1 3.6 3.5-.5 8.6-17.8-.8-31.7-8.4-36.5-19.1-1.4-1.1-3 0-4 1.4-7 9-13.8 17-25.7 21.4-7 1.8-14.3-1.1-17.7-5.7-2.3-2.7-2.2-5.6-3-6.2-3.9 1.7-36.9 15.7-32.7 9.1 8-8.5 22-14.9 34.2-23.3.9-2.9 2.5-12.5 7.3-15.6.3 0-.7 5.6-.6 8 0 2-.2 2.7.2 2.2.9-.5 15.7-12.2 17-15.8 1.4-2 .3-7.2.3-7.4-2.8-7.2-6.7-7.8-8.1-11.4-1.3-4.7-.7-10.1 2-11.7q3.7-3.1 7.9.5c3 2.7 5.6 8 6.4 11.9-.5 1.5-4-1-5-.3a16 16 0 0 1 3.7 7.8c2 8.2 1.4 11.4-.6 16.7-6.6 13.9-15 18-22.4 23.2-.2 0-.3 3.5 2.4 5.4 1 1 4.9 1.5 9.4 0a55 55 0 0 0 22.3-23.3 51 51 0 0 0-2.4-22.2c-2.9-6.7-6.3-16.2-6.3-16.4-.1-4.2.2-5.6 2-7.7m-95.8-38.6c4.2 2 12.2 1.1 11.8-5.7l-.2-3.1c-.8-2-3.2-1.5-3.7.5-.2.7.3 1.8-.3 2.1-.4.4-1.7.2-1.7-1.7q-.1-1-.7-1.6-.2-.2-.9-.2c-.6 0-.6.1-.9.6l-.3 1.6q-.1.9-.8 1c-.6 0-.5 0-1-.2q-.4-.3-.5-1l-.3-1.6q-.3-.4-1-.6c-2.3 0-2.5 2.7-2.3 3.7-.2.2-.3 4.9 2.8 6.2"/>
<path fill="#fff" d="M235.1 187.7c4.2 2 14.3.9 11.8-5.6l-.2-3.2c-.9-2-3.2-1.5-3.7.6-.2.6.3 1.7-.4 2-.3.4-1.7.2-1.6-1.6q-.1-1-.7-1.7-.3-.2-1-.2c-.5 0-.5.2-.8.7q-.3.7-.3 1.6-.2.8-.9 1c-.5 0-.4 0-.8-.3q-.5-.3-.6-.9l-.3-1.6q-.3-.5-1-.6c-2.3 0-2.5 2.6-2.4 3.6-.1.2-.2 5 3 6.2zm72-21.6c4.2 2 12.1 1.1 11.8-5.6l-.2-3.2c-.9-2-3.2-1.5-3.7.5-.2.7.3 1.8-.4 2.2-.3.3-1.7.1-1.6-1.8q-.1-1-.7-1.6-.3-.2-1-.2t-.8.7l-.3 1.5q-.1 1-.9 1c-.8 0-.4 0-.8-.2q-.5-.3-.6-.9 0-1-.3-1.7-.4-.4-1-.5c-2.3 0-2.5 2.6-2.4 3.6-.1.2-.2 4.9 3 6.2zm37.3 54.3c-7.3 8.3-4.1 22-2.4 25 2.4 4.8 4.3 7.9 9 10.3 4.3 3.1 7.7 1.2 9.5-1 4.3-4.5 4.4-16 6.4-18.2 1.4-4.2 5-3.5 6.7-1.6a17 17 0 0 0 6.2 5.3c4 3.5 8.8 4.2 13.6 1 3.2-1.9 5.3-4.2 7.2-8.9 2-5.6 1-31.6.5-47l-4.2-21.5c0-.2-.5-10.2-1-12.5 0-1-.3-1.3.7-1.2 1.1 1 1.2 1 2 1.3 1 .2 2-1.7 1.3-3.3l-10-18.6c-.8-.8-1.9-1.6-3.2.2a7 7 0 0 0-2.4 5.5l1.3 13.3 4 22.6c1.3 16 1.6 29.2 2.9 45.3-.2 6.8-2.3 12.7-4.3 13.6 0 0-3 1.7-5-.2-1.5-.6-7.4-9.9-7.4-9.9-3-2.7-5-2-7.1 0-6 5.8-8.6 16.4-12.7 23.8-1 1.7-4 3-7.2-.1-8.2-11.3-3.4-27.3-4.4-23.2M309 126.7c3.8 1.5 6.4 9.2 5.6 13-.8 4.5-2.8 9.5-4.2 8.9-1.6-.6 1-4.6-.5-8.8-.8-2.8-6-7.8-5.4-9.2-1-3.1 2.2-4.5 4.5-4z"/>
<path fill="#fff" d="M356.6 225c.7-9.2-.6-14.8-.8-20.2s-6.1-46.6-7.3-50.6c-1.5-7.8 5.7-1 4.9-5.6-2.5-5.6-8.6-13.9-10.5-18.8-1.2-2-.7-4-3.3-.5a42 42 0 0 0-2.3 19.2c6.2 32.3 12.5 59.1 11.5 89.8 3 0 6.3-6.7 7.8-13.3m64.4-85.3c3.5 1.7 5.5 11.3 5.1 14-.7 5-2.5 10.4-3.8 9.7-1.5-.6.3-7.4-.4-9.5-.8-3-5.5-8.4-5-10-1-3.4 2-4.8 4.1-4.2m-255.7 67.9c3.3 1.3 5.3 8.3 5 10.3-.8 3.7-2.5 7.7-3.8 7.1-1.3-.4.3-5.4-.3-7-.3-3.7-4.9-5.7-4.8-7.3-.8-3 2-3.5 4-3.1z"/>
<path fill="#165d31" d="M244.9 218.2c4.2.2 6.3 3.6 2.4 5-4 1.3-7.7 2.4-7.8 8 1.5 8-2 5.2-4 4.2-2.4-1.8-9.2-6-10.2-15-.1-2.1 1.6-4 4.3-4 4 1.1 10 1.2 15.3 1.8"/>
<path fill="#fff" d="M77.4 124.4c4.8 1.4 5.1 8.6 4.8 10.7-.7 3.8-2.4 7.9-3.6 7.4-1.4-.5 0-5.7-.7-7.3-.7-2.2-4.8-6.4-4.4-7.6-.9-2.5 2-3.7 3.9-3.2m95.9 33.6c-3.8 2-5.2 8-2.9 11.6 2.2 3 5.6 1.9 6 1.9 3.7.4 5.9-6.9 5.9-6.9s.1-2-4.2 1.9c-1.9.3-2-.4-2.5-1.4a9 9 0 0 1 .5-5.7c.7-1.8-.7-2.6-2.8-1.4m28-36.4c-2 1.3-5.7 5.2-5.8 9.6-.1 2.5-.6 2.5 1 4 1.3 1.8 2.4 1.7 4.8.4a5 5 0 0 0 2.3-3.4c.6-2.8-3 1.4-3.4-1.8-.8-3 1.5-4.2 3.7-7 0-2 0-3.3-2.7-1.8zm22.4 4a60 60 0 0 0-1.6 11.1c-.6 2.8 3 4 4.5.4 2.4-6.5 2.4-9.3 2.6-12-.7-4.3-3.6-4.2-5.5.5m142 72.3c.4-.5 20-14.4 20-14.4 2-.7 1.5 7.2.6 7.1a78 78 0 0 1-20.7 14.3c-1 .7-1.9-5.3 0-7zm17.7-.2c3.5 1.7 4.9 11.8 4.5 14.5 0 5.4-3.3 9.6-4.7 9-1.4-.7.2-6.7-.5-8.8-.8-3-3.7-8.5-3.2-10.1-1-3.4 1.8-5.2 4-4.6zm-116 43.4a26 26 0 0 1 5.6-4.9c2-1 3.8.8 3.7.7.3 2-1.2 3.7-.7 6.3.4 1 .7 2.2 2.6 1.8 3.1-2.5 6-2.7 9-2.8 2.5.1 2.6 4.2 1 4.2-5.7 1.2-8.2 2.8-12.3 4.3-2 1.2-3.6-.3-3.6-.4s-1.1-1.1-.4-3.7q.3-3.2-2.4-3c-1.2.8-2.4 1.2-3-.3q-.6-1.4.5-2.2m136.6 5.4c.8 1 1.4 2-.1 3.8l-3.7 3.2c-.6 1-1 2.8 1 3.3 3.6 1 12-4.5 12-4.6 1.4-1 1-3 .8-3-.8-.9-2.6-.3-3.8-.5-.6 0-2.5-.2-1.6-2a11 11 0 0 0 1.6-2.9q.9-1.7-2-2.7c-2.1-.4-3-.2-5.3 0q-1.6.2-1.9 2.3c.1 2.3 1.5 2.2 3 3z"/>
<path fill="#165d31" d="M268.1 189.7c-.5 1-2.3 1-4 0s-2.7-2.6-2.1-3.5 2.3-.9 4 0 2.6 2.6 2.1 3.5m-89-53.6c-1 .3-2.4-.6-3-2s-.3-2.6.7-2.9 2.3.7 3 2 .3 2.7-.8 3zM355.2 375c9.4.4 18.2 0 27.5.5 1.7 1.5.5 5-.6 4.8l-7.8-.3c-.1-3-7.7-2.5-7.5.1-4.1.5-7.8-.1-12-.3-1.2-1.5-1-4.2.4-4.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

4
docs/assets/flags/se.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-se" viewBox="0 0 640 480">
<path fill="#005293" d="M0 0h640v480H0z"/>
<path fill="#fecb00" d="M176 0v192H0v96h176v192h96V288h368v-96H272V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

9
docs/assets/flags/sk.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-sk" viewBox="0 0 640 480">
<path fill="#ee1c25" d="M0 0h640v480H0z"/>
<path fill="#0b4ea2" d="M0 0h640v320H0z"/>
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M233 370.8c-43-20.7-104.6-61.9-104.6-143.2 0-81.4 4-118.4 4-118.4h201.3s3.9 37 3.9 118.4S276 350 233 370.8"/>
<path fill="#ee1c25" d="M233 360c-39.5-19-96-56.8-96-131.4s3.6-108.6 3.6-108.6h184.8s3.5 34 3.5 108.6C329 303.3 272.5 341 233 360"/>
<path fill="#fff" d="M241.4 209c10.7.2 31.6.6 50.1-5.6 0 0-.4 6.7-.4 14.4s.5 14.4.5 14.4c-17-5.7-38.1-5.8-50.2-5.7v41.2h-16.8v-41.2c-12-.1-33.1 0-50.1 5.7 0 0 .5-6.7.5-14.4s-.5-14.4-.5-14.4c18.5 6.2 39.4 5.8 50 5.6v-25.9c-9.7 0-23.7.4-39.6 5.7 0 0 .5-6.6.5-14.4 0-7.7-.5-14.4-.5-14.4 15.9 5.3 29.9 5.8 39.6 5.7-.5-16.4-5.3-37-5.3-37s9.9.7 13.8.7 13.8-.7 13.8-.7-4.8 20.6-5.3 37c9.7.1 23.7-.4 39.6-5.7 0 0-.5 6.7-.5 14.4s.5 14.4.5 14.4a119 119 0 0 0-39.7-5.7v26z"/>
<path fill="#0b4ea2" d="M233 263.3c-19.9 0-30.5 27.5-30.5 27.5s-6-13-22.2-13c-11 0-19 9.7-24.2 18.8 20 31.7 51.9 51.3 76.9 63.4 25-12 57-31.7 76.9-63.4-5.2-9-13.2-18.8-24.2-18.8-16.2 0-22.2 13-22.2 13S253 263.3 233 263.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

7
docs/assets/flags/th.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-th" viewBox="0 0 640 480">
<g fill-rule="evenodd">
<path fill="#f4f5f8" d="M0 0h640v480H0z"/>
<path fill="#2d2a4a" d="M0 162.5h640v160H0z"/>
<path fill="#a51931" d="M0 0h640v82.5H0zm0 400h640v80H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 284 B

8
docs/assets/flags/tr.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-tr" viewBox="0 0 640 480">
<g fill-rule="evenodd">
<path fill="#e30a17" d="M0 0h640v480H0z"/>
<path fill="#fff" d="M407 247.5c0 66.2-54.6 119.9-122 119.9s-122-53.7-122-120 54.6-119.8 122-119.8 122 53.7 122 119.9"/>
<path fill="#e30a17" d="M413 247.5c0 53-43.6 95.9-97.5 95.9s-97.6-43-97.6-96 43.7-95.8 97.6-95.8 97.6 42.9 97.6 95.9z"/>
<path fill="#fff" d="m430.7 191.5-1 44.3-41.3 11.2 40.8 14.5-1 40.7 26.5-31.8 40.2 14-23.2-34.1 28.3-33.9-43.5 12-25.8-37z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 549 B

34
docs/assets/flags/tw.svg Normal file
View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-tw" viewBox="0 0 640 480">
<clipPath id="tw-a">
<path d="M0 0h640v480H0z"/>
</clipPath>
<g clip-path="url(#tw-a)">
<path fill="red" d="M0 0h720v480H0z"/>
<path fill="#000095" d="M0 0h360v240H0z"/>
<g fill="#fff">
<path d="m154 126.9-2.5 9.6 9.4 2.6-1.8-7.1zm46.9 5.1-1.8 7.1 9.4-2.6-2.5-9.6zm-41.8-24-5.1 5.1 1.9 6.9z"/>
<path d="m155.9 120-1.9 6.9 5.1 5.1z"/>
<path d="m154 113.1-6.9 6.9 6.9 6.9 1.9-6.9zm14 27.8 5.1 5.1 6.9-1.9zm18.9 5.1 9.6 2.5 2.6-9.4-7.1 1.8z"/>
<path d="m192 140.9 7.1-1.8 1.8-7.1zm-31.1-1.8 2.6 9.4 9.6-2.5-5.1-5.1zm19.1 5 6.9 1.9 5.1-5.1z"/>
<path d="m173.1 146 6.9 6.9 6.9-6.9-6.9-1.9zm-12.2-45.1-9.4 2.6 2.5 9.6 5.1-5.1zm-1.8 31.1 1.8 7.1 7.1 1.8zm45-12 1.9-6.9-5.1-5.1z"/>
<path d="m168 99.1-7.1 1.8-1.8 7.1zm32.9 8.9-1.8-7.1-7.1-1.8zm5.1 18.9 6.9-6.9-6.9-6.9-1.9 6.9z"/>
<path d="m200.9 108-8.9-8.9-12-3.2-12 3.2-8.9 8.9-3.2 12 3.2 12 8.9 8.9 12 3.2 12-3.2 8.9-8.9 3.2-12z"/>
<path d="m200.9 132 5.1-5.1-1.9-6.9zm5.1-18.9 2.5-9.6-9.4-2.6 1.8 7.1zm-6.9-12.2-2.6-9.4-9.6 2.5 5.1 5.1zm-26-6.9-9.6-2.5-2.6 9.4 7.1-1.8zm6.9 1.9-6.9-1.9-5.1 5.1z"/>
<path d="m186.9 94-6.9-6.9-6.9 6.9 6.9 1.9z"/>
<path d="m192 99.1-5.1-5.1-6.9 1.9zM173.1 146l-9.6 2.5 4.5 16.6 12-12.2zm-5.1 19.1 12 44.9 12-44.9-12-12.2zm-7.1-26-9.4-2.6-4.4 16.4 16.4-4.4z"/>
<path d="m147.1 152.9-12 45.1 32.9-32.9-4.5-16.6zm-12-20.9L102 165.1l45.1-12.2 4.4-16.4z"/>
<path d="m154 126.9-6.9-6.9-12 12 16.4 4.5zm0-13.8-2.5-9.6-16.4 4.5 12 12z"/>
<path d="M135.1 108 90 120l45.1 12 12-12zm90 24-16.6 4.5 4.4 16.4 45.1 12.2z"/>
<path d="m199.1 139.1-2.6 9.4 16.4 4.4-4.4-16.4zm-12.2 6.9-6.9 6.9 12 12.2 4.5-16.6zm19.1-19.1 2.5 9.6 16.6-4.5-12.2-12z"/>
<path d="m192 165.1 33.1 32.9-12.2-45.1-16.4-4.4zm7.1-64.2 9.4 2.6 4.4-16.4-16.4 4.4z"/>
<path d="M225.1 108 258 75.1l-45.1 12-4.4 16.4zm-12.2-20.9L225.1 42 192 75.1l4.5 16.4zm12.2 44.9 44.9-12-44.9-12-12.2 12z"/>
<path d="m206 113.1 6.9 6.9 12.2-12-16.6-4.5zm-38-38L135.1 42l12 45.1 16.4 4.4z"/>
<path d="m160.9 100.9 2.6-9.4-16.4-4.4 4.4 16.4z"/>
<path d="m147.1 87.1-45.1-12 33.1 32.9 16.4-4.5zm39.8 6.9 9.6-2.5-4.5-16.4-12 12z"/>
<path d="M192 75.1 180 30l-12 45.1 12 12z"/>
<path d="m173.1 94 6.9-6.9-12-12-4.5 16.4z"/>
</g>
<circle cx="180" cy="120" r="51.1" fill="#000095"/>
<circle cx="180" cy="120" r="45.1" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

13
docs/assets/flags/tz.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-tz" viewBox="0 0 640 480">
<defs>
<clipPath id="tz-a">
<path fill-opacity=".7" d="M10 0h160v120H10z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#tz-a)" transform="matrix(4 0 0 4 -40 0)">
<path fill="#09f" d="M0 0h180v120H0z"/>
<path fill="#090" d="M0 0h180L0 120z"/>
<path fill="#000001" d="M0 120h40l140-95V0h-40L0 95z"/>
<path fill="#ff0" d="M0 91.5 137.2 0h13.5L0 100.5zM29.3 120 180 19.5v9L42.8 120z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 548 B

6
docs/assets/flags/ua.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#0057b8" d="M0 0h640v240H0z"/>
<path fill="gold" d="M0 240h640v240H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 234 B

9
docs/assets/flags/us.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
<path fill="#bd3d44" d="M0 0h640v480H0"/>
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"/>
<path fill="#192f5d" d="M0 0h364.8v258.5H0"/>
<marker id="us-a" markerHeight="30" markerWidth="30">
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z"/>
</marker>
<path fill="none" marker-mid="url(#us-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

11
docs/assets/flags/vn.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-vn" viewBox="0 0 640 480">
<defs>
<clipPath id="vn-a">
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#vn-a)" transform="translate(80)scale(.9375)">
<path fill="#da251d" d="M-128 0h768v512h-768z"/>
<path fill="#ff0" d="M349.6 381 260 314.3l-89 67.3L204 272l-89-67.7 110.1-1 34.2-109.4L294 203l110.1.1-88.5 68.4 33.9 109.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1,168 @@
# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool
**Labels:** `enhancement`, `plugin`, `architecture`
## Problem / Use Case
OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for:
- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`)
- **Build complexity**: Turbopack must handle the `cloakbrowser` package
- **Bundle size**: All browser-pool code is compiled into the main codebase
- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports)
- **CI/cache impact**: Playwright installs in CI pipelines even when not needed
Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed.
## Proposed Solution
Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent.
### Architecture
```
open-sse/
interfaces/
browserPool.ts ← NEW: BrowserPoolProvider interface + types
services/
browserPool.ts ← BECOMES: thin stub, delegates to optional package
browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package
grokClearance.ts ← BECOMES: thin stub
packages/browser-pool/ ← NEW: optional package
index.ts ← exports BrowserPoolProvider implementation
src/
browserPool.ts ← extracted from open-sse/services/browserPool.ts
browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts
grokClearance.ts ← extracted from open-sse/services/grokClearance.ts
claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is)
inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle)
package.json
tsconfig.json
```
### Phase Breakdown
**Phase 1 — Core pool extraction (this issue):**
1. Define `BrowserPoolProvider` interface in `open-sse/interfaces/browserPool.ts`
2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/`
3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback
4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective)
5. Make Playwright an optional dependency (not in root `package.json`)
6. Typecheck core passes with and without the package installed
7. All existing tests pass (with plugin installed)
**Phase 2 — Turnstile solver extraction (future):**
- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface
- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver)
**Phase 3 — Standalone Playwright instances (future):**
- Extract `inAppLoginService.ts` (~257 LOC)
- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC)
### Interface Design (Phase 1)
```typescript
// open-sse/interfaces/browserPool.ts
export interface BrowserPoolProvider {
acquireBrowserContext(options?: BrowserPoolContextOptions): Promise<PooledContext>;
releaseBrowserContext(ctx: PooledContext): Promise<void>;
getBrowserPoolMetrics(): BrowserPoolMetrics;
shutdownPool(): Promise<void>;
isPoolEnabled(): boolean;
openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>;
readPageResponseBody(page: any): Promise<string>;
getBrowserPoolStatus(): BrowserPoolStatus;
}
```
### Stub Pattern
```typescript
// open-sse/services/browserPool.ts — thin stub
let _impl: BrowserPoolProvider | null = null;
async function getImpl(): Promise<BrowserPoolProvider> {
if (!_impl) {
try {
const { createBrowserPoolProvider } = await import("../../packages/browser-pool");
_impl = createBrowserPoolProvider();
} catch {
// Graceful fallback — disabled
_impl = createNullBrowserPoolProvider();
}
}
return _impl;
}
export async function acquireBrowserContext(...args) {
return (await getImpl()).acquireBrowserContext(...args);
}
```
## Alternatives Considered
1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern.
2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI.
3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works.
4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes.
## Acceptance Criteria
1. `open-sse/interfaces/browserPool.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types
2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports
3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance)
4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed
5. Core typecheck passes with the package installed
6. All existing tests pass when the browser-pool package is installed
7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed
8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing
9. Playwright is moved from root `dependencies` to optional/peer in the extracted package
10. Documentation updated in `docs/reference/ENVIRONMENT.md`
## Expected Test Plan
- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation)
- Unit tests moved to the extracted package
- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved)
- Verify `tests/unit/browserPool-proxy.test.ts` passes
- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes
- E2E: `npm run typecheck:core` without the package installed → 0 errors
- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass
## Additional Context
Current dependency graph (simplified):
```
open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool)
├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner)
│ ├── open-sse/executors/claude-web.ts (imports tryBackedChat)
│ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat)
├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser)
└── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics)
Standalone Playwright users (separate, future phases):
├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports)
├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle)
└── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path)
Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars)
```
Total extracted in Phase 1: ~856 LOC, 3 files.
Total deferred to Phase 2/3: ~1022 LOC, 4 files.
This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction.
## Related References
- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49`
- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation
- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast)

View File

@@ -71,9 +71,36 @@ creating another provider card or changing saved provider IDs.
provider page.
6. Keep custom endpoint overrides available for advanced and workspace-specific deployments.
Token Plan image-generation models are intentionally outside this first chat-provider slice. The
official quick start states that they use dedicated endpoints, so they require the image provider
pipeline instead of being advertised as chat models.
Token Plan media models remain outside the chat catalog because they use dedicated endpoints.
`wan2.7-image` and `wan2.7-image-pro` are registered in `open-sse/config/imageRegistry.ts`;
`happyhorse-1.1-i2v`, `happyhorse-1.1-t2v`, and `happyhorse-1.1-r2v` are registered in
`open-sse/config/videoRegistry.ts`. Both registries reuse the `qwen-cloud-token-plan` connection
identity and its region-specific credentials.
Regular Qwen Cloud has separate pay-as-you-go image and video catalogs under the `qwen-cloud`
identity. Its image models are `wan2.7-image-pro`, `wan2.7-image`, `qwen-image-3.0-pro`,
`qwen-image-2.0-pro-2026-06-22`, `qwen-image-2.0-2026-03-03`, and `z-image-turbo`. Its video
models are `happyhorse-1.1-i2v`, `happyhorse-1.1-t2v`, `happyhorse-1.1-r2v`,
`happyhorse-1.0-video-edit`, `wan2.7-t2v`, `wan2.7-i2v`, `wan2.7-r2v-2026-06-12`, and
`wan2.7-videoedit`. These models reuse only the regular `qwen-cloud` connection and regional
DashScope media endpoint. The Token Plan and pay-as-you-go registries do not inherit or merge
each other's model lists.
The `bailian-coding-plan` identity also has its own media allowlists:
`wan2.7-image`, `wan2.7-image-pro`, `qwen-image-2.0`, and `qwen-image-2.0-pro` for image
generation, plus
`happyhorse-1.1-i2v`, `happyhorse-1.1-t2v`, and `happyhorse-1.1-r2v` for video
generation. It uses only the Bailian Coding Plan connection and regional Coding Plan endpoint.
Its image and video registries do not inherit from or merge with either Qwen Cloud provider.
The regular `alibaba` identity has separate image and video allowlists. Its image models are
`qwen-image-3.0-pro`, `qwen-image-2.0-pro-2026-06-22`, `qwen-image-2.0`,
`z-image-turbo`, and `wan2.6-t2i`. Its added video models are `happyhorse-1.1-i2v`,
`happyhorse-1.1-t2v`, `happyhorse-1.1-r2v`, `happyhorse-1.0-video-edit`,
`wan2.7-i2v-2026-04-25`, `wan2.6-i2v-flash`, `wan2.7-t2v-2026-06-12`,
`wan2.7-r2v-2026-06-12`, and `wan2.7-videoedit`. These models use only the Alibaba Model Studio
connection and its selected regional media endpoint; they are not added to any Coding Plan or Qwen
Cloud registry.
The Qwen Cloud Token Plan chat catalog follows the Individual plan's exact-string text allowlist:
`qwen3.8-max-preview`, `qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-flash`, `glm-5.2`, and

View File

@@ -188,6 +188,10 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. |
| `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. |
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. |
| `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. |
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |

View File

@@ -140,6 +140,31 @@ function resolveAliasImageRequired(alias, modelConfig) {
}
export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
"qwen-cloud-token-plan": {
id: "qwen-cloud-token-plan",
alias: "qct",
baseUrl:
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
authType: "apikey",
authHeader: "bearer",
format: "qwen-token-plan-image",
models: [
{
id: "wan2.7-image",
name: "Wan 2.7 Image",
inputModalities: ["text", "image"],
},
{
id: "wan2.7-image-pro",
name: "Wan 2.7 Image Pro",
inputModalities: ["text", "image"],
},
],
// Both models share 1K/2K support. The Pro model also accepts explicit 4K
// dimensions, which callers can still pass through the permissive request schema.
supportedSizes: ["1024x1024", "2048x2048"],
},
openai: {
id: "openai",
baseUrl: "https://api.openai.com/v1/images/generations",
@@ -187,7 +212,8 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
"microsoft-designer-web": {
id: "microsoft-designer-web",
alias: "msdesigner",
baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci",
baseUrl:
"https://designerapp.officeapps.live.com/designerapp/DallE.ashx?action=GetDallEImagesCogSci",
authType: "apikey",
authHeader: "bearer",
format: "designer-web",
@@ -652,9 +678,21 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authHeader: "bearer",
format: "adobe-firefly-image",
models: [
{ id: "nano-banana-pro", name: "Firefly Gemini 3.0 (Nano Banana Pro)", inputModalities: ["text", "image"] },
{ id: "nano-banana", name: "Firefly Gemini 2.5 (Nano Banana)", inputModalities: ["text", "image"] },
{ id: "nano-banana-2", name: "Firefly Gemini 3.1 (Nano Banana 2)", inputModalities: ["text", "image"] },
{
id: "nano-banana-pro",
name: "Firefly Gemini 3.0 (Nano Banana Pro)",
inputModalities: ["text", "image"],
},
{
id: "nano-banana",
name: "Firefly Gemini 2.5 (Nano Banana)",
inputModalities: ["text", "image"],
},
{
id: "nano-banana-2",
name: "Firefly Gemini 3.1 (Nano Banana 2)",
inputModalities: ["text", "image"],
},
{ id: "gpt-image-2", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] },
{ id: "gpt-image", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] },
{ id: "gpt-image-1.5", name: "Firefly GPT Image 1.5", inputModalities: ["text", "image"] },
@@ -662,11 +700,131 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
{ id: "flux-pro", name: "Firefly Flux 1.1 Pro", inputModalities: ["text", "image"] },
{ id: "flux-ultra", name: "Firefly Flux 1.1 Ultra", inputModalities: ["text", "image"] },
{ id: "seedream-4", name: "Firefly Seedream 4.0", inputModalities: ["text", "image"] },
{ id: "seedream-5-lite", name: "Firefly Seedream 5.0 Lite", inputModalities: ["text", "image"] },
{ id: "runway-gen4-image", name: "Firefly Runway Gen-4 Image", inputModalities: ["text", "image"] },
{
id: "seedream-5-lite",
name: "Firefly Seedream 5.0 Lite",
inputModalities: ["text", "image"],
},
{
id: "runway-gen4-image",
name: "Firefly Runway Gen-4 Image",
inputModalities: ["text", "image"],
},
],
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
},
// Keep Bailian Coding Plan after existing duplicate model owners so adding
// explicit `bailian-coding-plan/` and `bcp/` routes does not change
// historical bare-model routing.
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
baseUrl:
"https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
authType: "apikey",
authHeader: "bearer",
format: "bailian-coding-plan-image",
models: [
{
id: "wan2.7-image",
name: "Wan 2.7 Image",
inputModalities: ["text", "image"],
},
{
id: "wan2.7-image-pro",
name: "Wan 2.7 Image Pro",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0",
name: "Qwen Image 2.0",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0-pro",
name: "Qwen Image 2.0 Pro",
inputModalities: ["text", "image"],
},
],
supportedSizes: ["1024x1024", "2048x2048"],
},
// Keep Alibaba after existing duplicate model owners so adding explicit
// `alibaba/` and `ali/` routes does not change historical bare-model routing.
alibaba: {
id: "alibaba",
alias: "ali",
baseUrl:
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
authType: "apikey",
authHeader: "bearer",
format: "alibaba-image",
models: [
{
id: "qwen-image-3.0-pro",
name: "Qwen Image 3.0 Pro",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0-pro-2026-06-22",
name: "Qwen Image 2.0 Pro (2026-06-22)",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0",
name: "Qwen Image 2.0",
inputModalities: ["text", "image"],
},
{ id: "z-image-turbo", name: "Z-Image Turbo" },
{ id: "wan2.6-t2i", name: "Wan 2.6 T2I" },
],
supportedSizes: ["1024x1024", "1280x1280", "2048x2048"],
},
// Keep regular Qwen Cloud isolated from Alibaba, Bailian Coding Plan, and
// Qwen Cloud Token Plan. Explicit `qwen-cloud/` or `qwc/` routes use only
// the regular Qwen Cloud connection and its regional DashScope endpoint.
"qwen-cloud": {
id: "qwen-cloud",
alias: "qwc",
baseUrl:
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
authType: "apikey",
authHeader: "bearer",
format: "qwen-cloud-image",
models: [
{
id: "wan2.7-image-pro",
name: "Wan 2.7 Image Pro",
inputModalities: ["text", "image"],
},
{
id: "wan2.7-image",
name: "Wan 2.7 Image",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-3.0-pro",
name: "Qwen Image 3.0 Pro",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0-pro-2026-06-22",
name: "Qwen Image 2.0 Pro (2026-06-22)",
inputModalities: ["text", "image"],
},
{
id: "qwen-image-2.0-2026-03-03",
name: "Qwen Image 2.0 (2026-03-03)",
inputModalities: ["text", "image"],
},
{ id: "z-image-turbo", name: "Z-Image Turbo" },
],
// 1K/2K are shared by the whole catalog. Wan 2.7 Image Pro callers can
// still pass supported 4K dimensions through the permissive request schema.
supportedSizes: ["1024x1024", "2048x2048"],
},
};
/**

View File

@@ -27,6 +27,36 @@ interface VideoProvider {
}
export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
"qwen-cloud-token-plan": {
id: "qwen-cloud-token-plan",
alias: "qct",
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/api/v1",
statusUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/api/v1/tasks",
authType: "apikey",
authHeader: "bearer",
format: "dashscope-video",
models: [
{ id: "happyhorse-1.1-i2v", name: "HappyHorse 1.1 I2V" },
{ id: "happyhorse-1.1-t2v", name: "HappyHorse 1.1 T2V" },
{ id: "happyhorse-1.1-r2v", name: "HappyHorse 1.1 R2V" },
],
},
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/api/v1",
statusUrl: "https://coding-intl.dashscope.aliyuncs.com/api/v1/tasks",
authType: "apikey",
authHeader: "bearer",
format: "dashscope-video",
models: [
{ id: "happyhorse-1.1-i2v", name: "HappyHorse 1.1 I2V" },
{ id: "happyhorse-1.1-t2v", name: "HappyHorse 1.1 T2V" },
{ id: "happyhorse-1.1-r2v", name: "HappyHorse 1.1 R2V" },
],
},
vertex: {
id: "vertex",
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",
@@ -221,7 +251,37 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "dashscope-video",
models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }],
models: [
{ id: "happyhorse-1.1-i2v", name: "HappyHorse 1.1 I2V" },
{ id: "happyhorse-1.1-t2v", name: "HappyHorse 1.1 T2V" },
{ id: "happyhorse-1.1-r2v", name: "HappyHorse 1.1 R2V" },
{ id: "happyhorse-1.0-video-edit", name: "HappyHorse 1.0 Video Edit" },
{ id: "wan2.7-i2v-2026-04-25", name: "Wan 2.7 I2V (2026-04-25)" },
{ id: "wan2.6-i2v-flash", name: "Wan 2.6 I2V Flash" },
{ id: "wan2.7-t2v-2026-06-12", name: "Wan 2.7 T2V (2026-06-12)" },
{ id: "wan2.7-r2v-2026-06-12", name: "Wan 2.7 R2V (2026-06-12)" },
{ id: "wan2.7-videoedit", name: "Wan 2.7 Video Edit" },
],
},
"qwen-cloud": {
id: "qwen-cloud",
alias: "qwc",
baseUrl: "https://dashscope-intl.aliyuncs.com/api/v1",
statusUrl: "https://dashscope-intl.aliyuncs.com/api/v1/tasks",
authType: "apikey",
authHeader: "bearer",
format: "dashscope-video",
models: [
{ id: "happyhorse-1.1-i2v", name: "HappyHorse 1.1 I2V" },
{ id: "happyhorse-1.1-t2v", name: "HappyHorse 1.1 T2V" },
{ id: "happyhorse-1.1-r2v", name: "HappyHorse 1.1 R2V" },
{ id: "happyhorse-1.0-video-edit", name: "HappyHorse 1.0 Video Edit" },
{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" },
{ id: "wan2.7-i2v", name: "Wan 2.7 I2V" },
{ id: "wan2.7-r2v-2026-06-12", name: "Wan 2.7 R2V (2026-06-12)" },
{ id: "wan2.7-videoedit", name: "Wan 2.7 Video Edit" },
],
},
// Segmind video generation (#6656). Same `POST /v1/{model}` REST shape as

View File

@@ -22,7 +22,10 @@
* chunk — safer than assuming unverified incremental-delta semantics.
*
* Auth: Cookie-based (token_v2 [+ optional space_id, notion_browser_id, user_id])
* Method: Direct fetch — no browser automation required.
* Method: Browser-TLS impersonation via tls-client-node (Chrome JA3). Plain
* Node/undici fetch is rejected by Notion's edge with in-band
* `temporarily-unavailable` (HTTP 200, empty assistant text) — curl/Schannel
* and Chrome work with the same cookie + body. See services/notionTlsClient.ts.
*/
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
@@ -57,6 +60,10 @@ import {
messagesForNotionTranscript,
type NotionAgentOptions,
} from "../services/notionTranscriptBuilder.ts";
import {
tlsFetchNotion,
TlsClientUnavailableError,
} from "../services/notionTlsClient.ts";
// Re-exported for unit tests that destructure `mod.<name>` on this module.
export {
@@ -79,8 +86,9 @@ export {
const BASE_URL = "https://app.notion.com";
const NOTION_URL = `${BASE_URL}/api/v3/runInferenceTranscript`;
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const NOTION_CLIENT_VERSION = "23.13.20260719.1125";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
// Match a recent live browser capture (web_providers/notion.txt, 2026-07-20).
const NOTION_CLIENT_VERSION = "23.13.20260720.1949";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -451,29 +459,62 @@ async function sendNotionInferenceRequest(opts: {
signal: ExecuteInput["signal"];
}): Promise<{ rawText?: string; errorResult?: ReturnType<typeof makeErrorResult> }> {
const { reqBody, reqHeaders, signal } = opts;
let upstream: Response;
// Notion's edge rejects Node/undici TLS fingerprints with in-band
// temporarily-unavailable (HTTP 200, no assistant text). Always use the
// Chrome-JA3 tls-client path for runInferenceTranscript.
let status = 0;
let rawText = "";
try {
upstream = await fetch(NOTION_URL, {
const tlsRes = await tlsFetchNotion(NOTION_URL, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
// Inference can take a while (tool-autoload + LLM first token).
timeoutMs:
Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 180_000,
});
status = tlsRes.status;
rawText = tlsRes.text ?? "";
} catch (err) {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
if (err instanceof TlsClientUnavailableError) {
// Fall back to plain fetch only when the native TLS sidecar is missing —
// better a degraded path than a hard crash on platforms without the binary.
try {
const upstream = await fetch(NOTION_URL, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
});
status = upstream.status;
rawText = await upstream.text().catch(() => "");
} catch (fallbackErr) {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${fallbackErr instanceof Error ? fallbackErr.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
} else {
return {
errorResult: makeErrorResult(
502,
`Notion fetch failed: ${err instanceof Error ? err.message : "unknown error"}`,
reqBody,
NOTION_URL
),
};
}
}
if (upstream.status === 401 || upstream.status === 403) {
if (status === 401 || status === 403) {
return {
errorResult: makeErrorResult(
upstream.status,
status,
"Notion session expired or invalid — re-paste token_v2 from notion.so",
reqBody,
NOTION_URL
@@ -481,19 +522,18 @@ async function sendNotionInferenceRequest(opts: {
};
}
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "");
if (status < 200 || status >= 300) {
return {
errorResult: makeErrorResult(
upstream.status,
`Notion error: ${errText}`,
status || 502,
`Notion error: ${rawText.slice(0, 500)}`,
reqBody,
NOTION_URL
),
};
}
return { rawText: await upstream.text() };
return { rawText };
}
// ─── Executor ───────────────────────────────────────────────────────────────

View File

@@ -24,6 +24,7 @@ import { kieExecutor } from "../executors/kie.ts";
import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts";
import type { ExecutorLog, ProviderCredentials } from "../executors/base.ts";
import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts";
import { createHash } from "node:crypto";
import { saveCallLog } from "@/lib/usageDb";
@@ -73,6 +74,7 @@ import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmin
import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts";
import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts";
import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts";
import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts";
import {
applyPollinationsAnonymousFallback,
reportPollinationsAnonOutcome,
@@ -621,6 +623,22 @@ export async function handleImageGeneration({
});
}
if (
providerConfig.format === "alibaba-image" ||
providerConfig.format === "qwen-cloud-image" ||
providerConfig.format === "qwen-token-plan-image" ||
providerConfig.format === "bailian-coding-plan-image"
) {
return handleAlibabaImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}
return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log });
}
@@ -1256,11 +1274,11 @@ export async function handleImageEdit({
}: {
provider: string;
model: string;
body: Record<string, any>;
body: Record<string, unknown>;
imageBytes: Buffer;
imageMime?: string; // accepted for symmetry with route layer; not used
credentials: any;
log: any;
credentials: ProviderCredentials | null | undefined;
log: ExecutorLog | null | undefined;
signal?: AbortSignal | null;
clientHeaders?: Record<string, string> | null;
}) {

View File

@@ -0,0 +1,202 @@
import { resolveAlibabaProviderMediaBaseUrl } from "@/shared/constants/alibabaProviderRegions";
import { isJsonObject } from "../../../utils/kieTask.ts";
import { sanitizeErrorMessage } from "../../../utils/error.ts";
interface AlibabaImageOptions {
model: string;
provider: string;
providerConfig: {
baseUrl: string;
models?: Array<{ id: string }>;
};
body: Record<string, unknown> & {
prompt?: unknown;
size?: unknown;
n?: unknown;
image?: unknown;
image_url?: unknown;
imageUrls?: unknown;
image_urls?: unknown;
parameters?: unknown;
};
credentials?: {
apiKey?: string;
accessToken?: string;
providerSpecificData?: unknown;
} | null;
log?: {
info: (scope: string, message: string) => void;
error: (scope: string, message: string) => void;
} | null;
}
function collectImageUrls(body: AlibabaImageOptions["body"]): string[] {
const values = [body.image, body.image_url, body.imageUrls, body.image_urls].flatMap((value) =>
Array.isArray(value) ? value : [value]
);
const urls = new Set<string>();
for (const value of values) {
if (typeof value === "string" && value.trim()) {
urls.add(value.trim());
continue;
}
if (!isJsonObject(value)) continue;
const url =
typeof value.url === "string"
? value.url
: typeof value.image_url === "string"
? value.image_url
: null;
if (url?.trim()) urls.add(url.trim());
}
return [...urls];
}
function normalizeImageSize(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) return undefined;
const size = value.trim();
return /^\d+x\d+$/i.test(size) ? size.replace(/x/i, "*") : size;
}
function getAlibabaImageUrls(payload: unknown): string[] {
if (!isJsonObject(payload) || !isJsonObject(payload.output)) return [];
const choices = Array.isArray(payload.output.choices) ? payload.output.choices : [];
const urls = new Set<string>();
for (const choice of choices) {
if (!isJsonObject(choice) || !isJsonObject(choice.message)) continue;
const content = Array.isArray(choice.message.content) ? choice.message.content : [];
for (const item of content) {
if (!isJsonObject(item) || typeof item.image !== "string" || !item.image.trim()) continue;
urls.add(item.image.trim());
}
}
return [...urls];
}
function getAlibabaImageError(payload: unknown, fallback: string): string {
if (!isJsonObject(payload)) return fallback;
const message =
typeof payload.message === "string"
? payload.message
: typeof payload.code === "string"
? payload.code
: fallback;
return sanitizeErrorMessage(message) || fallback;
}
export async function handleAlibabaImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: AlibabaImageOptions) {
const isRegisteredModel = providerConfig.models?.some((entry) => entry.id === model) === true;
if (!isRegisteredModel) {
return {
success: false as const,
status: 400,
error: `Unsupported ${provider} image model: ${model}`,
};
}
const token = credentials?.apiKey || credentials?.accessToken;
if (!token) {
return {
success: false as const,
status: 401,
error: `${provider} API key is required`,
};
}
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
const content = [
...collectImageUrls(body).map((image) => ({ image })),
...(prompt ? [{ text: prompt }] : []),
];
const parameters: Record<string, unknown> = isJsonObject(body.parameters)
? { ...body.parameters }
: {};
const size = normalizeImageSize(body.size);
if (size) parameters.size = size;
if (Number.isInteger(body.n) && Number(body.n) > 0) parameters.n = Number(body.n);
for (const key of [
"negative_prompt",
"prompt_extend",
"watermark",
"seed",
"enable_sequential",
"thinking_mode",
"color_palette",
] as const) {
if (body[key] !== undefined) parameters[key] = body[key];
}
const mediaBaseUrl = resolveAlibabaProviderMediaBaseUrl(
provider,
credentials.providerSpecificData,
providerConfig.baseUrl
);
const url = mediaBaseUrl.endsWith("/services/aigc/multimodal-generation/generation")
? mediaBaseUrl
: `${mediaBaseUrl}/services/aigc/multimodal-generation/generation`;
log?.info?.("IMAGE", `${provider}/${model} (alibaba-image)`);
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
input: {
messages: [{ role: "user", content }],
},
...(Object.keys(parameters).length > 0 ? { parameters } : {}),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return {
success: false as const,
status: response.status || 502,
error: getAlibabaImageError(payload, "Alibaba image generation failed"),
};
}
const urls = getAlibabaImageUrls(payload);
if (urls.length === 0) {
return {
success: false as const,
status: 502,
error: "Alibaba image generation returned no images",
};
}
return {
success: true as const,
data: {
created: Math.floor(Date.now() / 1000),
data: urls.map((url) => ({ url })),
},
};
} catch (error: unknown) {
log?.error?.("IMAGE", `Alibaba image generation failed: ${sanitizeErrorMessage(error)}`);
return {
success: false as const,
status: 502,
error: sanitizeErrorMessage(error) || "Alibaba image generation failed",
};
}
}

View File

@@ -1011,6 +1011,23 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown {
return sanitizeResponsesStreamingEvent(parsedRecord);
}
// #8271: Anthropic-native streaming events (content_block_delta with
// text_delta / thinking_delta) bypass the OpenAI choices[].delta.content
// path below. Strip zero-width characters from their text payloads so
// U+200D and friends don't leak to the client on the Messages API.
if (eventType === "content_block_delta") {
const deltaRecord = toRecord(parsedRecord.delta);
if (deltaRecord) {
if (typeof deltaRecord.text === "string") {
deltaRecord.text = stripZeroWidthText(deltaRecord.text);
}
if (typeof deltaRecord.thinking === "string") {
deltaRecord.thinking = stripZeroWidthText(deltaRecord.thinking);
}
}
return parsedRecord;
}
// Build sanitized chunk
const sanitized: JsonRecord = {};

View File

@@ -1,11 +1,14 @@
import { isJsonObject } from "../../utils/kieTask.ts";
import { saveCallLog } from "@/lib/usageDb";
import { resolveAlibabaProviderMediaBaseUrl } from "@/shared/constants/alibabaProviderRegions";
import { sanitizeErrorMessage } from "../../utils/error.ts";
/**
* Alibaba (DashScope) Wan video generation: create async task → poll → MP4.
* Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba
* provider Bearer apiKey — no separate credential flow.
* Alibaba-family video generation: create async task → poll → MP4.
*
* Provider identity remains authoritative for credentials and regional endpoints.
* Bailian Coding Plan, regular Qwen Cloud, and Qwen Cloud Token Plan intentionally
* share wire helpers only; their model lists and connections stay isolated.
*/
export async function handleDashscopeVideoGeneration({
model,
@@ -17,17 +20,35 @@ export async function handleDashscopeVideoGeneration({
}: {
model: string;
provider: string;
providerConfig: { baseUrl: string; statusUrl?: string };
providerConfig: {
baseUrl: string;
statusUrl?: string;
models?: Array<{ id: string }>;
};
body: Record<string, unknown> & {
prompt?: unknown;
negative_prompt?: unknown;
size?: unknown;
aspect_ratio?: unknown;
ratio?: unknown;
resolution?: unknown;
duration?: unknown;
image?: unknown;
image_url?: unknown;
imageUrls?: unknown;
image_urls?: unknown;
reference_images?: unknown;
media?: unknown;
prompt_extend?: unknown;
watermark?: unknown;
timeout_ms?: unknown;
poll_interval_ms?: unknown;
};
credentials?: { apiKey?: string; accessToken?: string } | null;
credentials?: {
apiKey?: string;
accessToken?: string;
providerSpecificData?: unknown;
} | null;
log?: {
info: (scope: string, message: string) => void;
error: (scope: string, message: string) => void;
@@ -37,29 +58,51 @@ export async function handleDashscopeVideoGeneration({
const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000;
const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500;
const token = credentials?.apiKey || credentials?.accessToken;
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, "");
const isAlibabaManagedMediaProvider =
provider === "alibaba" ||
provider === "bailian-coding-plan" ||
provider === "qwen-cloud" ||
provider === "qwen-cloud-token-plan";
const isRegisteredAlibabaMediaModel =
!isAlibabaManagedMediaProvider ||
providerConfig.models?.some((entry) => entry.id === model) === true;
if (!isRegisteredAlibabaMediaModel) {
return {
success: false,
status: 400,
error: `Unsupported ${provider} video model: ${model}`,
};
}
const baseUrl = (
isAlibabaManagedMediaProvider
? resolveAlibabaProviderMediaBaseUrl(
provider,
credentials?.providerSpecificData,
providerConfig.baseUrl
)
: providerConfig.baseUrl
).replace(/\/$/, "");
const statusUrl = (
isAlibabaManagedMediaProvider
? `${baseUrl}/tasks`
: providerConfig.statusUrl || `${baseUrl}/tasks`
).replace(/\/$/, "");
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
if (!token) {
return { success: false, status: 401, error: "Alibaba DashScope API key is required" };
}
const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio);
const parameters: Record<string, unknown> = {};
if (sizeParam) parameters.size = sizeParam;
if (body.duration != null) parameters.duration = Number(body.duration);
const payload = {
model,
input: {
prompt,
...(typeof body.negative_prompt === "string"
? { negative_prompt: body.negative_prompt }
: {}),
},
parameters,
};
const payload =
provider === "qwen-cloud" || provider === "alibaba"
? buildAlibabaMediaPayload(provider, model, prompt, body)
: provider === "qwen-cloud-token-plan" || provider === "bailian-coding-plan"
? buildHappyHorsePayload(model, prompt, body)
: buildLegacyDashscopePayload(model, prompt, body);
if ("error" in payload) {
return { success: false, status: 400, error: payload.error };
}
if (log) {
log.info(
@@ -154,6 +197,353 @@ export async function handleDashscopeVideoGeneration({
}
}
function buildLegacyDashscopePayload(model: string, prompt: string, body: Record<string, unknown>) {
const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio);
const parameters: Record<string, unknown> = {};
if (sizeParam) parameters.size = sizeParam;
if (body.duration != null) parameters.duration = Number(body.duration);
return {
model,
input: {
prompt,
...(typeof body.negative_prompt === "string"
? { negative_prompt: body.negative_prompt }
: {}),
},
parameters,
};
}
function buildHappyHorsePayload(
model: string,
prompt: string,
body: Record<string, unknown>
): Record<string, unknown> | { error: string } {
const input = buildDashscopeInput(prompt, body);
const media = collectDashscopeMedia(body);
if (model.endsWith("-i2v")) {
const firstFrame =
media.find((item) => item.type === "first_frame") ||
media.find((item) => item.type === "reference_image");
if (!firstFrame) {
return { error: `Image input is required for video model: ${model}` };
}
input.media = [{ type: "first_frame", url: firstFrame.url }];
} else if (model.endsWith("-r2v")) {
const referenceImages = media
.filter((item) => item.type === "first_frame" || item.type === "reference_image")
.map((item) => ({ type: "reference_image", url: item.url }));
if (referenceImages.length === 0) {
return { error: `Reference image input is required for video model: ${model}` };
}
input.media = referenceImages;
}
return {
model,
input,
parameters: buildModernDashscopeParameters(body, !model.endsWith("-i2v")),
};
}
function buildAlibabaMediaPayload(
provider: string,
model: string,
prompt: string,
body: Record<string, unknown>
): Record<string, unknown> | { error: string } {
if (
model === "happyhorse-1.1-i2v" ||
model === "happyhorse-1.1-t2v" ||
model === "happyhorse-1.1-r2v"
) {
return buildHappyHorsePayload(model, prompt, body);
}
if (model === "happyhorse-1.0-video-edit" || model === "wan2.7-videoedit") {
return buildModernDashscopeVideoEditPayload(model, prompt, body);
}
if (model === "wan2.7-t2v" || model === "wan2.7-t2v-2026-06-12") {
return buildModernDashscopeTextToVideoPayload(model, prompt, body);
}
if (model === "wan2.7-i2v" || model === "wan2.7-i2v-2026-04-25" || model === "wan2.6-i2v-flash") {
return buildModernDashscopeImageToVideoPayload(model, prompt, body);
}
if (model === "wan2.7-r2v-2026-06-12") {
return buildModernDashscopeReferenceToVideoPayload(model, prompt, body);
}
return { error: `Unsupported ${provider} video model: ${model}` };
}
function buildModernDashscopeTextToVideoPayload(
model: string,
prompt: string,
body: Record<string, unknown>
) {
const input = buildDashscopeInput(prompt, body);
const audio = collectDashscopeMedia(body).find((item) => item.type === "driving_audio");
if (audio) input.audio_url = audio.url;
return {
model,
input,
parameters: buildModernDashscopeParameters(body, true),
};
}
function buildModernDashscopeImageToVideoPayload(
model: string,
prompt: string,
body: Record<string, unknown>
): Record<string, unknown> | { error: string } {
const input = buildDashscopeInput(prompt, body);
const media = collectDashscopeMedia(body);
const firstFrame =
media.find((item) => item.type === "first_frame") ||
media.find((item) => item.type === "reference_image");
if (!firstFrame) {
return { error: `Image input is required for video model: ${model}` };
}
input.media = [
{ type: "first_frame", url: firstFrame.url },
...media
.filter((item) => item.type === "last_frame" || item.type === "driving_audio")
.map((item) => copyDashscopeMediaItem(item)),
];
return {
model,
input,
parameters: buildModernDashscopeParameters(body, false),
};
}
function buildModernDashscopeReferenceToVideoPayload(
model: string,
prompt: string,
body: Record<string, unknown>
): Record<string, unknown> | { error: string } {
const input = buildDashscopeInput(prompt, body);
const references = collectDashscopeMedia(body).flatMap((item) => {
if (
item.type === "first_frame" ||
item.type === "last_frame" ||
item.type === "reference_image"
) {
return [copyDashscopeMediaItem(item, "reference_image")];
}
if (item.type === "video" || item.type === "first_clip" || item.type === "reference_video") {
return [copyDashscopeMediaItem(item, "reference_video")];
}
return [];
});
if (references.length === 0) {
return { error: `Reference image or video input is required for video model: ${model}` };
}
input.media = references;
return {
model,
input,
parameters: buildModernDashscopeParameters(body, true),
};
}
function buildModernDashscopeVideoEditPayload(
model: string,
prompt: string,
body: Record<string, unknown>
): Record<string, unknown> | { error: string } {
const input = buildDashscopeInput(prompt, body);
const media = collectDashscopeMedia(body);
const sourceVideo = media.find(
(item) => item.type === "video" || item.type === "first_clip" || item.type === "reference_video"
);
if (!sourceVideo) {
return { error: `Video input is required for video model: ${model}` };
}
input.media = [
{ type: "video", url: sourceVideo.url },
...media
.filter((item) => item.type === "first_frame" || item.type === "reference_image")
.map((item) => copyDashscopeMediaItem(item, "reference_image")),
];
return {
model,
input,
parameters: buildModernDashscopeParameters(body, true),
};
}
type DashscopeMediaType =
| "first_frame"
| "last_frame"
| "first_clip"
| "reference_image"
| "reference_video"
| "video"
| "driving_audio";
type DashscopeMediaItem = {
type: DashscopeMediaType;
url: string;
reference_voice?: string;
};
const DASHSCOPE_MEDIA_TYPES = new Set<DashscopeMediaType>([
"first_frame",
"last_frame",
"first_clip",
"reference_image",
"reference_video",
"video",
"driving_audio",
]);
function buildDashscopeInput(prompt: string, body: Record<string, unknown>) {
return {
...(prompt ? { prompt } : {}),
...(typeof body.negative_prompt === "string" && body.negative_prompt.trim()
? { negative_prompt: body.negative_prompt.trim() }
: {}),
} as Record<string, unknown>;
}
function buildModernDashscopeParameters(
body: Record<string, unknown>,
includeRatio: boolean
): Record<string, unknown> {
const parameters = isJsonObject(body.parameters) ? { ...body.parameters } : {};
const resolution = normalizeHappyHorseResolution(body.resolution, body.size);
if (resolution) parameters.resolution = resolution;
const ratio = normalizeHappyHorseRatio(body.ratio, body.aspect_ratio, body.size);
if (ratio && includeRatio) parameters.ratio = ratio;
if (body.duration != null && Number.isFinite(Number(body.duration))) {
parameters.duration = Number(body.duration);
}
if (typeof body.prompt_extend === "boolean") parameters.prompt_extend = body.prompt_extend;
if (typeof body.watermark === "boolean") parameters.watermark = body.watermark;
if (Number.isInteger(body.seed) && Number(body.seed) >= 0) parameters.seed = Number(body.seed);
if (typeof body.shot_type === "string" && body.shot_type.trim()) {
parameters.shot_type = body.shot_type.trim();
}
return parameters;
}
function copyDashscopeMediaItem(
item: DashscopeMediaItem,
type: DashscopeMediaType = item.type
): DashscopeMediaItem {
return {
type,
url: item.url,
...(item.reference_voice ? { reference_voice: item.reference_voice } : {}),
};
}
function collectDashscopeMedia(body: Record<string, unknown>): DashscopeMediaItem[] {
if (Array.isArray(body.media)) {
const explicit = body.media.filter(isJsonObject).flatMap((item) => {
const normalized = toDashscopeMediaItem(item, "reference_image");
return normalized ? [normalized] : [];
});
if (explicit.length > 0) return dedupeDashscopeMedia(explicit);
}
const media: DashscopeMediaItem[] = [];
const imageCandidates = [body.image, body.image_url, body.imageUrls, body.image_urls].flatMap(
(value) => (Array.isArray(value) ? value : [value])
);
imageCandidates.forEach((value, index) => {
const item = toDashscopeMediaItem(value, index === 0 ? "first_frame" : "reference_image");
if (item) media.push(item);
});
addDashscopeMedia(media, body.reference_images, "reference_image");
addDashscopeMedia(media, [body.video, body.video_url, body.videoUrls, body.video_urls], "video");
addDashscopeMedia(media, body.reference_videos, "reference_video");
addDashscopeMedia(media, [body.audio, body.audio_url], "driving_audio");
return dedupeDashscopeMedia(media);
}
function addDashscopeMedia(media: DashscopeMediaItem[], value: unknown, type: DashscopeMediaType) {
const values = Array.isArray(value) ? value : [value];
for (const candidate of values.flatMap((item) => (Array.isArray(item) ? item : [item]))) {
const normalized = toDashscopeMediaItem(candidate, type);
if (normalized) media.push(normalized);
}
}
function toDashscopeMediaItem(
value: unknown,
fallbackType: DashscopeMediaType
): DashscopeMediaItem | null {
if (typeof value === "string" && value.trim()) {
return { type: fallbackType, url: value.trim() };
}
if (!isJsonObject(value)) return null;
const url = [value.url, value.image_url, value.video_url, value.audio_url].find(
(candidate) => typeof candidate === "string" && candidate.trim()
);
if (typeof url !== "string") return null;
const type =
typeof value.type === "string" && DASHSCOPE_MEDIA_TYPES.has(value.type as DashscopeMediaType)
? (value.type as DashscopeMediaType)
: fallbackType;
return {
type,
url: url.trim(),
...(typeof value.reference_voice === "string" && value.reference_voice.trim()
? { reference_voice: value.reference_voice.trim() }
: {}),
};
}
function dedupeDashscopeMedia(media: DashscopeMediaItem[]): DashscopeMediaItem[] {
const seen = new Set<string>();
return media.filter((item) => {
const key = `${item.type}\0${item.url}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeHappyHorseResolution(resolution: unknown, size: unknown): string | undefined {
if (typeof resolution === "string" && /^(720|1080)p$/i.test(resolution.trim())) {
return resolution.trim().toUpperCase();
}
if (typeof size !== "string") return undefined;
const match = size.trim().match(/^(\d+)[x*](\d+)$/i);
if (!match) return undefined;
return Math.max(Number(match[1]), Number(match[2])) >= 1920 ? "1080P" : "720P";
}
function normalizeHappyHorseRatio(
ratio: unknown,
aspectRatio: unknown,
size: unknown
): string | undefined {
const explicit = [ratio, aspectRatio].find(
(value) => typeof value === "string" && /^\d+:\d+$/.test(value.trim())
);
if (typeof explicit === "string") return explicit.trim();
if (typeof size !== "string") return undefined;
const match = size.trim().match(/^(\d+)[x*](\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (width === height) return "1:1";
if (width * 9 === height * 16) return "16:9";
if (width * 16 === height * 9) return "9:16";
if (width * 3 === height * 4) return "4:3";
if (width * 4 === height * 3) return "3:4";
return undefined;
}
// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720).
// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable
// (then omitted from the payload so DashScope applies its own default).

View File

@@ -27,12 +27,23 @@ describe("auto/chaos virtual combo", () => {
it("materializes with fusion strategy + chaos config", async () => {
const combo = await createVirtualAutoCombo("chaos");
expect(combo.type).toBe("auto");
expect(combo.strategy).toBe("fusion");
expect(combo.strategy).toBe("auto");
expect(combo.config?.chaos?.enabled).toBe(true);
// panel is capped to a sane size
expect((combo.models ?? []).length).toBeGreaterThan(0);
expect((combo.models ?? []).length).toBeLessThanOrEqual(5);
// judge (primary) is the first panel model
expect(combo.config?.chaos?.judgeModel).toBe(combo.models?.[0]?.model);
// tuning config is present (may have undefined fields from env vars)
expect(combo.config?.chaos?.tuning).toBeDefined();
});
it("deduplicates panel models by provider for diversity", async () => {
const combo = await createVirtualAutoCombo("chaos");
const models = combo.models ?? [];
const providers = models.map((m) => m.providerId);
const uniqueProviders = new Set(providers);
// No provider should appear more than once in the chaos panel.
expect(providers.length).toBe(uniqueProviders.size);
});
});

View File

@@ -41,6 +41,9 @@ export type ChaosTuning = {
type Body = Record<string, unknown>;
/** Minimal shape of the downstream single-model dispatch target (carries an abort signal). */
type ChaosTarget = { modelAbortSignal?: AbortSignal };
export type ChaosPart = {
model: string;
index: number;
@@ -54,6 +57,10 @@ export type ChaosPart = {
* We emit a custom event name `omni-chaos-part` so a protocol-aware IDE can
* split it out; non-aware clients reading OpenAI-style SSE will simply ignore
* the unknown event and use the final `data:` chunk below.
*
* The part's text is NOT included in the metadata event — it arrives in the
* final `data:` chunk for the primary model. This keeps each broadcast event
* small (metadata-only) so SSE buffering stays predictable.
*/
export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
const meta = {
@@ -62,9 +69,8 @@ export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
index: part.index,
ok: part.ok,
final: isFinal,
error: part.error,
...(part.error ? { error: part.error } : {}),
};
// Comment line (ignored by standard SSE parsers) + explicit event envelope.
return (
`: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n` +
`event: omni-chaos-part\n` +
@@ -83,27 +89,49 @@ function withTimeout<T>(
fallback: T,
onTimeout?: () => void
): Promise<T> {
return new Promise((resolve) => {
const t = setTimeout(() => {
try {
onTimeout?.();
} catch {
/* ignore abort errors */
}
if (!Number.isFinite(ms) || ms <= 0) return p;
return new Promise<T>((resolve) => {
const timer = setTimeout(() => {
onTimeout?.();
resolve(fallback);
}, ms);
Promise.resolve(p)
.then((v) => {
clearTimeout(t);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
})
.catch(() => {
clearTimeout(t);
},
() => {
clearTimeout(timer);
resolve(fallback);
});
}
);
});
}
/** Encoded SSE string builder — avoids re-encoding the same separator. */
const SSE_SEP = "\n\n";
const SSE_DONE = "data: [DONE]\n\n";
/**
* Build a standard OpenAI-style chat.completion.chunk SSE data line.
*/
function chatChunk(id: string, model: string, content: string, finishReason = "stop"): string {
return (
`data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
model,
choices: [
{
index: 0,
delta: { role: "assistant", content },
finish_reason: finishReason,
},
],
})}` + SSE_SEP
);
}
/**
* Run the chaos panel. Returns the ordered parts plus a recommended "primary"
* part (highest index of a successful model, or the first success) that callers
@@ -112,6 +140,50 @@ function withTimeout<T>(
* Each panel call gets its own AbortController; `withTimeout` aborts it on
* timeout so the underlying request is cancelled, not merely superseded.
*/
/**
* Shared per-model dispatch: wraps a single chaos panel call with timeout + abort.
* Used by both runChaosPanel and handleChaosChat to avoid code duplication.
*/
function dispatchOnePanelModel(opts: {
body: Body;
model: string;
index: number;
handleSingleModel: HandleSingleModel;
ctrl: AbortController;
hardTimeout: number;
log?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
/** Optional callback invoked per-result so callers (handleChaosChat) can
* enqueue progressive SSE events without duplicating dispatch logic. */
onResult?: (part: ChaosPart) => Promise<void>;
}): Promise<ChaosPart> {
const { body, model, index, handleSingleModel, ctrl, hardTimeout, log, onResult } = opts;
return withTimeout(
(async (): Promise<ChaosPart> => {
try {
const res = await handleSingleModel(body, model, {
modelAbortSignal: ctrl.signal,
});
const text = await extractText(res);
log?.info?.(
`CHAOS panel ${index} (${model}) ok=${res.ok} status=${res.status} textLen=${text.length}`
);
const part: ChaosPart = { model, index, ok: true, text };
await onResult?.(part);
return part;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.warn?.(`CHAOS panel ${index} (${model}) failed:`, msg);
const part: ChaosPart = { model, index, ok: false, text: "", error: msg };
await onResult?.(part);
return part;
}
})(),
hardTimeout,
{ model, index, ok: false, text: "", error: "chaos-panel-timeout" } as ChaosPart,
() => ctrl.abort()
);
}
export async function runChaosPanel(opts: {
body: Body;
models: string[];
@@ -127,40 +199,25 @@ export async function runChaosPanel(opts: {
return { parts: [], primary: null };
}
const calls = panel.map((model, index) => {
const ctrl = new AbortController();
return withTimeout(
(async (): Promise<ChaosPart> => {
try {
const res = await handleSingleModel(body, model, {
modelAbortSignal: ctrl.signal,
});
const text = await extractText(res);
log?.info?.(
`CHAOS panel ${index} (${model}) ok=${res.ok} status=${res.status} textLen=${text.length}`
);
return { model, index, ok: true, text };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.warn?.(`CHAOS panel ${index} (${model}) failed:`, msg);
return { model, index, ok: false, text: "", error: msg };
}
})(),
const controllers = panel.map(() => new AbortController());
const calls = panel.map((model, index) =>
dispatchOnePanelModel({
body,
model,
index,
handleSingleModel,
ctrl: controllers[index],
hardTimeout,
// Timed-out model → treated as a failed part, not a hang.
{
model,
index,
ok: false,
text: "",
error: "chaos-panel-timeout",
} as ChaosPart,
// Abort the underlying fetch so the connection is released on timeout.
() => ctrl.abort()
);
});
log,
})
);
const parts = await Promise.all(calls);
// Release all abort controllers (those not already aborted by timeout).
for (const ac of controllers) {
if (!ac.signal.aborted) ac.abort();
}
const successes = parts.filter((p) => p.ok);
const primary = successes.length > 0 ? successes[successes.length - 1] : null;
@@ -175,20 +232,27 @@ export async function runChaosPanel(opts: {
* not reliable here because OmniRoute may force a streaming envelope internally.
*/
async function extractText(res: Response): Promise<string> {
// Include error status info when non-200, so the dispatch caller can log it.
if (!res.ok) {
try {
const errBody = await res.clone().text();
return errBody.trim() || `(HTTP ${res.status})`;
} catch {
return `(HTTP ${res.status})`;
}
}
let raw: string;
try {
raw = await res.clone().text();
} catch {
return "";
}
// Try JSON first (non-streaming completion).
const trimmed = raw.trim();
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
const fromJson = firstTextFromOpenAI(parsed);
if (fromJson) return fromJson;
// Anthropic non-streaming also arrives as `{...}` with top-level `content`.
if (typeof (parsed as Record<string, unknown>)?.content === "string") {
return (parsed as Record<string, unknown>).content as string;
}
@@ -196,10 +260,8 @@ async function extractText(res: Response): Promise<string> {
/* fall through to SSE / raw */
}
}
// SSE or wrapped stream → concat data: content deltas (OpenAI + Anthropic).
const sse = concatSseText(raw);
if (sse) return sse;
// Last resort: maybe the body itself is plain prose.
return trimmed.length > 0 && !trimmed.startsWith("data:") ? trimmed : "";
}
@@ -250,13 +312,10 @@ function concatSseText(sse: string): string {
// Anthropic Messages streaming delta shapes.
const delta = json?.delta as Record<string, unknown> | undefined;
if (delta && typeof delta.text === "string") {
// content_block_delta: { delta: { type: "text_delta", text: "..." } }
out.push(String(delta.text));
} else if (delta && typeof delta.content === "string") {
// proxy shape: { delta: { content: "..." } }
out.push(String(delta.content));
} else if (typeof json?.content === "string") {
// top-level content token (rare, non-standard)
out.push(String(json.content));
}
} catch {
@@ -294,6 +353,7 @@ export async function handleChaosChat(opts: {
const { body, models, handleSingleModel, log, comboName, primaryModel, tuning } = opts;
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs;
const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel;
if (panel.length === 0) {
return errorResponse(400, "Chaos combo has no models");
}
@@ -303,12 +363,12 @@ export async function handleChaosChat(opts: {
return handleSingleModel(body, panel[0]);
}
const chunkId = `chaos-${comboName ?? "panel"}`;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
let closed = false;
// Serialize enqueues: ReadableStream controllers must not be enqueued
// concurrently. A committed promise chain gives us ordered, race-free writes.
let enqueueChain: Promise<void> = Promise.resolve();
const safeEnqueue = (s: string): Promise<void> => {
enqueueChain = enqueueChain.then(() => {
@@ -322,83 +382,56 @@ export async function handleChaosChat(opts: {
return enqueueChain;
};
const abortControllers: AbortController[] = [];
const modelPromises = panel.map((model, index) => {
const ctrl = new AbortController();
return withTimeout(
(async (): Promise<ChaosPart> => {
try {
const res = await handleSingleModel(body, model, {
modelAbortSignal: ctrl.signal,
});
const text = await extractText(res);
const part: ChaosPart = { model, index, ok: true, text };
// Progressive: emit this part the instant its model lands.
await safeEnqueue(serializeChaosPart(part, false));
return part;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const part: ChaosPart = { model, index, ok: false, text: "", error: msg };
await safeEnqueue(serializeChaosPart(part, false));
return part;
}
})(),
abortControllers.push(ctrl);
return dispatchOnePanelModel({
body,
model,
index,
handleSingleModel,
ctrl,
hardTimeout,
{ model, index, ok: false, text: "", error: "chaos-panel-timeout" } as ChaosPart,
() => ctrl.abort()
);
log,
onResult: async (part) => {
await safeEnqueue(serializeChaosPart(part, false));
},
});
});
const allParts = await Promise.all(modelPromises);
const successes = allParts.filter((p) => p.ok);
// Clean up all abort controllers to release references.
for (const ac of abortControllers) {
if (!ac.signal.aborted) ac.abort();
}
if (successes.length === 0) {
// No panel model answered — terminate with an error final chunk.
const errText = "All chaos panel models failed";
await safeEnqueue(
`data: ${JSON.stringify({
id: `chaos-${comboName ?? "panel"}`,
object: "chat.completion.chunk",
model: panel[0],
choices: [
{
index: 0,
delta: { role: "assistant", content: errText },
finish_reason: "stop",
},
],
})}\n\n`
);
await safeEnqueue("data: [DONE]\n\n");
await safeEnqueue(chatChunk(chunkId, panel[0], errText));
await safeEnqueue(SSE_DONE);
await enqueueChain;
closed = true;
controller.close();
return;
}
// Choose the primary: explicit primaryModel if it succeeded, else the last
// successful part (by construction that's the top-scored stable model).
// If fewer than minPanel succeeded, still return the best we have.
// The primary is the explicit primaryModel if it succeeded, else the
// last successful part (by construction that's the top-scored stable model).
const primaryPart =
(primaryModel && allParts.find((p) => p.model === primaryModel && p.ok)) ||
allParts.filter((p) => p.ok).slice(-1)[0] ||
successes[0];
// Final canonical answer (non-aware clients consume this).
const finalText = primaryPart?.text ?? "";
await safeEnqueue(
`data: ${JSON.stringify({
id: `chaos-${comboName ?? "panel"}`,
object: "chat.completion.chunk",
model: primaryPart?.model ?? panel[0],
choices: [
{
index: 0,
delta: { role: "assistant", content: finalText },
finish_reason: "stop",
},
],
})}\n\n`
chatChunk(chunkId, primaryPart?.model ?? panel[0], primaryPart?.text ?? "")
);
await safeEnqueue("data: [DONE]\n\n");
await safeEnqueue(SSE_DONE);
await enqueueChain;
closed = true;
controller.close();
@@ -413,7 +446,7 @@ export async function handleChaosChat(opts: {
Connection: "keep-alive",
"X-OmniRoute-Chaos": "true",
"X-OmniRoute-Chaos-Panel": String(panel.length),
"X-OmniRoute-Chaos-Primary": "",
"X-OmniRoute-Chaos-Primary": primaryModel ?? "",
},
});
}
@@ -444,7 +477,7 @@ export function dispatchChaosFromCombo(args: {
) {
return null;
}
const chaosCfg = cfg.chaos as { panelSize?: number; judgeModel?: string };
const chaosCfg = cfg.chaos as { panelSize?: number; judgeModel?: string; tuning?: ChaosTuning };
const chaosModels = (comboModels || [])
.map((m) => {
if (typeof m === "string") return m;
@@ -455,13 +488,17 @@ export function dispatchChaosFromCombo(args: {
return null;
})
.filter((m): m is string => Boolean(m));
log.info("CHAOS", `dispatching parallel panel of ${chaosModels.length} stable models`);
// Enforce minPanel: if configured and pool < minPanel, degrades to single model.
const minPanel = chaosCfg.tuning?.minPanel ?? 1;
const effectiveModels = chaosModels.length >= minPanel ? chaosModels : chaosModels.slice(0, 1);
log.info("CHAOS", `dispatching parallel panel of ${effectiveModels.length} stable models`);
return handleChaosChat({
body,
models: chaosModels,
models: effectiveModels,
handleSingleModel,
log,
comboName,
primaryModel: chaosCfg.judgeModel,
tuning: chaosCfg.tuning,
});
}

View File

@@ -88,16 +88,19 @@ export const MODE_PACKS: Record<string, ScoringWeights> = {
resetWindowAffinity: 0,
connectionDensity: 0.05,
},
// Chaos mode — priority: health > stability > taskFit > quota diversity.
// Chaos mode — priority: health > stability > taskFit > latency.
// Selects top-N healthy providers for parallel dispatch. Favors providers with
// closed circuit breakers, low latency variance, and high task fitness.
// quota weight reduced (chaos fans out in parallel, quota diversity is secondary
// to picking the most stable providers); connectionDensity boosted slightly to
// prefer providers with multiple accounts (more resilient to per-account rate limits).
"chaos-mode": {
quota: 0.1,
health: 0.4,
quota: 0.05,
health: 0.42,
costInv: 0.02,
latencyInv: 0.03,
taskFit: 0.2,
stability: 0.15,
stability: 0.18,
tierPriority: 0.02,
tierAffinity: 0,
specificityMatch: 0,

View File

@@ -16,6 +16,7 @@ import {
type AutoCategory,
type AutoTier,
} from "./suffixComposition";
import type { AutoVariant } from "./autoPrefix";
import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
@@ -570,11 +571,35 @@ export async function createVirtualAutoCombo(
routerStrategy,
};
// Chaos mode fans out to the top-N most stable models in parallel. We cap the
// panel size so a single IDE request doesn't fan out to dozens of providers.
// Chaos mode fans out to the top-N most stable models in parallel. Panel size
// is capped to keep a single IDE request from fanning out to dozens of providers;
// operators can override via env var OMNIROUTE_CHAOS_MAX_PANEL (default 5).
//
// Provider diversity: when multiple candidates from the same provider exist, only
// the highest-scored model per provider is included. This prevents a single
// provider from monopolizing the panel and gives the IDE truly diverse answers.
const isChaos = variant === "chaos";
const CHAOS_MAX_PANEL = 5;
const chaosModels = isChaos ? models.slice(0, CHAOS_MAX_PANEL) : models;
const CHAOS_MAX_PANEL = (() => {
const env = process.env.OMNIROUTE_CHAOS_MAX_PANEL;
const parsed = env ? parseInt(env, 10) : 5;
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : 5;
})();
let chaosModels: typeof models;
if (isChaos) {
// Deduplicate by provider: keep first occurrence per provider (models are
// already scored/sorted by health + stability from scoring).
const seenProviders = new Set<string>();
const diverse: typeof models = [];
for (const m of models) {
if (seenProviders.has(m.providerId)) continue;
seenProviders.add(m.providerId);
diverse.push(m);
if (diverse.length >= CHAOS_MAX_PANEL) break;
}
chaosModels = diverse.length > 0 ? diverse : models.slice(0, CHAOS_MAX_PANEL);
} else {
chaosModels = models;
}
const advertisedLimits = computeAdvertisedLimits(effectivePool);
@@ -582,7 +607,7 @@ export async function createVirtualAutoCombo(
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto",
strategy: isChaos ? "fusion" : "auto",
strategy: "auto",
models: chaosModels,
candidatePool: providerPool,
weights,
@@ -599,6 +624,11 @@ export async function createVirtualAutoCombo(
enabled: true,
panelSize: chaosModels.length,
judgeModel: chaosModels[0]?.model,
tuning: {
panelHardTimeoutMs:
Number(process.env.OMNIROUTE_CHAOS_PANEL_TIMEOUT_MS) || undefined,
minPanel: Number(process.env.OMNIROUTE_CHAOS_MIN_PANEL) || undefined,
},
},
}
: {}),

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,12 @@
/**
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
* browserPool.ts — core stub that delegates to @omniroute/browser-pool.
*
* The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both
* validate values that only a real browser can produce (DOM layout
* measurements like offsetWidth/Height, getBoundingClientRect,
* getComputedStyle, iframe contentWindow probes). Plain Node fetch + a
* VM-stubs solver structurally runs the JS but cannot match those values,
* so the server rejects the request.
* Copyright 2025 OmniRoute. All rights reserved.
*
* This pool keeps one Chromium instance warm and serves "browser contexts"
* (one per caller-defined isolation key) on demand. Each context owns one or more pages; the
* caller is expected to be polite (one page per request, close on done).
*
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
* fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and
* dozens more) are the only thing that gets past DuckDuckGo's anti-bot
* in this environment. Falls back to plain `playwright` if cloakbrowser
* is not installed; the fallback works for Claude web (which only needs
* valid cookies) but not for DDG's VQD challenge.
*
* Opt-in: pool only launches Chromium when an executor explicitly asks
* for a context, so users who never use the browser-backed path pay zero
* startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable.
* Lightweight functions (resolvePlaywrightProxy, getBrowserPoolStatus)
* remain inline to avoid forcing an async package load for trivial lookups.
* All heavy browser-interaction functions are forwarded to the optional
* @omniroute/browser-pool package via dynamic import.
*/
import { Buffer } from "node:buffer";
@@ -30,6 +15,12 @@ type Browser = import("playwright").Browser;
type BrowserContext = import("playwright").BrowserContext;
type Page = import("playwright").Page;
// ---------------------------------------------------------------------------
// Types — re-exported so callers never import from @omniroute/browser-pool
// directly. When the package is installed these would come from there, but by
// keeping local definitions both the stub and the package agree on shape.
// ---------------------------------------------------------------------------
export interface BrowserPoolContextOptions {
cookieDomain: string;
cookieString?: string | null;
@@ -49,10 +40,6 @@ export interface PooledContext {
isStealth: boolean;
}
// #3368 PR7 — lightweight, cumulative browser-pool telemetry. Counters are
// incremented at lifecycle points and surfaced via getBrowserPoolMetrics()
// (and the omniroute_browser_pool_status MCP tool), giving the previously
// caller-less getBrowserPoolStatus() an observability home.
export interface BrowserPoolMetrics {
browserLaunches: number;
browserLaunchFailures: number;
@@ -65,111 +52,9 @@ export interface BrowserPoolMetrics {
lastShutdownReason: string | null;
}
function createBrowserPoolMetrics(): BrowserPoolMetrics {
return {
browserLaunches: 0,
browserLaunchFailures: 0,
contextsCreated: 0,
contextsReused: 0,
contextsEvicted: 0,
contextsReleased: 0,
contextCreateFailures: 0,
shutdowns: 0,
lastShutdownReason: null,
};
}
interface PoolState {
browser: Browser | null;
contexts: Map<string, PooledContext>;
pendingContexts: Map<string, Promise<PooledContext>>;
launching: Promise<Browser> | null;
lastActivity: number;
idleTimer: NodeJS.Timeout | null;
evictTimer: NodeJS.Timeout | null;
cloakLaunch: ((opts: unknown) => Promise<Browser>) | null;
cloakLaunchResolved: boolean;
metrics: BrowserPoolMetrics;
}
const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts
const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const state: PoolState = {
browser: null,
contexts: new Map(),
pendingContexts: new Map(),
launching: null,
lastActivity: 0,
idleTimer: null,
evictTimer: null,
cloakLaunch: null,
cloakLaunchResolved: false,
metrics: createBrowserPoolMetrics(),
};
function getCloakbrowserModuleId(): string {
// Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal
// dynamic import with the package name makes Turbopack resolve it during route compilation.
return ["cloak", "browser"].join("");
}
async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> {
if (state.cloakLaunchResolved) return state.cloakLaunch;
state.cloakLaunchResolved = true;
try {
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
launch?: (opts: unknown) => Promise<Browser>;
};
state.cloakLaunch = mod.launch ?? null;
} catch {
state.cloakLaunch = null;
}
return state.cloakLaunch;
}
function isPoolEnabled(): boolean {
const flag = process.env.OMNIROUTE_BROWSER_POOL;
if (flag === undefined) return true;
return flag !== "off" && flag !== "0" && flag !== "false";
}
function resetIdleTimer(): void {
if (state.idleTimer) clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => {
void shutdownPool("idle-timeout");
}, POOL_IDLE_TIMEOUT_MS);
state.idleTimer.unref?.();
}
function evictStaleContexts(): void {
const now = Date.now();
for (const [key, pooled] of state.contexts) {
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
console.log(
"[BrowserPool] Evicted stale context",
"(idle",
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
);
state.contexts.delete(key);
state.metrics.contextsEvicted++;
pooled.context.close().catch(() => {});
}
}
if (state.contexts.size === 0 && !state.launching) {
void shutdownPool("all-contexts-evicted");
}
}
function startEvictTimer(): void {
if (state.evictTimer) clearInterval(state.evictTimer);
state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS);
state.evictTimer.unref?.();
}
// ---------------------------------------------------------------------------
// Proxy resolver types — local to resolvePlaywrightProxy
// ---------------------------------------------------------------------------
interface ProxyRecord {
type?: string;
host: string;
@@ -182,7 +67,54 @@ interface ResolvePlaywrightProxyDeps {
resolveProxy?: (providerId: string) => Promise<ProxyRecord | null | undefined>;
}
// Exported for tests (deps injection avoids mock.module()).
// ---------------------------------------------------------------------------
// Dynamic import rationale: we keep the import URL as a computed string so
// that bundlers (Turbopack / webpack / esbuild) do NOT statically resolve
// it during route compilation. If a literal dynamic import of the module
// appeared here, Turbopack resolve it during route compilation and error out.
// By shunting through a join pattern the bundler never sees the literal
// dependency path and leaves the import as a true runtime dynamic import.
// return ["cloak", "browser"].join("");
// ---------------------------------------------------------------------------
/**
* Resolve the cloakbrowser module name without a literal string —
* prevents Turbopack from resolving it at build time.
*/
function resolveCloakBrowserModule(): string {
return ["cloak", "browser"].join("");
}
// ---------------------------------------------------------------------------
// Dynamic import — eager; load starts on module import.
// setProxyResolver is called once the module resolves, so any subsequent
// acquireBrowserContext call will have the resolver wired in.
// ---------------------------------------------------------------------------
let modPromise: Promise<typeof import("@omniroute/browser-pool")> | null = null;
function getMod(): Promise<typeof import("@omniroute/browser-pool")> {
if (!modPromise) {
modPromise = import("@omniroute/browser-pool").then((mod) => {
mod.setProxyResolver(resolvePlaywrightProxy);
return mod;
});
}
return modPromise;
}
// ---------------------------------------------------------------------------
// resolvePlaywrightProxy — INLINE (no Playwright dependency at runtime)
// ---------------------------------------------------------------------------
/**
* Resolve a Playwright-compatible proxy config for a given provider key.
* Looks up the proxy from the database via resolveProxyForProvider, then
* builds the { server, username?, password? } object that Playwright expects.
*
* May be injected into @omniroute/browser-pool via setProxyResolver so
* that acquireBrowserContext uses the same proxy resolution as the rest
* of the application.
*/
export async function resolvePlaywrightProxy(
providerKey: string,
deps?: ResolvePlaywrightProxyDeps
@@ -214,6 +146,13 @@ export async function resolvePlaywrightProxy(
}
}
/**
* Resolve the proxy for a browser-pool context key. Scoped context keys
* (e.g. "claude-web:account-scope") carry a stable `proxyProviderKey` so the
* proxy lookup always targets the underlying provider, not the scoped key.
* Kept inline alongside resolvePlaywrightProxy — trivial wrapper, no
* playwright/browser-pool dependency.
*/
export async function resolveBrowserContextProxy(
contextKey: string,
options: Pick<BrowserPoolContextOptions, "proxyProviderKey">,
@@ -222,251 +161,14 @@ export async function resolveBrowserContextProxy(
return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps);
}
async function launchBrowser(): Promise<Browser> {
if (state.browser) return state.browser;
if (state.launching) return state.launching;
state.launching = (async () => {
const cloakLaunch = await resolveCloakLaunch();
let browser: Browser;
if (cloakLaunch) {
browser = await cloakLaunch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
} else {
// Fallback: plain Playwright. Works for Claude web (cookie-only
// auth) but DDG's VQD challenge will detect this Chromium build.
const { chromium } = await import("playwright");
browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
],
});
}
state.browser = browser;
state.launching = null;
state.metrics.browserLaunches++;
return browser;
})();
try {
return await state.launching;
} catch (err) {
state.launching = null;
state.metrics.browserLaunchFailures++;
throw err;
}
}
function parseCookieString(
raw: string,
domain: string
): Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}> {
return raw
.split(";")
.map((p) => p.trim())
.filter(Boolean)
.map((pair) => {
const eq = pair.indexOf("=");
if (eq < 0) return null;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (!name || !value) return null;
return {
name,
value,
domain: domain.startsWith(".") ? domain : `.${domain}`,
path: "/",
expires: -1,
httpOnly: false,
secure: true,
sameSite: "Lax" as const,
};
})
.filter(Boolean) as Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}>;
}
// Clear a key from the pending-creation map once its promise settles, counting
// failures. Kept as a leaf helper so acquireBrowserContext stays under the
// function-length ceiling (#3368 PR7 metrics).
function settlePendingContext(key: string, failed: boolean): void {
if (failed) state.metrics.contextCreateFailures++;
state.pendingContexts.delete(key);
}
export async function acquireBrowserContext(
key: string,
options: BrowserPoolContextOptions
): Promise<PooledContext> {
if (!isPoolEnabled()) {
throw new Error(
"browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled"
);
}
const existing = state.contexts.get(key);
if (existing) {
existing.lastUsed = Date.now();
state.lastActivity = Date.now();
state.metrics.contextsReused++;
resetIdleTimer();
return existing;
}
// Dedup concurrent creations for the same key
const pending = state.pendingContexts.get(key);
if (pending) return pending;
const createPromise = (async (): Promise<PooledContext> => {
const [browser, proxy] = await Promise.all([
launchBrowser(),
resolveBrowserContextProxy(key, options),
]);
const isStealth = state.cloakLaunch !== null;
const context = await browser.newContext({
userAgent: options.userAgent || DEFAULT_USER_AGENT,
locale: options.locale || "en-US",
timezoneId: options.timezone || "America/New_York",
viewport: { width: 1280, height: 800 },
...(proxy ? { proxy } : {}),
});
if (options.cookieString) {
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
if (cookies.length > 0) {
await context.addCookies(cookies);
}
}
let warmupPage: Page | null = null;
if (options.warmupUrl) {
try {
warmupPage = await context.newPage();
await warmupPage.goto(options.warmupUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
// Give the warmup a moment for the upstream's status/auth/country
// JSON endpoints to fire. Without this, the first chat request would
// pay the warmup cost on the hot path.
await new Promise((r) => setTimeout(r, 1500));
} catch (err) {
try {
await warmupPage?.close();
} catch {
/* ignore */
}
warmupPage = null;
void err;
}
}
// Guard: if shutdownPool() ran while we were creating this context,
// the browser we obtained is now closed. Close our temp context and
// throw so the caller knows to retry.
if (state.browser !== browser) {
await context.close().catch(() => {});
if (warmupPage) {
await warmupPage.close().catch(() => {});
}
throw new Error("Pool shut down during context creation");
}
const pooled: PooledContext = {
id: key,
context,
warmupPage,
lastUsed: Date.now(),
isStealth,
};
state.contexts.set(key, pooled);
state.metrics.contextsCreated++;
state.lastActivity = Date.now();
resetIdleTimer();
startEvictTimer();
return pooled;
})();
state.pendingContexts.set(key, createPromise);
createPromise
.then(() => settlePendingContext(key, false))
.catch(() => settlePendingContext(key, true));
return createPromise;
}
export async function openPage(pooled: PooledContext): Promise<Page> {
return pooled.context.newPage();
}
export async function releaseBrowserContext(key: string): Promise<void> {
const pooled = state.contexts.get(key);
if (!pooled) return;
state.contexts.delete(key);
state.metrics.contextsReleased++;
try {
await pooled.context.close();
} catch {
/* ignore */
}
if (state.contexts.size === 0) {
await shutdownPool("last-context-closed");
}
}
export async function shutdownPool(reason: string): Promise<void> {
state.metrics.shutdowns++;
state.metrics.lastShutdownReason = reason;
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
if (state.evictTimer) {
clearInterval(state.evictTimer);
state.evictTimer = null;
}
state.pendingContexts.clear();
for (const [key, pooled] of state.contexts) {
try {
await pooled.context.close();
} catch {
/* ignore */
}
state.contexts.delete(key);
}
if (state.browser) {
try {
await state.browser.close();
} catch {
/* ignore */
}
state.browser = null;
}
state.lastActivity = Date.now();
// Avoid unused-parameter lint: log reason via debug if anyone hooks
// process.on('exit') and prints state.
void reason;
}
// ---------------------------------------------------------------------------
// getBrowserPoolStatus — INLINE (returns disabled status)
// ---------------------------------------------------------------------------
/**
* Return the current status of the browser pool. In the core stub, the pool
* is always reported as disabled — the real pool lives inside the optional
* @omniroute/browser-pool package.
*/
export function getBrowserPoolStatus(): {
enabled: boolean;
contexts: number;
@@ -475,38 +177,71 @@ export function getBrowserPoolStatus(): {
lastActivityAgoMs: number;
} {
return {
enabled: isPoolEnabled(),
contexts: state.contexts.size,
browserRunning: state.browser !== null,
stealthAvailable: state.cloakLaunch !== null,
lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity,
enabled: false,
contexts: 0,
browserRunning: false,
stealthAvailable: false,
lastActivityAgoMs: -1,
};
}
// ---------------------------------------------------------------------------
// Delegated functions — forwarded to @omniroute/browser-pool
// ---------------------------------------------------------------------------
export async function acquireBrowserContext(
key: string,
options: BrowserPoolContextOptions
): Promise<PooledContext> {
const mod = await getMod();
return mod.acquireBrowserContext(key, options);
}
/**
* #3368 PR7 — browser-pool observability. Returns live status plus cumulative
* lifecycle telemetry (launches, context create/reuse/evict/release counts,
* failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool.
* #3368 PR7 — browser-pool observability. Returns disabled status and empty
* metrics. Callers get a snapshot; the real pool state lives inside the
* optional @omniroute/browser-pool package.
*/
export function getBrowserPoolMetrics(): {
status: ReturnType<typeof getBrowserPoolStatus>;
metrics: BrowserPoolMetrics;
} {
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
return { status: getBrowserPoolStatus(), metrics: createEmptyMetrics() };
}
function createEmptyMetrics(): BrowserPoolMetrics {
return {
browserLaunches: 0,
browserLaunchFailures: 0,
contextsCreated: 0,
contextsReused: 0,
contextsEvicted: 0,
contextsReleased: 0,
contextCreateFailures: 0,
shutdowns: 0,
lastShutdownReason: null,
};
}
export async function releaseBrowserContext(key: string): Promise<void> {
const mod = await getMod();
return mod.releaseBrowserContext(key);
}
export async function shutdownPool(reason: string): Promise<void> {
const mod = await getMod();
return mod.shutdownPool(reason);
}
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
export function __resetBrowserPoolMetricsForTest(): void {
state.metrics = createBrowserPoolMetrics();
export async function __resetBrowserPoolMetricsForTest(): Promise<void> {
const mod = await getMod();
return mod.__resetBrowserPoolMetricsForTest();
}
export async function readPageResponseBody(
response: import("playwright").Response
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(response.headers())) {
headers[name] = value;
}
const body = await response.body();
return { status: response.status(), headers, body: Buffer.from(body) };
const mod = await getMod();
return mod.readPageResponseBody(response);
}

View File

@@ -147,7 +147,7 @@ import {
waitForCooldownAwareRetry,
} from "../../src/sse/services/cooldownAwareRetry.ts";
import { handleFusionChat, type FusionTuning } from "./fusion.ts";
import { dispatchChaosFromCombo } from "./autoCombo/chaosEngine.ts";
import { dispatchChaosFromCombo, type ChaosTuning } from "./autoCombo/chaosEngine.ts";
import { handlePipelineChat, type PipelineStep } from "./pipeline.ts";
import {
TRANSIENT_FOR_SEMAPHORE,
@@ -918,7 +918,7 @@ export async function handleComboChat({
});
if (chaosDispatch) return chaosDispatch;
// Pipeline strategy: sequential chain — each step's output feeds the next step's
// Pipeline strategy: sequential chain
// input, only the final step's response is returned. Handled in a separate module
// because it neither iterates targets as fallbacks nor needs the failover/retry
// machinery below — it runs targets in order, threading output → input. The step

View File

@@ -65,39 +65,34 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
description:
"Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff.",
levels: {
lite: `# Ponytail, lazy senior dev mode (lite)
Before writing any code: does it need to exist? Does it already exist here? Does the stdlib/installed dep cover it? Only then: write the minimum. Reuse over rewrite. ${SHARED_BOUNDARIES}`,
full: `# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two solutions tie. ${SHARED_BOUNDARIES}`,
ultra: `# Ponytail, lazy senior dev mode (ultra)
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written. Before any code: YAGNI → reuse → stdlib → platform feature → installed dep → one line → minimum that works. Fix root cause, not symptom: grep every caller, patch the shared function once. No unrequested abstractions. No new dependency. No boilerplate. Deletion over addition. Fewest files. Shortest working diff, only after you understand the problem. Question complex asks. Edge-case-correct when tied. ${SHARED_BOUNDARIES}`,
lite: `# Ponytail (lite)\nBefore writing code: does it need to exist? Does it already exist here? Does the stdlib or an installed dep cover it? Only then: write the minimum. Reuse over rewrite. ${SHARED_BOUNDARIES}`,
full: `# Ponytail — lazy senior dev\n\nYou are a lazy senior developer. Lazy = efficient, not careless. The best code is the code never written.\n\nBefore writing any code, stop at the first rung that holds:\n1. Does this need to exist? (YAGNI)\n2. Does it already exist in this codebase? Reuse it.\n3. Does the stdlib do this? Use it.\n4. Does a platform feature or installed dep cover it? Use it.\n5. Can it be one line? Make it one line.\n6. Only then: write the minimum that works.\n\nBug fix = root cause, not symptom. Grep every caller of the function you touch; fix the shared function once — one guard there is a smaller diff than one per caller.\n\nRules:\n- No unrequested abstractions. No new deps. No boilerplate.\n- Deletion over addition. Boring over clever. Fewest files.\n- Shortest working diff wins — but only after you understand the problem.\n- Question complex asks: "Do you need X, or does Y cover it?"\n- When two solutions tie, pick the edge-case-correct one. ${SHARED_BOUNDARIES}`,
ultra: `# Ponytail (ultra)\nLazy senior dev. Best code = code never written. Before any code: YAGNI → reuse → stdlib → platform → installed dep → one line → minimum that works. Fix root cause not symptom: grep every caller, patch shared function once. No unrequested abstractions, no new deps, no boilerplate. Deletion > addition. Fewest files. Shortest working diff, only after understanding the problem. Question complex asks. Edge-case-correct when tied. ${SHARED_BOUNDARIES}`,
},
// i18n maps: localized ponytail prompts by language.
// Each captures the same YAGNI ladder + root-cause discipline in the target
// language's dev-community vernacular.
i18n: {
"pt-BR": {
lite: `# Ponytail (lite)\nAntes de escrever código: ele precisa existir? Já existe aqui? A stdlib ou uma dep já instalada cobre? Só então: escreva o mínimo. Reutilize em vez de reescrever. ${SHARED_BOUNDARIES}`,
full: `# Ponytail — dev sênior preguiçoso\n\nVocê é um dev sênior preguiçoso. Preguiçoso = eficiente, não descuidado. O melhor código é o código nunca escrito.\n\nAntes de escrever qualquer código, pare no primeiro degrau que segurar:\n1. Isso precisa existir? (YAGNI)\n2. Já existe nesse codebase? Reutilize.\n3. A stdlib faz isso? Use.\n4. Uma feature da plataforma ou dep instalada cobre? Use.\n5. Dá pra fazer em uma linha? Faça em uma.\n6. Só então: escreva o mínimo que funciona.\n\nBug fix = causa raiz, não sintoma. Grep em todos os callers da função; corrija a função compartilhada uma vez — um guard ali é um diff menor que um por caller.\n\nRegras:\n- Sem abstrações não solicitadas. Sem novas deps. Sem boilerplate.\n- Deleção > adição. Tedioso > engenhoso. Menos arquivos.\n- Menor diff funcional vence — mas só depois de entender o problema.\n- Questione pedidos complexos: "Você precisa de X, ou Y cobre?"\n- Em empate técnico, escolha o correto para edge-cases. ${SHARED_BOUNDARIES}`,
ultra: `# Ponytail (ultra)\nDev sênior preguiçoso. Melhor código = nunca escrito. Antes de código: YAGNI → reuso → stdlib → plataforma → dep instalada → uma linha → mínimo que funciona. Corrige causa raiz, não sintoma: grep todo caller, corrige função compartilhada uma vez. Sem abstrações não solicitadas, sem deps novas, sem boilerplate. Deleção > adição. Menos arquivos. Menor diff, só depois de entender o problema. Questione pedidos complexos. Correto para edge-cases em empate. ${SHARED_BOUNDARIES}`,
},
vi: {
lite: `# Ponytail (lite)\nTrước khi viết code: có thực sự cần không? Đã có ở đây chưa? Thư viện chuẩn hoặc dep có sẵn giải quyết được không? Chỉ khi không: viết tối thiểu. Dùng lại hơn viết mới. ${SHARED_BOUNDARIES}`,
full: `# Ponytail — dev già lười\n\nBạn là một senior dev lười. Lười = hiệu quả, không cẩu thả. Code tốt nhất là code không bao giờ viết.\n\nTrước khi viết, dừng ở nấc thang đầu tiên đúng:\n1. Có thực sự cần? (YAGNI)\n2. Đã có trong codebase? Dùng lại.\n3. Thư viện chuẩn làm được? Dùng nó.\n4. Platform hoặc dep có sẵn đáp ứng? Dùng nó.\n5. Có thể một dòng? Làm một dòng.\n6. Chỉ khi không: viết tối thiểu.\n\nSửa lỗi = căn nguyên, không triệu chứng. Grep mọi caller của hàm bạn sửa; sửa hàm chung một lần — một guard ở đó nhỏ hơn một guard mỗi caller.\n\nLuật:\n- Không abstraction không được yêu cầu. Không dep mới. Không boilerplate.\n- Xoá > thêm. Đơn giản > khéo léo. Ít file nhất.\n- Diff ngắn nhất thắng — nhưng chỉ sau khi hiểu vấn đề.\n- Hỏi lại yêu cầu phức tạp: "Bạn cần X, hay Y đủ?"\n- Khi hai giải pháp hoà, chọn cái đúng edge-case. ${SHARED_BOUNDARIES}`,
ultra: `# Ponytail (ultra)\nDev già lười. Code tốt nhất = không viết. Trước code: YAGNI → dùng lại → stdlib → platform → dep → một dòng → tối thiểu. Sửa căn nguyên, không triệu chứng: grep mọi caller, sửa hàm chung một lần. Không abstraction lạ, không dep mới, không boilerplate. Xoá > thêm. Ít file nhất. Diff ngắn nhất, chỉ sau khi hiểu vấn đề. Hỏi lại yêu cầu phức tạp. Edge-case-correct khi hoà. ${SHARED_BOUNDARIES}`,
},
ja: {
lite: `# Ponytail軽量\nコードを書く前に本当に必要か既にここに存在するか標準ライブラリやインストール済み依存でカバーできるかそれから初めて最小限を書く。再利用書き直し。${SHARED_BOUNDARIES}`,
full: `# Ponytail — 怠惰なシニア開発者\n\nあなたは怠惰なシニア開発者です。怠惰効率的、不注意ではない。最高のコードは書かれなかったコードです。\n\nコードを書く前に、最初の段階で止まれ\n1. これ必要かYAGNI\n2. コードベースに既にあるか?再利用。\n3. 標準ライブラリでできるか?使え。\n4. プラットフォーム機能やインストール済み依存でカバー?使え。\n5. 一行でできるか?一行に。\n6. それから初めて:動く最小限。\n\nバグ修正根本原因、症状ではない。触る関数の全呼び出し箇所をgrep共有関数を一箇所修正 — そこに1つのguardが呼び出し元ごとにguardを置くより小さい。\n\nルール\n- 要求されていない抽象化は禁止。新しい依存も禁止。ボイラープレートも禁止。\n- 削除>追加。地味>巧妙。最小ファイル数。\n- 最短の動くdiffが勝ち — ただし問題を理解した後に限る。\n- 複雑な要求に疑問を「Xが必要ですか、それともYで足りますか\n- 解決策が同点の時は、エッジケースでも正しい方を選べ。${SHARED_BOUNDARIES}`,
ultra: `# Ponytail超重量\n怠惰なシニア開発者。最高のコード書かれなかったもの。コードの前YAGNI→再利用→std→platform→依存→一行→最小限。根本原因修正、症状じゃない全callerをgrep、共有関数を一箇所修正。不要な抽象化禁止、新しい依存禁止、ボイラープレート禁止。削除追加。最小ファイル数。最短diff、問題理解後に限る。複雑要求に疑問。同点時はedge-case正解。${SHARED_BOUNDARIES}`,
},
id: {
lite: `# Ponytail (lite)\nSebelum menulis kode: apakah perlu? Sudah ada di sini? Stdlib atau dep terinstal mencakup? Baru tulis minimal. Pakai ulang daripada tulis ulang. ${SHARED_BOUNDARIES}`,
full: `# Ponytail — dev senior malas\n\nKamu adalah senior developer yang malas. Malas = efisien, bukan ceroboh. Kode terbaik adalah kode yang tidak pernah ditulis.\n\nSebelum menulis kode, berhenti di anak tangga pertama yang tepat:\n1. Apakah ini perlu? (YAGNI)\n2. Sudah ada di codebase? Pakai ulang.\n3. Stdlib melakukan ini? Pakai.\n4. Fitur platform atau dep terinstal mencakup? Pakai.\n5. Bisa satu baris? Buat satu baris.\n6. Baru tulis minimum yang bekerja.\n\nPerbaiki bug = akar masalah, bukan gejala. Grep semua pemanggil fungsi yang disentuh; perbaiki fungsi bersama sekali — satu guard di sana lebih kecil daripada satu guard per pemanggil.\n\nAturan:\n- Tanpa abstraksi yang tidak diminta. Tanpa dep baru. Tanpa boilerplate.\n- Hapus > tambah. Membosankan > cerdas. Paling sedikit file.\n- Diff terpendek menang — tapi hanya setelah paham masalah.\n- Tanyai permintaan kompleks: "Kamu perlu X, atau Y mencakup?"\n- Saat dua solusi imbang, pilih yang benar untuk edge-case. ${SHARED_BOUNDARIES}`,
ultra: `# Ponytail (ultra)\nDev senior malas. Kode terbaik = tak pernah ditulis. Sebelum kode: YAGNI → pakai ulang → stdlib → platform → dep → satu baris → minimum. Perbaiki akar, bukan gejala: grep semua caller, perbaiki fungsi bersama sekali. Tanpa abstraksi tak diminta, tanpa dep baru, tanpa boilerplate. Hapus > tambah. Paling sedikit file. Diff terpendek, hanya setelah paham masalah. Tanya permintaan kompleks. Edge-case benar saat imbang. ${SHARED_BOUNDARIES}`,
},
},
},
"terse-cjk": {

View File

@@ -1,35 +1,3 @@
/**
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
* grok-web (#8019).
*
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
* clearance from a datacenter egress that Cloudflare has already flagged —
* only a real browser solving the challenge natively can mint one bound to
* that egress's own fingerprint.
*
* This module reuses the EXISTING provider-agnostic browser pool
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
* claude.ai-specific and does not apply here.
*
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
* With the gate off, `acquireFreshGrokClearance` is never called — the
* executor stays on the Step-1 `cloudflare_challenge` classification.
*/
import { acquireBrowserContext, type PooledContext } from "./browserPool.ts";
const GROK_WARMUP_URL = "https://grok.com/";
const GROK_COOKIE_DOMAIN = ".grok.com";
const GROK_POOL_KEY = "grok-web";
/**
* Reads the same opt-in gate as claude-web/duckduckgo-web
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
*/
export function shouldUseGrokBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
@@ -37,48 +5,20 @@ export function shouldUseGrokBrowserBacked(): boolean {
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
}
type AcquireGrokClearanceFn = (signal?: AbortSignal | null) => Promise<string | null>;
// Test-only injection point — mirrors browserBackedChat.ts's
// __setBrowserBackedChatOverrideForTesting pattern so unit tests can prove
// the gating/wiring without launching a real browser (no chromium in CI).
let acquireOverride: AcquireGrokClearanceFn | null = null;
let grokClearanceAcquireOverride:
((signal?: AbortSignal | null) => Promise<string | null>)
| null = null;
export function __setGrokClearanceAcquireOverrideForTesting(
fn: AcquireGrokClearanceFn | null
fn: ((signal?: AbortSignal | null) => Promise<string | null>) | null,
): void {
acquireOverride = fn;
grokClearanceAcquireOverride = fn;
}
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
const match = cookies.find((c) => c.name === "cf_clearance");
return match?.value || null;
}
async function acquireViaPool(): Promise<string | null> {
try {
const pooled = await acquireBrowserContext(GROK_POOL_KEY, {
cookieDomain: GROK_COOKIE_DOMAIN,
cookieString: null,
warmupUrl: GROK_WARMUP_URL,
});
return await readCfClearanceFromContext(pooled);
} catch {
return null;
}
}
/**
* Acquire a fresh `.grok.com` cf_clearance via the shared browser pool.
* Never throws — resolves to `null` on any failure so callers can fall
* through to the Cloudflare-challenge error rather than crash the request.
*/
export async function acquireFreshGrokClearance(signal?: AbortSignal | null): Promise<string | null> {
if (acquireOverride) return acquireOverride(signal);
try {
return await acquireViaPool();
} catch {
return null;
}
export async function acquireFreshGrokClearance(
signal?: AbortSignal | null,
): Promise<string | null> {
if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(signal);
const mod = await import("@omniroute/browser-pool");
return mod.acquireFreshGrokClearance(signal);
}

View File

@@ -243,7 +243,35 @@ export function extractNotionUpstreamError(raw: string): {
if (o) candidates.push(o);
}
for (const o of candidates) {
// Flatten nested error objects: live Notion streams often nest the error inside
// `patch-start.data.s[]` (HTTP 200 NDJSON) instead of a top-level `{type:"error"}`.
// Missing that shape used to surface as the misleading "No response from Notion AI".
const flat: Record<string, unknown>[] = [];
const pushNested = (o: Record<string, unknown>) => {
flat.push(o);
const data = o.data;
if (data && typeof data === "object" && !Array.isArray(data)) {
const s = (data as Record<string, unknown>).s;
if (Array.isArray(s)) {
for (const item of s) {
if (item && typeof item === "object" && !Array.isArray(item)) {
flat.push(item as Record<string, unknown>);
}
}
}
}
// Also walk a top-level `s` array if present on the record.
if (Array.isArray(o.s)) {
for (const item of o.s) {
if (item && typeof item === "object" && !Array.isArray(item)) {
flat.push(item as Record<string, unknown>);
}
}
}
};
for (const o of candidates) pushNested(o);
for (const o of flat) {
const type = typeof o.type === "string" ? o.type.toLowerCase() : "";
const subType = typeof o.subType === "string" ? o.subType : undefined;
const message =
@@ -257,6 +285,8 @@ export function extractNotionUpstreamError(raw: string): {
if (!isError && !subType) continue;
const sub = (subType || "").toLowerCase();
// Notion often sets isRetryable:false on temporarily-unavailable even though
// a short wait + retry succeeds (TLS/edge flake). Treat those subtypes as retryable.
const retryable =
o.isRetryable === true ||
sub.includes("temporarily") ||

View File

@@ -0,0 +1,593 @@
/**
* Browser-TLS-impersonating HTTP client for app.notion.com.
*
* Why this exists: Notion AI sits behind the same Cloudflare Enterprise
* configuration as ChatGPT — it pins access to the client's TLS fingerprint
* (JA3/JA4) + HTTP/2 SETTINGS frame ordering. Node's Undici fetch presents an
* obvious "not a browser" handshake and gets challenged with a 403 "Just a
* moment..." page from VPS/datacenter IPs — even with a valid session cookie.
* This module wraps `tls-client-node` (native shared library built from
* bogdanfinn/tls-client) to send a Firefox handshake instead. (issue #2459)
*
* Mirrors `claudeTlsClient.ts` / `perplexityTlsClient.ts`; kept as an independent module so changes here
* cannot regress the production chatgpt-web path. The first call lazily starts
* the managed sidecar; subsequent calls reuse a singleton TLSClient. Process
* exit hooks stop the sidecar cleanly.
*/
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises";
import { randomUUID } from "node:crypto";
let clientPromise: Promise<unknown> | null = null;
let exitHookInstalled = false;
const NOTION_PROFILE = "chrome_146"; // matches the Chrome UA we send
const DEFAULT_TIMEOUT_MS =
Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_TIMEOUT_MS || "", 10) || 30_000;
// Grace period added to the binding's wire-level timeout before our JS-level
// hard timeout fires. Under healthy operation `tls-client-node` honors
// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins
// when the koffi-loaded native library is wedged (which the binding's own
// timer can't escape). Keep the grace small so users don't wait noticeably
// longer than the configured timeout when the binding is dead.
const HARD_TIMEOUT_GRACE_MS =
Number.parseInt(process.env.OMNIROUTE_NOTION_TLS_GRACE_MS || "", 10) || 10_000;
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
const stop = async () => {
if (!clientPromise) return;
try {
const c = (await clientPromise) as { stop?: () => Promise<unknown> };
await c.stop?.();
} catch {
// ignore
}
};
process.once("beforeExit", stop);
process.once("SIGINT", () => {
void stop();
});
process.once("SIGTERM", () => {
void stop();
});
}
/**
* Drop the cached client so the next `getClient()` call respawns it. Called
* when a request observes the native binding has wedged — releasing the
* reference lets a fresh TLSClient (and a fresh koffi load) take over without
* a process restart.
*/
function resetClientCache(): void {
clientPromise = null;
}
export class TlsClientHangError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsClientHangError";
}
}
/**
* Race a `client.request()` promise against (a) a JS-level hard timeout and
* (b) the caller's abort signal. The native binding's `timeoutMilliseconds`
* already covers the wire path; this guards the case where the koffi binding
* itself deadlocks (observed after sustained load), where neither the
* binding's own timer nor a post-call `signal.aborted` re-check can recover.
*/
async function raceWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
signal: AbortSignal | null | undefined
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
let abortListener: (() => void) | null = null;
try {
const racers: Promise<T>[] = [
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(
new TlsClientHangError(
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
)
);
}, timeoutMs);
}),
];
if (signal) {
racers.push(
new Promise<T>((_, reject) => {
if (signal.aborted) {
reject(makeAbortError(signal));
return;
}
abortListener = () => reject(makeAbortError(signal));
signal.addEventListener("abort", abortListener, { once: true });
})
);
}
return await Promise.race(racers);
} finally {
if (timer) clearTimeout(timer);
if (signal && abortListener) signal.removeEventListener("abort", abortListener);
}
}
async function getClient(): Promise<{
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
}> {
if (!clientPromise) {
clientPromise = (async () => {
try {
const mod = await import("tls-client-node");
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => unknown })
.TLSClient;
// Native mode loads the shared library directly via koffi, avoiding the
// managed sidecar's localhost HTTP calls that OmniRoute's global fetch
// proxy patch interferes with.
const client = new TLSClient({ runtimeMode: "native" }) as {
start: () => Promise<void>;
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
};
await client.start();
installExitHook();
return client;
} catch (err) {
clientPromise = null;
const msg = err instanceof Error ? err.message : String(err);
throw new TlsClientUnavailableError(
`TLS impersonation client failed to start: ${msg}. ` +
`Verify tls-client-node is installed and its native binary downloaded.`
);
}
})();
}
return clientPromise as Promise<{
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
}>;
}
interface TlsResponseLike {
status: number;
headers: Record<string, string[]>;
body: string; // for non-streaming requests, the full response body
cookies?: Record<string, string>;
text: () => Promise<string>;
bytes: () => Promise<Uint8Array>;
json: <T = unknown>() => Promise<T>;
}
export class TlsClientUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsClientUnavailableError";
}
}
export interface TlsFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
signal?: AbortSignal | null;
/**
* If true, the response body is streamed to a temp file and exposed as a
* ReadableStream<Uint8Array>. Use for SSE responses (the runInferenceTranscript
* endpoint). Otherwise, the full body is read into memory.
*/
stream?: boolean;
/** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */
streamEofSymbol?: string;
/**
* Optional upstream proxy URL (`http://user:pass@host:port` or
* `socks5://...`). When set, the request is tunneled through this proxy
* before reaching notion.so.
*
* Resolution order:
* 1. `options.proxyUrl` (per-call override from caller)
* 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in)
* 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback)
*
* The native `tls-client-node` binding does **not** consult Go's
* `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at
* the JS layer.
*/
proxyUrl?: string;
}
import { resolveProxyForRequest } from "../utils/proxyFetch.ts";
import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts";
/**
* Resolve the proxy URL for a tls-client request. Per-call value wins;
* otherwise we use the standard proxy fetch resolution which reads from
* the dashboard AsyncLocalStorage context or falls back to env vars.
*
* Fail-closed: if resolution throws (e.g. a configured socks5 proxy with
* ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined —
* undefined would let the native binding connect directly and leak the real IP.
*/
function resolveProxyUrl(perCall: string | undefined): string | undefined {
return resolveTlsClientProxyUrl("https://app.notion.com", perCall, resolveProxyForRequest);
}
export interface TlsFetchResult {
status: number;
headers: Headers;
/** Full response body as text — only populated for non-streaming requests. */
text: string | null;
/** Streaming body — only populated when options.stream === true. */
body: ReadableStream<Uint8Array> | null;
}
// Test-only injection point. Tests call __setTlsFetchOverrideForTesting()
// to replace the real TLS client with a mock; production never touches this.
let testOverride: ((url: string, options: TlsFetchOptions) => Promise<TlsFetchResult>) | null =
null;
export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void {
testOverride = fn;
}
/**
* Make a single HTTP request to notion.so with a Chrome-like TLS fingerprint.
*
* Throws TlsClientUnavailableError if the native binary failed to load.
*/
export async function tlsFetchNotion(
url: string,
options: TlsFetchOptions = {}
): Promise<TlsFetchResult> {
if (testOverride) return testOverride(url, options);
// Honor abort signals up-front. tls-client-node's koffi binding doesn't
// accept an AbortSignal mid-flight (the binary call is opaque), so the best
// we can do is bail before issuing the call. We also re-check after — if
// the caller aborted while the upstream was running, throw rather than
// returning a stale response so the caller doesn't try to use it.
if (options.signal?.aborted) {
throw makeAbortError(options.signal);
}
const client = await getClient();
if (options.signal?.aborted) {
throw makeAbortError(options.signal);
}
const requestOptions: Record<string, unknown> = {
method: options.method || "GET",
headers: options.headers || {},
body: options.body,
tlsClientIdentifier: NOTION_PROFILE,
timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
followRedirects: true,
withRandomTLSExtensionOrder: true,
// Plumb the configured proxy through to the native binding. tls-client-node
// consults `proxyUrl` in the per-call options (it does NOT auto-pick up
// HTTP_PROXY / HTTPS_PROXY env), so callers / env have to be threaded in
// explicitly. See `resolveProxyUrl()` for the lookup order.
proxyUrl: resolveProxyUrl(options.proxyUrl),
};
if (options.stream) {
return await tlsFetchStreaming(
client,
url,
requestOptions,
options.streamEofSymbol,
options.signal ?? null,
(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS
);
}
let tlsResponse: TlsResponseLike;
try {
tlsResponse = await raceWithTimeout(
client.request(url, requestOptions),
(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS,
options.signal ?? null
);
} catch (err) {
if (err instanceof TlsClientHangError) {
// The native binding is wedged — drop the singleton so the next
// request respawns a fresh client (and a fresh koffi load).
resetClientCache();
}
throw err;
}
if (options.signal?.aborted) {
throw makeAbortError(options.signal);
}
return {
status: tlsResponse.status,
headers: toHeaders(tlsResponse.headers),
text: tlsResponse.body,
body: null,
};
}
function makeAbortError(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) return reason;
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
err.name = "AbortError";
return err;
}
function toHeaders(raw: Record<string, string[]>): Headers {
const h = new Headers();
for (const [k, vs] of Object.entries(raw || {})) {
for (const v of vs) h.append(k, v);
}
return h;
}
/**
* Returns true if the response body is a Cloudflare challenge/interstitial page
* rather than a real Perplexity response. From VPS/datacenter IPs a valid cookie
* still gets a 403 "Just a moment..." HTML page; distinguishing it from a genuine
* auth failure lets the caller surface an actionable error (issue #2459).
*
* Exported so the executor and the connection validator share one detector.
*/
export function isCloudflareChallenge(text: string | null | undefined): boolean {
if (!text) return false;
return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test(
text
);
}
// ─── Streaming via temp file ────────────────────────────────────────────────
// tls-client-node's streaming primitive writes the response body chunk-by-chunk
// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`.
// We tail the file from a worker and surface the bytes as a ReadableStream.
async function tlsFetchStreaming(
client: { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
eofSymbol = "[DONE]",
signal: AbortSignal | null = null,
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
): Promise<TlsFetchResult> {
const dir = await mkdtemp(join(tmpdir(), "pplx-stream-"));
const path = join(dir, `${randomUUID()}.sse`);
const streamOpts = {
...requestOptions,
streamOutputPath: path,
streamOutputBlockSize: 1024,
streamOutputEOFSymbol: eofSymbol,
};
// Kick off the request without awaiting — tls-client writes the body to
// `path` chunk-by-chunk while the call runs. The Promise resolves when the
// request fully completes (full body written). Wrapping in raceWithTimeout
// guarantees this promise eventually settles even if the koffi binding
// wedges; on hang we reset the singleton so the next request respawns.
let resetOnHang = true;
const requestPromise = raceWithTimeout(
client.request(url, streamOpts),
hardTimeoutMs,
signal
).catch((err: unknown) => {
if (resetOnHang && err instanceof TlsClientHangError) {
resetClientCache();
resetOnHang = false;
}
// Re-throw so downstream consumers (waitForContent, tailFile) observe
// the rejection and surface it instead of treating the stream as having
// ended cleanly.
throw err;
});
// Wait for the file to exist AND have at least one byte. tls-client-node
// creates the output file when the request starts, but the file can be
// empty for a brief window before the first body chunk lands — peeking
// during that window would return "" and misclassify the response as
// non-SSE, dropping us into the buffered-wait branch and silently turning
// a streaming request into a buffered one. Waiting for content avoids
// that race; if the request actually fails before producing any bytes,
// the timeout falls through to the requestPromise drain below (returning
// the real upstream status).
const ready = await waitForContent(path, 5_000, requestPromise);
if (!ready) {
const r = await requestPromise.catch(
(e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
);
await cleanupTempPath(path);
return {
status: r.status,
headers: toHeaders(r.headers),
text: r.body,
body: null,
};
}
// Peek the first bytes to decide whether this looks like SSE. Anything
// that doesn't positively look like SSE (JSON `{...}`, HTML `<...>`, plain
// text rate-limit messages, Cloudflare challenge pages, etc.) gets surfaced
// as a non-streaming response so the executor sees the real upstream status
// and body — otherwise non-2xx error pages get silently treated as 200 OK
// and the SSE parser produces an empty completion.
const peek = await readFirstBytes(path, 256);
if (!looksLikeSse(peek)) {
const r = await requestPromise.catch(
(e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
);
await cleanupTempPath(path);
return {
status: r.status,
headers: toHeaders(r.headers),
text: r.body,
body: null,
};
}
// Looks like SSE — start tailing. SSE bodies in practice are always 2xx;
// tls-client-node doesn't expose response status separately from full-body
// completion, so we report 200 and let the SSE parser consume the stream.
const stream = tailFile(path, eofSymbol, requestPromise, signal);
const headers = new Headers({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
});
return { status: 200, headers, text: null, body: stream };
}
/**
* Returns true if the peeked response body looks like an SSE stream — i.e.,
* begins (after any leading whitespace) with one of the SSE field markers
* (`data:`, `event:`, `id:`, `retry:`) or a comment line (`:`).
*
* Exported for tests.
*/
export function looksLikeSse(text: string): boolean {
const trimmed = text.replace(/^[\s\r\n]+/, "");
if (!trimmed) return false;
if (trimmed.startsWith(":")) return true;
return /^(data|event|id|retry):/i.test(trimmed);
}
async function cleanupTempPath(path: string): Promise<void> {
await unlink(path).catch(() => {});
const dir = path.substring(0, path.lastIndexOf("/"));
await rmdir(dir).catch(() => {});
}
async function readFirstBytes(path: string, n: number): Promise<string> {
const fd = await open(path, "r");
try {
const buf = Buffer.alloc(n);
const { bytesRead } = await fd.read(buf, 0, n, 0);
return buf.subarray(0, bytesRead).toString("utf8");
} finally {
await fd.close().catch(() => {});
}
}
/**
* Wait for the streaming output file to exist AND contain at least one byte.
* Returns false if the request settles before any bytes arrive (so the caller
* can drain `requestPromise` and surface the real upstream status). Returns
* true as soon as the file has data — even one byte is enough for the SSE
* heuristic to give a useful answer.
*/
async function waitForContent(
path: string,
timeoutMs: number,
requestPromise: Promise<TlsResponseLike>
): Promise<boolean> {
let requestSettled = false;
requestPromise.then(
() => {
requestSettled = true;
},
() => {
requestSettled = true;
}
);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const s = await stat(path);
if (s.size > 0) return true;
} catch {
// file doesn't exist yet
}
// If the request finished without producing any bytes, no point waiting
// out the rest of the timeout — let the caller drain it.
if (requestSettled) return false;
await sleep(25);
}
return false;
}
function tailFile(
path: string,
eofSymbol: string,
done: Promise<TlsResponseLike>,
signal: AbortSignal | null = null
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async start(controller) {
const fd = await open(path, "r");
const buf = Buffer.alloc(64 * 1024);
let offset = 0;
let finished = false;
let aborted = false;
let upstreamError: Error | null = null;
// Track request settlement, capturing both fulfillment and rejection.
// Without the rejection branch, a mid-stream tls-client-node error
// becomes an unhandledRejection — the stream cleans up silently and
// the consumer sees what looks like a successful truncated response.
done.then(
() => {
finished = true;
},
(err) => {
upstreamError = err instanceof Error ? err : new Error(String(err));
finished = true;
}
);
// If the caller aborts, stop tailing immediately.
const onAbort = () => {
aborted = true;
};
if (signal) {
if (signal.aborted) aborted = true;
else signal.addEventListener("abort", onAbort, { once: true });
}
let errored = false;
try {
while (!aborted) {
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
if (bytesRead > 0) {
const chunk = buf.subarray(0, bytesRead);
offset += bytesRead;
const text = chunk.toString("utf8");
if (text.includes(eofSymbol)) {
const cutAt = text.indexOf(eofSymbol) + eofSymbol.length;
controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt)));
break;
}
controller.enqueue(new Uint8Array(chunk));
} else if (finished) {
// No more data and request completed. If the request rejected,
// surface the error so the consumer doesn't think the stream
// ended cleanly.
if (upstreamError) {
controller.error(upstreamError);
errored = true;
}
break;
} else {
await sleep(25);
}
}
} catch (err) {
controller.error(err);
errored = true;
} finally {
if (signal) signal.removeEventListener("abort", onAbort);
await fd.close().catch(() => {});
await unlink(path).catch(() => {});
const dir = path.substring(0, path.lastIndexOf("/"));
await rmdir(dir).catch(() => {});
if (!errored) controller.close();
}
},
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

View File

@@ -18,7 +18,8 @@
"paths": {
"@/*": ["./src/*"],
"@omniroute/open-sse": ["./open-sse"],
"@omniroute/open-sse/*": ["./open-sse/*"]
"@omniroute/open-sse/*": ["./open-sse/*"],
"@omniroute/browser-pool": ["./packages/browser-pool/src"]
}
},
"include": ["**/*.ts", "**/*.js"]

112
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"open-sse"
"open-sse",
"packages/browser-pool"
],
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
@@ -3709,9 +3710,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3728,9 +3726,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3747,9 +3742,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3766,9 +3758,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3785,9 +3774,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3804,9 +3790,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3823,9 +3806,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3842,9 +3822,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3861,9 +3838,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3886,9 +3860,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3911,9 +3882,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3936,9 +3904,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3961,9 +3926,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3986,9 +3948,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4011,9 +3970,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4036,9 +3992,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -6347,6 +6300,10 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@omniroute/browser-pool": {
"resolved": "packages/browser-pool",
"link": true
},
"node_modules/@omniroute/open-sse": {
"resolved": "open-sse",
"link": true
@@ -10735,9 +10692,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10755,9 +10709,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10775,9 +10726,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10795,9 +10743,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -12890,9 +12835,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12906,9 +12848,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12922,9 +12861,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12938,9 +12874,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12954,9 +12887,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12970,9 +12900,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -37044,6 +36971,33 @@
"safe-regex": "^2.1.1",
"smol-toml": "1.7.0"
}
},
"packages/browser-pool": {
"name": "@omniroute/browser-pool",
"version": "0.1.0",
"dependencies": {
"playwright": "1.61.1"
},
"devDependencies": {
"@types/node": "^22"
}
},
"packages/browser-pool/node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/browser-pool/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -47,7 +47,8 @@
"!**/*.spec.tsx"
],
"workspaces": [
"open-sse"
"open-sse",
"packages/browser-pool"
],
"engines": {
"node": ">=22.22.2 <23 || >=24.0.0 <27"

View File

@@ -0,0 +1,15 @@
{
"name": "@omniroute/browser-pool",
"version": "0.1.0",
"private": true,
"description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"playwright": "1.61.1"
},
"devDependencies": {
"@types/node": "^22"
}
}

View File

@@ -0,0 +1,37 @@
/**
* @omniroute/browser-pool — Optional browser pool for Playwright-backed
* executor support (claude-web, duckduckgo-web, grok).
*
* Core stubs dynamically import this package at runtime. When the package
* is not installed, the stubs degrade gracefully (fallback or error).
*/
// ── Re-exports from browserPool ──────────────────────────────────────────
export {
acquireBrowserContext,
releaseBrowserContext,
getBrowserPoolMetrics,
readPageResponseBody,
openPage,
shutdownPool,
setProxyResolver,
__resetBrowserPoolMetricsForTest,
} from "./services/browserPool.ts";
export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts";
// ── Re-exports from browserBackedChat ────────────────────────────────────
export {
browserBackedChat,
startBrowserWarmup,
getFreshCookiesWithWarmup,
} from "./services/browserBackedChat.ts";
// ── Re-exports from grokClearance ─────────────────────────────────────────
export {
getCachedCookies,
setCachedCookies,
clearCookieCache,
} from "./services/browserBackedChat.ts";
export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts";

View File

@@ -0,0 +1,94 @@
/**
* interfaces.ts — Shared type definitions for @omniroute/browser-pool.
*
* These types are used by both the package entry and the core stubs.
* The core stubs re-export them so existing import paths remain stable.
*/
import type { BrowserContext, Page } from "playwright";
// ── Browser pool ───────────────────────────────────────
export interface BrowserPoolContextOptions {
cookieDomain: string;
cookieString?: string | null;
warmupUrl?: string | null;
userAgent?: string;
locale?: string;
timezone?: string;
preferCloakbrowser?: boolean;
/** Time (ms) to wait for the warmup page to be ready. */
waitFor?: number;
}
export interface PooledContext {
id: string;
context: BrowserContext;
warmupPage: Page | null;
lastUsed: number;
isStealth: boolean;
}
export interface BrowserPoolMetrics {
browserLaunches: number;
browserLaunchFailures: number;
contextsCreated: number;
contextsReused: number;
contextsEvicted: number;
contextsReleased: number;
contextCreateFailures: number;
shutdowns: number;
lastShutdownReason: string | null;
}
// ── Browser-backed chat ────────────────────────────────
export interface BrowserBackedChatRequest {
/** Pool key — typically a provider id like "duckduckgo-web" or
* "claude-web", optionally suffixed by user/account id. */
poolKey: string;
/** Chat URL the page should submit to (captured via waitForResponse). */
chatUrl: string;
/** Chat page URL to navigate to before typing. */
chatPageUrl: string;
/** The text the user wants to send. */
userMessage: string;
/** Cookie string (raw) to inject into the browser context. */
cookieString?: string | null;
/** Cookie domain (used together with cookieString). */
cookieDomain?: string;
/** Domain for the page's fetch to identify the chat endpoint. */
chatUrlMatchDomain: string;
/** User-Agent string for the browser context. */
userAgent?: string;
/** Locale (BCP 47). Defaults to en-US. */
locale?: string;
/** IANA timezone. Defaults to America/New_York. */
timezone?: string;
/** Selector for the chat input. */
inputSelector: string;
/** Selector for the submit button (optional — falls back to Enter). */
submitButtonSelector?: string;
/** Wait after submit for SSE/JSON to arrive. Default 15 seconds. */
postSubmitWaitMs?: number;
/** Optional AbortSignal. Cancels navigation/submit. */
signal?: AbortSignal | null;
/** Reuse the same context across requests. Default true. */
reuseContext?: boolean;
}
export interface BrowserBackedChatTiming {
acquireContextMs: number;
navigateMs: number;
submitMs: number;
captureResponseMs: number;
totalMs: number;
}
export interface BrowserBackedChatResult {
status: number;
contentType: string | null;
body: Buffer;
isStealth: boolean;
timing: BrowserBackedChatTiming;
}

View File

@@ -0,0 +1,461 @@
/**
* browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool.
*
* Opens a page on a shared browser context, navigates to the provider's
* chat page, types the user's message, clicks Send, and returns the
* upstream SSE/JSON response body as a structured result.
*
* Providers using this path: duckduckgo-web, claude-web.
*
* The browser solves the provider's challenge natively (VQD, Cloudflare
* Turnstile, etc.) by computing real DOM measurement values. The
* Node-side challenge solver still runs as a first-line best-effort;
* this module is the fallback.
*/
import { Buffer } from "node:buffer";
import {
acquireBrowserContext,
openPage,
readPageResponseBody,
releaseBrowserContext,
} from "./browserPool.ts";
import type {
PooledContext,
BrowserBackedChatRequest,
BrowserBackedChatResult,
} from "../interfaces.ts";
// Safety constants
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
// Cookie cache constants
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes
const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms
const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies
// Cookie cache — avoids repeated browser launches when cookies are still valid
interface CachedCookies {
cookieString: string;
expiresAt: number;
domain: string;
}
const cookieCache = new Map<string, CachedCookies>();
export function getCachedCookies(domain: string): string | null {
const cached = cookieCache.get(domain);
if (cached && Date.now() < cached.expiresAt) return cached.cookieString;
cookieCache.delete(domain);
return null;
}
export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void {
cookieCache.set(domain, {
cookieString,
expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS),
domain,
});
}
export function clearCookieCache(): void {
cookieCache.clear();
}
// Dedup pending cookie refreshes per pool key
const pendingRefreshes = new Map<string, Promise<string | null>>();
/** Sanitize an error message for safe JSON transport. */
const MAX_ERROR_LEN = 512;
function sanitizeErrorMessage(message: unknown): string {
let str = typeof message === "string" ? message : String(message ?? "");
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
const nl = str.indexOf("\n");
if (nl >= 0) str = str.slice(0, nl);
return str.replace(/[^ -~]/g, "").trim();
}
/** Wait N milliseconds, abortable via signal. */
async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s.
* Returns as soon as challenge cookies appear, instead of always
* waiting the full timeout. Saves 1-4s when anti-bot resolves quickly.
*/
async function waitForCookiesWithPolling(
context: import("playwright").BrowserContext,
cookieDomain: string,
signal: AbortSignal | null
): Promise<string | null> {
const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const cookies = await context.cookies(cookieDomain);
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
if (cookieString) return cookieString;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal);
}
return null;
}
/**
* doCookieRefreshOnContext — Run cookie extraction on an already-acquired
* browser context. Opens a temporary page, navigates to the chat URL,
* polls for cookies, and returns the result.
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals are
* handled via waitWithSignal wrapping instead.
*/
async function doCookieRefreshOnContext(
pooled: PooledContext,
chatPageUrl: string,
cookieDomain: string,
signal: AbortSignal | null
): Promise<string | null> {
const page = await openPage(pooled);
try {
await page.goto(chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return null;
} finally {
await page.close().catch(() => {});
}
}
/**
* Match a URL against a chat URL template, allowing a single dynamic
* id segment (PLACEHOLDER) in the template.
*/
function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
if (u === chatUrl) return true;
let parsed: URL;
let chatParsed: URL;
try {
parsed = new URL(u);
chatParsed = new URL(chatUrl);
} catch {
return false;
}
if (!parsed.host.endsWith(matchDomain)) return false;
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
const reqSeg = parsed.pathname.split("/").filter(Boolean);
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
let allowedDynamic = 1;
for (let i = 0; i < chatSeg.length; i++) {
if (chatSeg[i] === reqSeg[i]) continue;
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
allowedDynamic--;
continue;
}
return false;
}
return true;
}
/** Resolve a unique pool key; when reuseContext is false, create a unique key. */
async function settlePoolKey(
requestedKey: string,
reuseContext: boolean
): Promise<{ key: string; acquired: boolean }> {
if (reuseContext) return { key: requestedKey, acquired: true };
return {
key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
acquired: false,
};
}
// ── Cookie refresh helpers ──────────────────────────
/**
* doRefresh — Acquire a fresh browser context, navigate to the
* chat page, and poll for cookies. Returns the cookie string
* or null on failure.
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals
* are handled via waitWithSignal wrapping instead.
*/
async function doRefresh(options: {
chatPageUrl: string;
cookieDomain: string;
poolKey: string;
signal: AbortSignal | null;
}): Promise<string | null> {
const pooled = await acquireBrowserContext(options.poolKey + "-refresh", {
cookieDomain: options.cookieDomain,
cookieString: null,
warmupUrl: options.chatPageUrl,
});
const page = await openPage(pooled);
try {
await page.goto(options.chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return null;
} finally {
await page.close().catch(() => {});
// release context — we got the cookies
setTimeout(() => {
releaseBrowserContext(options.poolKey + "-refresh").catch(() => {});
}, 1000);
}
}
/**
* refreshCookiesViaBrowser — Refresh cookies using a browser context.
* Uses pendingRefreshes dedup so concurrent requests share one browser launch.
* NOTE: Override check (httpOverride) is handled in the core stub — this
* package version always attempts browser cookie refresh.
*/
async function refreshCookiesViaBrowser(
chatUrl: string,
chatPageUrl: string,
cookieDomain: string,
poolKey: string,
signal: AbortSignal | null
): Promise<string | null> {
const pending = pendingRefreshes.get(poolKey);
if (pending) return pending;
const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal });
pendingRefreshes.set(poolKey, promise);
try {
return await promise;
} finally {
pendingRefreshes.delete(poolKey);
}
}
/**
* startBrowserWarmup — Pre-warm a browser context for the given pool key.
* This is a fire-and-forget operation: errors are caught and ignored.
* The warmup page serves as a readiness indicator — we open a page in the
* pooled context to force early navigation before the actual request.
*/
export async function startBrowserWarmup(
poolKey: string,
chatPageUrl: string,
cookieDomain: string,
signal: AbortSignal | null
): Promise<void> {
if (process.env.OMNIROUTE_BROWSER_POOL === "off") return;
const pooled = await acquireBrowserContext(poolKey, {
cookieDomain,
cookieString: null,
warmupUrl: chatPageUrl,
waitFor: 2000,
});
// Warmup: open a page in the pooled context — this can happen in parallel
openPage(pooled).catch(() => {});
}
/**
* getFreshCookiesWithWarmup — Try cached cookies first; if none, start
* a browser warmup in parallel with a cookie refresh. Returns cookie string
* or null. Caches successful results.
*/
export async function getFreshCookiesWithWarmup(
chatUrl: string,
chatPageUrl: string,
cookieDomain: string,
poolKey: string,
signal: AbortSignal | null
): Promise<string | null> {
// Try cached cookies first
const cached = getCachedCookies(cookieDomain);
if (cached) return cached;
// Start warmup in parallel with refresh
const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal);
const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal);
// Await warmup (errors are non-fatal)
await warmup.catch(() => {});
if (fresh) {
setCachedCookies(cookieDomain, fresh);
return fresh;
}
return null;
}
// ── Main entry point ───────────────────────────────────
export async function browserBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
const t0 = Date.now();
const {
poolKey,
chatUrl,
chatPageUrl,
userMessage,
cookieString,
cookieDomain,
chatUrlMatchDomain,
userAgent,
locale,
timezone,
inputSelector,
submitButtonSelector,
postSubmitWaitMs = 15000,
signal,
reuseContext = true,
} = req;
const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext);
const tAcquireStart = Date.now();
const pooled: PooledContext = await acquireBrowserContext(key, {
cookieDomain: cookieDomain || chatUrlMatchDomain,
cookieString: cookieString || null,
warmupUrl: chatPageUrl,
userAgent,
locale,
timezone,
});
const acquireContextMs = Date.now() - tAcquireStart;
const page = await openPage(pooled);
try {
const tNavStart = Date.now();
await page.goto(chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
const navigateMs = Date.now() - tNavStart;
const inputLocator = page.locator(inputSelector).first();
await inputLocator.waitFor({ state: "visible", timeout: 10000 });
await waitWithSignal(800, signal);
const responsePromise = page.waitForResponse(
(r) =>
r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl),
{ timeout: 30000 }
);
let abortListener: (() => void) | undefined;
const signalPromise = signal
? new Promise<never>((_, reject) => {
if (signal.aborted) return reject(new DOMException("Aborted", "AbortError"));
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abortListener, { once: true });
})
: null;
if (submitButtonSelector) {
const btn = page.locator(submitButtonSelector).first();
if ((await btn.count()) > 0) {
try {
await btn.click({ timeout: 2000 });
} catch {
await page.keyboard.press("Enter");
}
} else {
await page.keyboard.press("Enter");
}
} else {
await page.keyboard.press("Enter");
}
const tCaptureStart = Date.now();
const response = signalPromise
? await Promise.race([responsePromise, signalPromise]).catch(() => null)
: await responsePromise.catch(() => null);
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
if (response) {
await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal);
} else {
await waitWithSignal(postSubmitWaitMs, signal);
}
const captureResponseMs = Date.now() - tCaptureStart;
const submitMs = captureResponseMs;
let status = 0;
let contentType: string | null = null;
let body: Buffer = Buffer.alloc(0);
if (response) {
const captured = await readPageResponseBody(response);
if (captured.body.length > MAX_RESPONSE_BYTES) {
body = Buffer.from(
JSON.stringify({
error: {
message: "Response too large",
type: "upstream_error",
},
})
);
status = 502;
contentType = "application/json";
} else {
body = captured.body as unknown as Buffer;
contentType = captured.headers["content-type"] || null;
}
}
return {
status,
contentType,
body,
isStealth: pooled.isStealth,
timing: {
acquireContextMs,
navigateMs,
submitMs,
captureResponseMs,
totalMs: Date.now() - t0,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const body = Buffer.from(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`),
type: "upstream_error",
},
})
);
return {
status: 502,
contentType: "application/json",
body,
isStealth: pooled.isStealth,
timing: {
acquireContextMs,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - t0,
},
};
} finally {
await page.close();
if (!reuseAcquired) {
try {
await pooled.context.close();
} catch {
/* ignore */
}
}
}
}

View File

@@ -0,0 +1,440 @@
/**
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
*
* The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both
* validate values that only a real browser can produce (DOM layout
* measurements like offsetWidth/Height, getBoundingClientRect,
* getComputedStyle, iframe contentWindow probes). Plain Node fetch + a
* VM-stubs solver structurally runs the JS but cannot match those values,
* so the server rejects the request.
*
* This pool keeps one Chromium instance warm and serves "browser contexts"
* (one per provider) on demand. Each context owns one or more pages; the
* caller is expected to be polite (one page per request, close on done).
*
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
* fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and
* dozens more) are the only thing that gets past DuckDuckGo's anti-bot
* in this environment. Falls back to plain `playwright` if cloakbrowser
* is not installed; the fallback works for Claude web (which only needs
* valid cookies) but not for DDG's VQD challenge.
*
* Opt-in: pool only launches Chromium when an executor explicitly asks
* for a context, so users who never use the browser-backed path pay zero
* startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable.
*/
import { Buffer } from "node:buffer";
import type {
BrowserPoolContextOptions,
BrowserPoolMetrics,
PooledContext,
} from "../interfaces.ts";
type Browser = import("playwright").Browser;
type BrowserContext = import("playwright").BrowserContext;
type Page = import("playwright").Page;
/** Proxy resolver injected by the core stub after dynamic import. */
type ProxyResolverFn = (
providerKey: string
) => Promise<import("playwright").LaunchOptions["proxy"] | undefined>;
let injectedProxyResolver: ProxyResolverFn | null = null;
export function setProxyResolver(fn: ProxyResolverFn): void {
injectedProxyResolver = fn;
}
function createBrowserPoolMetrics(): BrowserPoolMetrics {
return {
browserLaunches: 0,
browserLaunchFailures: 0,
contextsCreated: 0,
contextsReused: 0,
contextsEvicted: 0,
contextsReleased: 0,
contextCreateFailures: 0,
shutdowns: 0,
lastShutdownReason: null,
};
}
interface PoolState {
browser: Browser | null;
contexts: Map<string, PooledContext>;
pendingContexts: Map<string, Promise<PooledContext>>;
launching: Promise<Browser> | null;
lastActivity: number;
idleTimer: NodeJS.Timeout | null;
evictTimer: NodeJS.Timeout | null;
cloakLaunch: ((opts: unknown) => Promise<Browser>) | null;
cloakLaunchResolved: boolean;
metrics: BrowserPoolMetrics;
}
const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts
const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const state: PoolState = {
browser: null,
contexts: new Map(),
pendingContexts: new Map(),
launching: null,
lastActivity: 0,
idleTimer: null,
evictTimer: null,
cloakLaunch: null,
cloakLaunchResolved: false,
metrics: createBrowserPoolMetrics(),
};
function getCloakbrowserModuleId(): string {
// Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal
// dynamic import with the package name makes Turbopack resolve it during route compilation.
return ["cloak", "browser"].join("");
}
async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> {
if (state.cloakLaunchResolved) return state.cloakLaunch;
state.cloakLaunchResolved = true;
try {
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
launch?: (opts: unknown) => Promise<Browser>;
};
state.cloakLaunch = mod.launch ?? null;
} catch {
state.cloakLaunch = null;
}
return state.cloakLaunch;
}
function isPoolEnabled(): boolean {
const flag = process.env.OMNIROUTE_BROWSER_POOL;
if (flag === undefined) return true;
return flag !== "off" && flag !== "0" && flag !== "false";
}
function resetIdleTimer(): void {
if (state.idleTimer) clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => {
void shutdownPool("idle-timeout");
}, POOL_IDLE_TIMEOUT_MS);
state.idleTimer.unref?.();
}
function evictStaleContexts(): void {
const now = Date.now();
for (const [key, pooled] of state.contexts) {
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
console.log(
"[BrowserPool] Evicted stale context:",
key,
"(idle",
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
);
state.contexts.delete(key);
state.metrics.contextsEvicted++;
pooled.context.close().catch(() => {});
}
}
if (state.contexts.size === 0 && !state.launching) {
void shutdownPool("all-contexts-evicted");
}
}
function startEvictTimer(): void {
if (state.evictTimer) clearInterval(state.evictTimer);
state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS);
state.evictTimer.unref?.();
}
async function launchBrowser(): Promise<Browser> {
if (state.browser) return state.browser;
if (state.launching) return state.launching;
state.launching = (async () => {
const cloakLaunch = await resolveCloakLaunch();
let browser: Browser;
if (cloakLaunch) {
browser = await cloakLaunch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
} else {
// Fallback: plain Playwright. Works for Claude web (cookie-only
// auth) but DDG's VQD challenge will detect this Chromium build.
const { chromium } = await import("playwright");
browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
],
});
}
state.browser = browser;
state.launching = null;
state.metrics.browserLaunches++;
return browser;
})();
try {
return await state.launching;
} catch (err) {
state.launching = null;
state.metrics.browserLaunchFailures++;
throw err;
}
}
function parseCookieString(
raw: string,
domain: string
): Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}> {
return raw
.split(";")
.map((p) => p.trim())
.filter(Boolean)
.map((pair) => {
const eq = pair.indexOf("=");
if (eq < 0) return null;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (!name || !value) return null;
return {
name,
value,
domain: domain.startsWith(".") ? domain : `.${domain}`,
path: "/",
expires: -1,
httpOnly: false,
secure: true,
sameSite: "Lax" as const,
};
})
.filter(Boolean) as Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}>;
}
// Clear a key from the pending-creation map once its promise settles, counting
// failures. Kept as a leaf helper so acquireBrowserContext stays under the
// function-length ceiling (#3368 PR7 metrics).
function settlePendingContext(key: string, failed: boolean): void {
if (failed) state.metrics.contextCreateFailures++;
state.pendingContexts.delete(key);
}
export async function acquireBrowserContext(
key: string,
options: BrowserPoolContextOptions
): Promise<PooledContext> {
if (!isPoolEnabled()) {
throw new Error(
"browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled"
);
}
const existing = state.contexts.get(key);
if (existing) {
existing.lastUsed = Date.now();
state.lastActivity = Date.now();
state.metrics.contextsReused++;
resetIdleTimer();
return existing;
}
// Dedup concurrent creations for the same key
const pending = state.pendingContexts.get(key);
if (pending) return pending;
const createPromise = (async (): Promise<PooledContext> => {
const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined;
const [browser] = await Promise.all([launchBrowser()]);
const isStealth = state.cloakLaunch !== null;
const context = await browser.newContext({
userAgent: options.userAgent || DEFAULT_USER_AGENT,
locale: options.locale || "en-US",
timezoneId: options.timezone || "America/New_York",
viewport: { width: 1280, height: 800 },
...(proxy ? { proxy } : {}),
});
if (options.cookieString) {
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
if (cookies.length > 0) {
await context.addCookies(cookies);
}
}
let warmupPage: Page | null = null;
if (options.warmupUrl) {
try {
warmupPage = await context.newPage();
await warmupPage.goto(options.warmupUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
// Give the warmup a moment for the upstream's status/auth/country
// JSON endpoints to fire. Without this, the first chat request would
// pay the warmup cost on the hot path.
await new Promise((r) => setTimeout(r, 1500));
} catch (err) {
try {
await warmupPage?.close();
} catch {
/* ignore */
}
warmupPage = null;
void err;
}
}
// Guard: if shutdownPool() ran while we were creating this context,
// the browser we obtained is now closed. Close our temp context and
// throw so the caller knows to retry.
if (state.browser !== browser) {
await context.close().catch(() => {});
if (warmupPage) {
await warmupPage.close().catch(() => {});
}
throw new Error("Pool shut down during context creation");
}
const pooled: PooledContext = {
id: key,
context,
warmupPage,
lastUsed: Date.now(),
isStealth,
};
state.contexts.set(key, pooled);
state.metrics.contextsCreated++;
state.lastActivity = Date.now();
resetIdleTimer();
startEvictTimer();
return pooled;
})();
state.pendingContexts.set(key, createPromise);
createPromise
.then(() => settlePendingContext(key, false))
.catch(() => settlePendingContext(key, true));
return createPromise;
}
export async function openPage(pooled: PooledContext): Promise<Page> {
return pooled.context.newPage();
}
export async function releaseBrowserContext(key: string): Promise<void> {
const pooled = state.contexts.get(key);
if (!pooled) return;
state.contexts.delete(key);
state.metrics.contextsReleased++;
try {
await pooled.context.close();
} catch {
/* ignore */
}
if (state.contexts.size === 0) {
await shutdownPool("last-context-closed");
}
}
export async function shutdownPool(reason: string): Promise<void> {
state.metrics.shutdowns++;
state.metrics.lastShutdownReason = reason;
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
if (state.evictTimer) {
clearInterval(state.evictTimer);
state.evictTimer = null;
}
state.pendingContexts.clear();
for (const [key, pooled] of state.contexts) {
try {
await pooled.context.close();
} catch {
/* ignore */
}
state.contexts.delete(key);
}
if (state.browser) {
try {
await state.browser.close();
} catch {
/* ignore */
}
state.browser = null;
}
state.lastActivity = Date.now();
// Avoid unused-parameter lint: log reason via debug if anyone hooks
// process.on('exit') and prints state.
void reason;
}
function getBrowserPoolStatus(): {
enabled: boolean;
contexts: number;
browserRunning: boolean;
stealthAvailable: boolean;
lastActivityAgoMs: number;
} {
return {
enabled: isPoolEnabled(),
contexts: state.contexts.size,
browserRunning: state.browser !== null,
stealthAvailable: state.cloakLaunch !== null,
lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity,
};
}
/**
* #3368 PR7 — browser-pool observability. Returns live status plus cumulative
* lifecycle telemetry (launches, context create/reuse/evict/release counts,
* failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool.
*/
export function getBrowserPoolMetrics(): {
status: ReturnType<typeof getBrowserPoolStatus>;
metrics: BrowserPoolMetrics;
} {
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
}
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
export function __resetBrowserPoolMetricsForTest(): void {
state.metrics = createBrowserPoolMetrics();
}
export async function readPageResponseBody(
response: import("playwright").Response
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(response.headers())) {
headers[name] = value;
}
const body = await response.body();
return { status: response.status(), headers, body: Buffer.from(body) };
}

View File

@@ -0,0 +1,73 @@
/**
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
* grok-web (#8019).
*
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
* clearance from a datacenter egress that Cloudflare has already flagged —
* only a real browser solving the challenge natively can mint one bound to
* that egress's own fingerprint.
*
* This module reuses the EXISTING provider-agnostic browser pool
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
* claude.ai-specific and does not apply here.
*
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
* With the gate off, `acquireFreshGrokClearance` is never called — the
* executor stays on the Step-1 `cloudflare_challenge` classification.
*/
import { acquireBrowserContext } from "./browserPool.ts";
import type { PooledContext } from "../interfaces.ts";
const GROK_WARMUP_URL = "https://grok.com/";
const GROK_COOKIE_DOMAIN = ".grok.com";
const GROK_POOL_KEY = "grok-web";
/**
* Reads the same opt-in gate as claude-web/duckduckgo-web
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
*/
export function shouldUseGrokBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
const poolFlag = process.env.OMNIROUTE_BROWSER_POOL;
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
}
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
const match = cookies.find((c) => c.name === "cf_clearance");
return match?.value || null;
}
async function acquireViaPool(): Promise<string | null> {
try {
const pooled = await acquireBrowserContext(GROK_POOL_KEY, {
cookieDomain: GROK_COOKIE_DOMAIN,
cookieString: null,
warmupUrl: GROK_WARMUP_URL,
});
return await readCfClearanceFromContext(pooled);
} catch {
return null;
}
}
/**
* Acquire a fresh `.grok.com` cf_clearance via the shared browser pool.
* Never throws — resolves to `null` on any failure so callers can fall
* through to the Cloudflare-challenge error rather than crash the request.
*/
export async function acquireFreshGrokClearance(
signal?: AbortSignal | null
): Promise<string | null> {
try {
return await acquireViaPool();
} catch {
return null;
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
"lib": ["esnext"],
"types": ["node"],
"allowJs": false
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,4 +1,5 @@
packages:
- "packages/*"
- "open-sse"
allowBuilds:
"@parcel/watcher": true

View File

@@ -43,6 +43,10 @@ export const KNOWN_MISSING_ERROR_HELPER = new Set([
// --- original open-sse/executors + handlers scope (pre-6A.8) ---
// --- 6A.8 expanded scope: src/app/api/**/route.ts pre-existing violations ---
// TODO(6A.8): pre-existing, triage — route through buildErrorBody()/sanitizeErrorMessage()
// open-sse/executors/muse-spark-web.ts has its own local buildErrorResponse() + errorResult()
// that follow the same contract (wraps message inside error:{message,type}). The helper
// import would create a circular dep. Verified: no raw internal-stack-leak path.
"open-sse/executors/muse-spark-web.ts",
]);
// Import specifiers that count as "uses the error helper" (path ends in utils/error).

View File

@@ -10,7 +10,7 @@ import {
AUDIO_SPEECH_PROVIDERS,
AUDIO_TRANSCRIPTION_PROVIDERS,
} from "@omniroute/open-sse/config/audioRegistry.ts";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { toProviderModels, type ProviderModelGroup } from "./mediaProviderModels";
type Modality = "image" | "video" | "music" | "speech" | "transcription";
type GenerationResult = {
@@ -19,27 +19,6 @@ type GenerationResult = {
timestamp: number;
audioUrl?: string;
};
type MediaModelConfig = { id: string; name: string };
type MediaProviderConfig = { id: string; models: MediaModelConfig[] };
type ProviderModelGroup = {
id: string;
name: string;
models: { id: string; name: string }[];
};
const PROVIDER_METADATA = AI_PROVIDERS as Record<string, { name?: string }>;
function toProviderModels(registry: Record<string, MediaProviderConfig>): ProviderModelGroup[] {
return Object.entries(registry).map(([providerId, config]) => ({
id: providerId,
name: PROVIDER_METADATA[providerId]?.name || config.id || providerId,
models: config.models.map((model) => ({
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
name: model.name,
})),
}));
}
const IMAGE_PROVIDER_MODELS = toProviderModels(IMAGE_PROVIDERS);
const VIDEO_PROVIDER_MODELS = toProviderModels(VIDEO_PROVIDERS);
const MUSIC_PROVIDER_MODELS = toProviderModels(MUSIC_PROVIDERS);

View File

@@ -0,0 +1,37 @@
import { AI_PROVIDERS } from "@/shared/constants/providers";
type MediaModelConfig = { id: string; name: string };
type MediaProviderConfig = { id: string; models: MediaModelConfig[] };
export type ProviderModelGroup = {
id: string;
name: string;
models: { id: string; name: string }[];
};
const PROVIDER_METADATA = AI_PROVIDERS as Record<string, { name?: string }>;
/**
* Sort dropdown labels without changing registry insertion order, which can
* affect bare-model routing precedence.
*/
export function toProviderModels(
registry: Record<string, MediaProviderConfig>
): ProviderModelGroup[] {
return Object.entries(registry)
.map(([providerId, config]) => ({
id: providerId,
name: PROVIDER_METADATA[providerId]?.name || config.id || providerId,
models: config.models.map((model) => ({
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
name: model.name,
})),
}))
.sort((left, right) => {
const byName = left.name.localeCompare(right.name, "en", {
sensitivity: "base",
numeric: true,
});
return byName || left.id.localeCompare(right.id, "en");
});
}

View File

@@ -828,7 +828,7 @@ export default function ProvidersPage() {
{providerText(t, "onboardingWizard", "Provider Onboarding Wizard")}
</Button>
<a
href="https://docs.omniroute.io/providers"
href="https://github.com/diegosouzapw/OmniRoute#-documentation"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"

View File

@@ -347,10 +347,10 @@ export async function GET(request: Request) {
}
}
// Compute the raw-data cutoff: rows older than this may have been rolled up to
// daily_usage_summary and deleted from usage_history.
// Raw-data cutoff: must match cleanupUsageHistory's rollup/delete boundary —
// retention.usageHistory (src/lib/db/cleanup.ts), NOT aggregation.rawDataRetentionDays.
const dbSettings = getUserDatabaseSettings();
const rawRetentionDays = dbSettings.aggregation?.rawDataRetentionDays ?? 30;
const rawRetentionDays = dbSettings.retention?.usageHistory ?? 30;
const rawCutoff = new Date();
rawCutoff.setDate(rawCutoff.getDate() - rawRetentionDays);
const rawCutoffIso = rawCutoff.toISOString();

View File

@@ -14,6 +14,7 @@ import {
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
import {
admitChatRequest,
admitChatStructure,
releaseChatAdmissionAfterHandler,
releaseChatAdmissionWhenDone,
} from "@/shared/middleware/chatBodyAdmission";
@@ -73,8 +74,9 @@ export async function POST(request) {
// Reserve heavyweight capacity atomically and ingest the body with a hard byte bound
// BEFORE JSON parsing. Missing or dishonest Content-Length values cannot bypass
// the actual-byte limit. Capacity exhaustion is retryable rather than process-fatal.
const admission = await admitChatRequest(request);
if (!admission.admit) return admission.response;
const admissionResult = await admitChatRequest(request);
if (admissionResult.admit === false) return admissionResult.response;
const admission = admissionResult;
request = admission.request;
const finishAdmission = (response: Response) =>
releaseChatAdmissionWhenDone(response, admission.lease);
@@ -101,6 +103,13 @@ export async function POST(request) {
try {
parsedBody = await request.json().catch(() => null);
if (parsedBody) {
const structuralAdmission = admitChatStructure(parsedBody, admission.lease);
if (structuralAdmission.admit === false) {
admission.lease?.release();
return structuralAdmission.response;
}
admission.lease = structuralAdmission.lease;
const { blocked, result } = injectionGuard(parsedBody);
if (blocked) {
return finishAdmission(

View File

@@ -65,16 +65,28 @@ async function postHandler(request, context) {
}
const body = parsed.body;
const startTime = Date.now();
const parsedModel = parseVideoModel(body.model);
const promptError = promptRequiredResponse(body);
if (promptError) return promptError;
const promptOptional =
(parsedModel.model === "happyhorse-1.1-i2v" &&
(parsedModel.provider === "alibaba" ||
parsedModel.provider === "bailian-coding-plan" ||
parsedModel.provider === "qwen-cloud-token-plan" ||
parsedModel.provider === "qwen-cloud")) ||
(parsedModel.provider === "qwen-cloud" && parsedModel.model === "wan2.7-i2v") ||
(parsedModel.provider === "alibaba" &&
(parsedModel.model === "wan2.7-i2v-2026-04-25" || parsedModel.model === "wan2.6-i2v-flash"));
if (!promptOptional) {
const promptError = promptRequiredResponse(body);
if (promptError) return promptError;
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Parse model to get provider
const { provider } = parseVideoModel(body.model);
const { provider } = parsedModel;
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,

File diff suppressed because it is too large Load Diff

View File

@@ -49,14 +49,14 @@ export function getUsageSummary(unifiedSource: string, params: AnalyticsParams):
.prepare(
`
SELECT
COUNT(*) as totalRequests,
COALESCE(SUM(requests), 0) as totalRequests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COUNT(DISTINCT model) as uniqueModels,
COUNT(DISTINCT COALESCE(NULLIF(account_key, ''), NULLIF(connection_id, ''))) as uniqueAccounts,
COUNT(DISTINCT COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''))) as uniqueApiKeys,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MIN(timestamp), '') as firstRequest,
COALESCE(MAX(timestamp), '') as lastRequest
@@ -101,7 +101,7 @@ export function getDailyUsage(unifiedSource: string, params: AnalyticsParams): D
`
SELECT
DATE(timestamp) as date,
COUNT(*) as requests,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
@@ -215,7 +215,7 @@ export function getModelUsageRows(unifiedSource: string, params: AnalyticsParams
LOWER(model) as model,
LOWER(provider) as provider,
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
COUNT(*) as requests,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
@@ -223,7 +223,7 @@ export function getModelUsageRows(unifiedSource: string, params: AnalyticsParams
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests,
COALESCE(MAX(timestamp), '') as lastUsed
FROM ${unifiedSource} AS _u
GROUP BY LOWER(model), LOWER(provider), serviceTier
@@ -298,12 +298,12 @@ export function getProviderUsageRows(
`
SELECT
LOWER(provider) as provider,
COUNT(*) as requests,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests
COALESCE(SUM(CASE WHEN success = 1 THEN requests ELSE 0 END), 0) as successfulRequests
FROM ${unifiedSource} AS _u
GROUP BY LOWER(provider)
ORDER BY requests DESC
@@ -553,7 +553,7 @@ export function getServiceTierUsageRows(
LOWER(provider) as provider,
LOWER(model) as model,
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
COUNT(*) as requests,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
@@ -633,7 +633,7 @@ export function getWeeklyPatternRows(
SELECT
DATE(timestamp) as date,
strftime('%w', timestamp) as dayOfWeek,
COUNT(*) as requests,
COALESCE(SUM(requests), 0) as requests,
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM ${unifiedSource} AS _u
GROUP BY DATE(timestamp), strftime('%w', timestamp)

View File

@@ -88,7 +88,6 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour
unifiedParams.rawCutoffDate = rawCutoffDate;
}
const aggWhere = aggConditions.length > 0 ? `WHERE ${aggConditions.join(" AND ")}` : "";
const unifiedSource = needsAggregated
? `(
SELECT
@@ -106,7 +105,8 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour
connection_id,
account_key,
api_key_id,
api_key_name
api_key_name,
1 as requests
FROM usage_history
${rawWhere}
UNION ALL
@@ -121,11 +121,12 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour
0 as tokens_reasoning,
'standard' as service_tier,
1 as success,
0 as latency_ms,
NULL as latency_ms,
NULL as connection_id,
NULL as account_key,
NULL as api_key_id,
NULL as api_key_name
NULL as api_key_name,
total_requests as requests
FROM daily_usage_summary
${aggWhere}
)`
@@ -134,10 +135,11 @@ export function buildUnifiedSource(opts: BuildUnifiedSourceOptions): UnifiedSour
tokens_input, tokens_output,
tokens_cache_read, tokens_cache_creation, tokens_reasoning,
service_tier, success, latency_ms,
connection_id, account_key, api_key_id, api_key_name
connection_id, account_key, api_key_id, api_key_name,
1 as requests
FROM usage_history
${rawWhere}
)`;
)`;
return { unifiedSource, unifiedParams };
}

View File

@@ -25,12 +25,21 @@ export interface SpawnArgs {
let latestVersionCache: { value: string; expiresAt: number } | null = null;
const VERSION_CACHE_TTL_MS = 3_600_000;
// Resolve the install dir lazily from the *current* DATA_DIR so a runtime
// DATA_DIR override (operator env change, or a test's tmp-dir isolation) is
// honored — the module-level BIFROST_INSTALL_DIR const is frozen at import.
function getBifrostInstallDir(): string {
return process.env.DATA_DIR
? path.join(process.env.DATA_DIR, "services", "bifrost")
: BIFROST_INSTALL_DIR;
}
function getInstalledPkgPath(): string {
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "package.json");
return path.join(getBifrostInstallDir(), "node_modules", "@maximhq", "bifrost", "package.json");
}
function getBinPath(): string {
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "bin.js");
return path.join(getBifrostInstallDir(), "node_modules", "@maximhq", "bifrost", "bin.js");
}
function getInstalledVersionSync(): string | null {
@@ -118,10 +127,28 @@ export async function update(): Promise<InstallResult> {
return install("latest");
}
/**
* Bifrost's own bin.js validates BIFROST_TRANSPORT_VERSION against
* /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ or the literal "latest" - it does
* NOT accept a bare semver string like "1.6.3". getInstalledVersionSync()
* reads the raw "version" field straight out of package.json, which is
* always bare semver (npm/package.json convention never includes the "v"
* prefix), so passing it through unmodified made every embedded Bifrost
* instance fail on startup with "Invalid transport version format".
*
* Normalize here, at the call site that owns the env var, so both
* bifrost.ts and the upstream @maximhq/bifrost package can stay exactly as
* they are otherwise designed to be used.
*/
export function formatTransportVersion(version: string | null): string {
if (!version || version === "latest") return version ?? "latest";
return version.startsWith("v") ? version : `v${version}`;
}
export function resolveSpawnArgs(port: number): SpawnArgs {
const binPath = getBinPath();
// Pin transport version to the installed npm version for reproducibility (spec §2b)
const transportVersion = getInstalledVersionSync() ?? "latest";
const transportVersion = formatTransportVersion(getInstalledVersionSync());
return {
command: process.execPath,

View File

@@ -399,6 +399,17 @@ export async function checkConnection(conn) {
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
if (intervalMin <= 0) return;
if (!conn.isActive) return;
// #8182: skip terminal connections (credits_exhausted / banned / expired).
// These can never self-heal via a token refresh — probing them wastes
// CPU and network on every sweep cycle. Mirrors isTerminalConnectionStatus
// in src/sse/services/auth.ts and TERMINAL_CONNECTION_STATUSES in
// src/lib/quota/connectionRecovery.ts.
const terminalStatuses = new Set(["credits_exhausted", "banned", "expired"]);
if (typeof conn.testStatus === "string" && terminalStatuses.has(conn.testStatus.toLowerCase())) {
return;
}
if (!conn.refreshToken || typeof conn.refreshToken !== "string") {
if (isGitHubAccessTokenOnlyConnection(conn)) {
const now = new Date().toISOString();

View File

@@ -196,7 +196,13 @@ export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise<
* @returns ISO date string (YYYY-MM-DD)
*/
export async function getRawDataCutoffDate(): Promise<string> {
const rawDataRetentionDays = getUserDatabaseSettings().aggregation.rawDataRetentionDays;
// The raw-data cutoff MUST match the actual rollup/delete boundary used by
// cleanupUsageHistory (src/lib/db/cleanup.ts), which is driven by
// retention.usageHistory — NOT aggregation.rawDataRetentionDays.
// Using rawDataRetentionDays (default 7 per migration 046) creates a gap:
// analytics floors raw data at day-7 while cleanup doesn't roll up until
// day-30, so the window [day-30, day-7) is excluded from BOTH UNION legs.
const rawDataRetentionDays = getUserDatabaseSettings().retention.usageHistory;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - rawDataRetentionDays);

View File

@@ -431,6 +431,7 @@ export default function Sidebar({
<Link
key={item.href}
href={item.href}
prefetch={false}
onClick={onClose}
className={className}
{...sharedProps}
@@ -497,6 +498,7 @@ export default function Sidebar({
<div className={cn("py-3", collapsed ? "px-2" : "px-4")}>
<Link
href="/home"
prefetch={false}
className={cn("flex items-center", collapsed ? "justify-center" : "gap-2.5")}
>
<div className="flex items-center justify-center size-8 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] shrink-0">

View File

@@ -144,3 +144,19 @@ export function resolveAlibabaProviderModelsUrl(
.replace(/\/(?:chat\/completions|messages|models)$/i, "");
return baseUrl ? `${baseUrl}/models` : "";
}
/**
* Resolve the dedicated Alibaba-family media API root from the connection's
* regional OpenAI/Anthropic-compatible endpoint.
*/
export function resolveAlibabaProviderMediaBaseUrl(
providerId: string,
providerSpecificData?: unknown,
fallback = ""
): string {
return resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback)
.trim()
.replace(/\/+$/, "")
.replace(/\/compatible-mode\/v1(?:\/(?:chat\/completions|models))?$/i, "/api/v1")
.replace(/\/apps\/anthropic(?:\/v1)?(?:\/messages)?$/i, "/api/v1");
}

View File

@@ -30,6 +30,23 @@ const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt(
1
);
export const CHAT_HEAVY_MESSAGE_COUNT = parsePositiveInt(
process.env.OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT,
200
);
export const CHAT_HEAVY_TOOL_COUNT = parsePositiveInt(
process.env.OMNIROUTE_CHAT_HEAVY_TOOL_COUNT,
64
);
export const CHAT_HEAVY_ESTIMATED_TOKENS = parsePositiveInt(
process.env.OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS,
32_000
);
export const CHAT_HARD_MAX_MESSAGES = parsePositiveInt(
process.env.OMNIROUTE_CHAT_HARD_MAX_MESSAGES,
800
);
export interface ChatAdmissionLease {
readonly released: boolean;
release(): void;
@@ -76,6 +93,10 @@ export type ChatRequestAdmission =
| { admit: true; request: Request; lease: ChatAdmissionLease | null }
| { admit: false; response: Response };
export type ChatStructureAdmission =
| { admit: true; lease: ChatAdmissionLease | null }
| { admit: false; response: Response };
function rejectionResponse(status: 413 | 503, hardMaxBytes: number): Response {
const isPayload = status === 413;
const headers: Record<string, string> = {
@@ -99,6 +120,124 @@ function rejectionResponse(status: 413 | 503, hardMaxBytes: number): Response {
);
}
function structuralRejectionResponse(status: 413 | 503, maxMessages: number): Response {
const historyLimit = status === 413;
const headers: Record<string, string> = {
...CORS_HEADERS,
"Content-Type": "application/json",
};
if (!historyLimit) headers["Retry-After"] = "1";
return new Response(
JSON.stringify({
error: {
message: historyLimit
? `Chat history exceeds the ${maxMessages}-message limit; compact the conversation and retry.`
: "Structurally heavy chat request capacity is busy; retry shortly.",
type: historyLimit ? "payload_too_large" : "server_error",
code: historyLimit ? "chat_history_too_large" : "chat_admission_busy",
reason: historyLimit ? "message_limit" : "structure_limit",
},
}),
{ status, headers }
);
}
type TokenEstimate = { tokens: number; exhausted: boolean };
function conservativeStringTokens(value: string, remaining: number): number {
let tokens = 0;
for (const character of value) {
tokens += character.codePointAt(0)! < 0x80 ? 0.25 : 1;
if (tokens >= remaining) return remaining;
}
return tokens;
}
function estimateStructureTokens(value: unknown, limit: number): TokenEstimate {
let tokens = 0;
let visited = 0;
const maxNodes = 10_000;
const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
while (stack.length > 0 && tokens < limit && visited < maxNodes) {
const current = stack.pop();
if (!current) break;
visited += 1;
if (typeof current.value === "string") {
tokens += conservativeStringTokens(current.value, limit - tokens);
continue;
}
if (!current.value || typeof current.value !== "object") continue;
if (current.depth >= 12) return { tokens, exhausted: true };
const remainingNodes = maxNodes - visited - stack.length;
if (Array.isArray(current.value)) {
if (current.value.length > remainingNodes) return { tokens, exhausted: true };
for (const child of current.value) stack.push({ value: child, depth: current.depth + 1 });
continue;
}
let children = 0;
for (const key in current.value) {
if (!Object.hasOwn(current.value, key)) continue;
children += 1;
if (children > remainingNodes) return { tokens, exhausted: true };
tokens += conservativeStringTokens(key, limit - tokens);
if (tokens >= limit) return { tokens: limit, exhausted: false };
stack.push({
value: (current.value as Record<string, unknown>)[key],
depth: current.depth + 1,
});
}
}
return { tokens, exhausted: stack.length > 0 && tokens < limit };
}
export function admitChatStructure(
body: unknown,
lease: ChatAdmissionLease | null,
options: {
controller?: ChatAdmissionController;
maxMessages?: number;
heavyMessages?: number;
heavyTools?: number;
heavyTokens?: number;
} = {}
): ChatStructureAdmission {
if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease };
const record = body as Record<string, unknown>;
const messages = Array.isArray(record.messages) ? record.messages : [];
const tools = Array.isArray(record.tools) ? record.tools : [];
const maxMessages = options.maxMessages ?? CHAT_HARD_MAX_MESSAGES;
if (messages.length > maxMessages) {
return { admit: false, response: structuralRejectionResponse(413, maxMessages) };
}
const heavyMessages = options.heavyMessages ?? CHAT_HEAVY_MESSAGE_COUNT;
const heavyTools = options.heavyTools ?? CHAT_HEAVY_TOOL_COUNT;
const heavyTokens = options.heavyTokens ?? CHAT_HEAVY_ESTIMATED_TOKENS;
const countHeavy = messages.length >= heavyMessages || tools.length >= heavyTools;
if (!countHeavy && lease) return { admit: true, lease };
const messageEstimate = estimateStructureTokens(messages, heavyTokens);
const toolEstimate = messageEstimate.exhausted
? { tokens: 0, exhausted: true }
: estimateStructureTokens(tools, heavyTokens - messageEstimate.tokens);
const estimatedTokens = Math.min(heavyTokens, messageEstimate.tokens + toolEstimate.tokens);
const heavy =
countHeavy ||
messageEstimate.exhausted ||
toolEstimate.exhausted ||
estimatedTokens >= heavyTokens;
if (!heavy || lease) return { admit: true, lease };
const acquired = (options.controller ?? defaultAdmissionController).tryAcquireHeavy();
return acquired
? { admit: true, lease: acquired }
: { admit: false, response: structuralRejectionResponse(503, maxMessages) };
}
function parseContentLength(header: string | null): number | null {
if (header === null || !/^(0|[1-9]\d*)$/.test(header.trim())) return null;
const parsed = Number(header);

View File

@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
test.describe("Dashboard Navigation", () => {
test("redirects unauthenticated user to /login", async ({ page }) => {
@@ -10,6 +11,30 @@ test.describe("Dashboard Navigation", () => {
expect(url).toMatch(/\/(login|dashboard)/);
});
test("does not prefetch dashboard routes and preserves client navigation", async ({ page }) => {
const speculativeRequests: string[] = [];
page.on("request", (request) => {
const headers = request.headers();
const isRscPrefetch =
headers.rsc === "1" &&
(headers["next-router-prefetch"] === "1" ||
headers.purpose?.toLowerCase().includes("prefetch") ||
headers["sec-purpose"]?.toLowerCase().includes("prefetch"));
if (isRscPrefetch) speculativeRequests.push(request.url());
});
await gotoDashboardRoute(page, "/home");
await expect(page.getByRole("link", { name: /providers/i }).first()).toBeVisible();
await page.waitForTimeout(500);
expect(speculativeRequests).toEqual([]);
await page.getByRole("link", { name: /providers/i }).first().click();
await expect(page).toHaveURL(/\/dashboard\/providers/);
});
test("login page renders with form elements", async ({ page }) => {
await page.goto("/login");
// Should show some form of authentication UI

View File

@@ -0,0 +1,161 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-alibaba-image-media-"));
const { IMAGE_PROVIDERS, parseImageModel } = await import("../../open-sse/config/imageRegistry.ts");
const { resolveProviderServiceKinds } = await import("../../open-sse/config/mediaServiceKinds.ts");
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
const { resolveAlibabaProviderMediaBaseUrl } =
await import("../../src/shared/constants/alibabaProviderRegions.ts");
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
test("Alibaba owns an independent five-model image catalog", () => {
assert.deepEqual(
IMAGE_PROVIDERS.alibaba.models.map((model) => model.id),
[
"qwen-image-3.0-pro",
"qwen-image-2.0-pro-2026-06-22",
"qwen-image-2.0",
"z-image-turbo",
"wan2.6-t2i",
]
);
assert.notEqual(IMAGE_PROVIDERS.alibaba.models, IMAGE_PROVIDERS["bailian-coding-plan"].models);
assert.notEqual(IMAGE_PROVIDERS.alibaba.models, IMAGE_PROVIDERS["qwen-cloud-token-plan"].models);
assert.deepEqual(parseImageModel("ali/qwen-image-3.0-pro"), {
provider: "alibaba",
model: "qwen-image-3.0-pro",
});
assert.deepEqual(resolveProviderServiceKinds("alibaba", ["llm"]).sort(), [
"image",
"llm",
"video",
]);
});
test("Alibaba registration preserves existing bare duplicate-model routing", () => {
assert.deepEqual(parseImageModel("z-image-turbo"), {
provider: "nanogpt",
model: "z-image-turbo",
});
assert.deepEqual(parseImageModel("qwen-image-2.0"), {
provider: "lmarena",
model: "qwen-image-2.0",
});
assert.deepEqual(parseImageModel("qwen-image-3.0-pro"), {
provider: "alibaba",
model: "qwen-image-3.0-pro",
});
});
test("Alibaba image URLs follow only the selected Alibaba region", () => {
assert.equal(
resolveAlibabaProviderMediaBaseUrl("alibaba", { region: "global-sg" }),
"https://dashscope-intl.aliyuncs.com/api/v1"
);
assert.equal(
resolveAlibabaProviderMediaBaseUrl("alibaba", { region: "china-beijing" }),
"https://dashscope.aliyuncs.com/api/v1"
);
assert.notEqual(
resolveAlibabaProviderMediaBaseUrl("alibaba", { region: "global-sg" }),
resolveAlibabaProviderMediaBaseUrl("bailian-coding-plan", { region: "global-sg" })
);
assert.notEqual(
resolveAlibabaProviderMediaBaseUrl("alibaba", { region: "global-sg" }),
resolveAlibabaProviderMediaBaseUrl("qwen-cloud-token-plan", { region: "global-sg" })
);
});
test("All five Alibaba image models use the Alibaba multimodal endpoint and key", async () => {
const originalFetch = globalThis.fetch;
const captures = [];
globalThis.fetch = async (url, options = {}) => {
captures.push({
url: String(url),
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
});
return jsonResponse({
output: {
choices: [
{
message: {
content: [{ image: "https://cdn.example.com/alibaba-image.png" }],
},
},
],
},
});
};
try {
for (const model of IMAGE_PROVIDERS.alibaba.models.map((entry) => entry.id)) {
const result = await handleImageGeneration({
body: {
model: `alibaba/${model}`,
prompt: "A cinematic horse portrait",
size: "2048x2048",
n: 1,
negative_prompt: "blurry",
prompt_extend: true,
watermark: false,
seed: 42,
},
credentials: {
apiKey: "alibaba-key",
providerSpecificData: { region: "china-beijing" },
},
log: null,
});
assert.equal(result.success, true);
}
assert.equal(captures.length, 5);
for (const capture of captures) {
assert.equal(
capture.url,
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
);
assert.equal(capture.headers.Authorization, "Bearer alibaba-key");
assert.deepEqual(capture.body.input.messages[0].content, [
{ text: "A cinematic horse portrait" },
]);
assert.deepEqual(capture.body.parameters, {
size: "2048*2048",
n: 1,
negative_prompt: "blurry",
prompt_extend: true,
watermark: false,
seed: 42,
});
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("Alibaba rejects image models outside its own allowlist", async () => {
const result = await handleImageGeneration({
body: {
model: "alibaba/wan2.7-image-pro",
prompt: "wrong catalog",
},
credentials: { apiKey: "alibaba-key" },
log: null,
});
assert.equal(result.status, 400);
assert.match(result.error, /unsupported alibaba image model/i);
});

View File

@@ -0,0 +1,258 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-alibaba-video-media-"));
const { VIDEO_PROVIDERS, parseVideoModel } = await import("../../open-sse/config/videoRegistry.ts");
const { resolveProviderServiceKinds } = await import("../../open-sse/config/mediaServiceKinds.ts");
const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts");
const ADDED_ALIBABA_VIDEO_MODELS = [
"happyhorse-1.1-i2v",
"happyhorse-1.1-t2v",
"happyhorse-1.1-r2v",
"happyhorse-1.0-video-edit",
"wan2.7-i2v-2026-04-25",
"wan2.6-i2v-flash",
"wan2.7-t2v-2026-06-12",
"wan2.7-r2v-2026-06-12",
"wan2.7-videoedit",
];
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function immediateTimeout(callback, _ms, ...args) {
if (typeof callback === "function") callback(...args);
return 0;
}
async function captureAlibabaRequest(body, region = "global-sg") {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
let captured;
globalThis.setTimeout = immediateTimeout;
globalThis.fetch = async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl.endsWith("/services/aigc/video-generation/video-synthesis")) {
captured = {
url: stringUrl,
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
};
return jsonResponse({ output: { task_id: "alibaba-video-task", task_status: "PENDING" } });
}
if (stringUrl.endsWith("/tasks/alibaba-video-task")) {
return jsonResponse({
output: {
task_status: "SUCCEEDED",
video_url: "https://cdn.example.com/alibaba-video.mp4",
},
});
}
throw new Error(`Unexpected URL: ${stringUrl}`);
};
try {
const result = await handleVideoGeneration({
body,
credentials: {
apiKey: "alibaba-video-key",
providerSpecificData: { region },
},
log: null,
});
return { captured, result };
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
}
test("Alibaba exposes the nine added video models without changing Qwen Cloud lists", () => {
const alibabaModels = VIDEO_PROVIDERS.alibaba.models.map((model) => model.id);
assert.deepEqual(alibabaModels, ADDED_ALIBABA_VIDEO_MODELS);
assert.equal(
VIDEO_PROVIDERS["qwen-cloud"].models.some(
(model) =>
model.id === "wan2.7-i2v-2026-04-25" ||
model.id === "wan2.6-i2v-flash" ||
model.id === "wan2.7-t2v-2026-06-12"
),
false
);
assert.deepEqual(parseVideoModel("ali/wan2.6-i2v-flash"), {
provider: "alibaba",
model: "wan2.6-i2v-flash",
});
assert.deepEqual(resolveProviderServiceKinds("alibaba", ["llm"]).sort(), [
"image",
"llm",
"video",
]);
});
test("Alibaba HappyHorse I2V uses only the Alibaba key and selected regional endpoint", async () => {
const { captured, result } = await captureAlibabaRequest(
{
model: "alibaba/happyhorse-1.1-i2v",
image_url: "https://cdn.example.com/horse.png",
resolution: "720p",
duration: 5,
},
"china-beijing"
);
assert.equal(
captured.url,
"https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"
);
assert.equal(captured.headers.Authorization, "Bearer alibaba-video-key");
assert.deepEqual(captured.body.input.media, [
{ type: "first_frame", url: "https://cdn.example.com/horse.png" },
]);
assert.deepEqual(captured.body.parameters, {
resolution: "720P",
duration: 5,
});
assert.equal(result.success, true);
});
test("Alibaba Wan I2V variants map first-frame and driving-audio media", async () => {
for (const model of ["wan2.7-i2v-2026-04-25", "wan2.6-i2v-flash"]) {
const { captured } = await captureAlibabaRequest({
model: `alibaba/${model}`,
image_url: "https://cdn.example.com/first.png",
audio_url: "https://cdn.example.com/voice.mp3",
duration: 10,
});
assert.equal(captured.body.model, model);
assert.deepEqual(captured.body.input.media, [
{ type: "first_frame", url: "https://cdn.example.com/first.png" },
{ type: "driving_audio", url: "https://cdn.example.com/voice.mp3" },
]);
assert.equal(captured.body.parameters.duration, 10);
assert.equal("ratio" in captured.body.parameters, false);
}
});
test("Alibaba dated Wan T2V maps modern resolution, ratio, and audio input", async () => {
const { captured } = await captureAlibabaRequest({
model: "alibaba/wan2.7-t2v-2026-06-12",
prompt: "A cinematic horse race",
audio_url: "https://cdn.example.com/score.mp3",
size: "1920x1080",
duration: 10,
prompt_extend: true,
});
assert.deepEqual(captured.body.input, {
prompt: "A cinematic horse race",
audio_url: "https://cdn.example.com/score.mp3",
});
assert.deepEqual(captured.body.parameters, {
resolution: "1080P",
ratio: "16:9",
duration: 10,
prompt_extend: true,
});
});
test("Alibaba Wan R2V preserves ordered image, video, and voice references", async () => {
const { captured } = await captureAlibabaRequest({
model: "alibaba/wan2.7-r2v-2026-06-12",
prompt: "Video 1 greets Image 1",
media: [
{
type: "reference_video",
url: "https://cdn.example.com/actor.mp4",
reference_voice: "https://cdn.example.com/actor.mp3",
},
{
type: "reference_image",
url: "https://cdn.example.com/prop.png",
},
],
ratio: "16:9",
});
assert.deepEqual(captured.body.input.media, [
{
type: "reference_video",
url: "https://cdn.example.com/actor.mp4",
reference_voice: "https://cdn.example.com/actor.mp3",
},
{
type: "reference_image",
url: "https://cdn.example.com/prop.png",
},
]);
assert.equal(captured.body.parameters.ratio, "16:9");
});
test("Alibaba video-edit models require and map a source video", async () => {
for (const model of ["happyhorse-1.0-video-edit", "wan2.7-videoedit"]) {
const { captured } = await captureAlibabaRequest({
model: `alibaba/${model}`,
prompt: "Replace the jacket with the reference image",
video_url: "https://cdn.example.com/source.mp4",
reference_images: ["https://cdn.example.com/jacket.png"],
resolution: "1080P",
});
assert.deepEqual(captured.body.input.media, [
{ type: "video", url: "https://cdn.example.com/source.mp4" },
{ type: "reference_image", url: "https://cdn.example.com/jacket.png" },
]);
assert.equal(captured.body.parameters.resolution, "1080P");
}
});
test("Alibaba media-specific video models reject missing input locally", async () => {
for (const [model, errorPattern] of [
["happyhorse-1.1-i2v", /image input is required/i],
["happyhorse-1.1-r2v", /image input is required/i],
["happyhorse-1.0-video-edit", /video input is required/i],
["wan2.7-i2v-2026-04-25", /image input is required/i],
["wan2.6-i2v-flash", /image input is required/i],
["wan2.7-r2v-2026-06-12", /reference image or video input is required/i],
["wan2.7-videoedit", /video input is required/i],
]) {
const result = await handleVideoGeneration({
body: {
model: `alibaba/${model}`,
prompt: "missing media",
},
credentials: { apiKey: "alibaba-video-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 400);
assert.match(result.error, errorPattern);
}
});
test("Alibaba rejects video models outside its own allowlist", async () => {
const result = await handleVideoGeneration({
body: {
model: "alibaba/wan2.7-t2v",
prompt: "not part of the Alibaba allowlist",
},
credentials: { apiKey: "alibaba-video-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 400);
assert.match(result.error, /unsupported alibaba video model/i);
});

View File

@@ -0,0 +1,233 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-bailian-coding-plan-media-"));
const { IMAGE_PROVIDERS, parseImageModel } = await import("../../open-sse/config/imageRegistry.ts");
const { VIDEO_PROVIDERS, parseVideoModel } = await import("../../open-sse/config/videoRegistry.ts");
const { resolveProviderServiceKinds } = await import("../../open-sse/config/mediaServiceKinds.ts");
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts");
const { resolveAlibabaProviderMediaBaseUrl } =
await import("../../src/shared/constants/alibabaProviderRegions.ts");
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function immediateTimeout(callback, _ms, ...args) {
if (typeof callback === "function") callback(...args);
return 0;
}
test("Bailian Coding Plan owns an independent seven-model media catalog", () => {
assert.deepEqual(
IMAGE_PROVIDERS["bailian-coding-plan"].models.map((model) => model.id),
["wan2.7-image", "wan2.7-image-pro", "qwen-image-2.0", "qwen-image-2.0-pro"]
);
assert.deepEqual(
VIDEO_PROVIDERS["bailian-coding-plan"].models.map((model) => model.id),
["happyhorse-1.1-i2v", "happyhorse-1.1-t2v", "happyhorse-1.1-r2v"]
);
assert.notEqual(
IMAGE_PROVIDERS["bailian-coding-plan"].models,
IMAGE_PROVIDERS["qwen-cloud-token-plan"].models
);
assert.notEqual(
VIDEO_PROVIDERS["bailian-coding-plan"].models,
VIDEO_PROVIDERS["qwen-cloud-token-plan"].models
);
assert.notEqual(
IMAGE_PROVIDERS["bailian-coding-plan"].models,
IMAGE_PROVIDERS["qwen-cloud"].models
);
assert.deepEqual(parseImageModel("bcp/wan2.7-image-pro"), {
provider: "bailian-coding-plan",
model: "wan2.7-image-pro",
});
assert.deepEqual(parseImageModel("bcp/qwen-image-2.0-pro"), {
provider: "bailian-coding-plan",
model: "qwen-image-2.0-pro",
});
assert.deepEqual(parseVideoModel("bcp/happyhorse-1.1-t2v"), {
provider: "bailian-coding-plan",
model: "happyhorse-1.1-t2v",
});
assert.deepEqual(parseImageModel("wan2.7-image"), {
provider: "qwen-cloud-token-plan",
model: "wan2.7-image",
});
assert.deepEqual(parseVideoModel("happyhorse-1.1-t2v"), {
provider: "qwen-cloud-token-plan",
model: "happyhorse-1.1-t2v",
});
assert.deepEqual(resolveProviderServiceKinds("bailian-coding-plan", ["llm"]).sort(), [
"image",
"llm",
"video",
]);
});
test("Bailian Coding Plan media URLs are isolated from both Qwen Cloud providers", () => {
const bailianUrl = resolveAlibabaProviderMediaBaseUrl("bailian-coding-plan", {
region: "global-sg",
});
assert.equal(bailianUrl, "https://coding-intl.dashscope.aliyuncs.com/api/v1");
assert.equal(
resolveAlibabaProviderMediaBaseUrl("bailian-coding-plan", {
region: "china-beijing",
}),
"https://coding.dashscope.aliyuncs.com/api/v1"
);
assert.notEqual(
bailianUrl,
resolveAlibabaProviderMediaBaseUrl("qwen-cloud", { region: "global-sg" })
);
assert.notEqual(
bailianUrl,
resolveAlibabaProviderMediaBaseUrl("qwen-cloud-token-plan", { region: "global-sg" })
);
});
test("Bailian image models use only the Coding Plan endpoint and key", async () => {
const originalFetch = globalThis.fetch;
let captured;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
};
return jsonResponse({
output: {
choices: [
{
message: {
content: [{ image: "https://cdn.example.com/bailian-wan.png" }],
},
},
],
},
});
};
try {
for (const model of [
"wan2.7-image",
"wan2.7-image-pro",
"qwen-image-2.0",
"qwen-image-2.0-pro",
]) {
const result = await handleImageGeneration({
body: {
model: `bailian-coding-plan/${model}`,
prompt: "A watercolor horse",
size: "2048x2048",
},
credentials: {
apiKey: "bailian-plan-key",
providerSpecificData: { region: "china-beijing" },
},
log: null,
});
assert.equal(
captured.url,
"https://coding.dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
);
assert.equal(captured.headers.Authorization, "Bearer bailian-plan-key");
assert.equal(captured.body.model, model);
assert.deepEqual(captured.body.parameters, { size: "2048*2048" });
assert.equal(result.success, true);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("HappyHorse video generation uses only the Bailian Coding Plan endpoint and key", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
let captured;
globalThis.setTimeout = immediateTimeout;
globalThis.fetch = async (url, options = {}) => {
const stringUrl = String(url);
if (stringUrl.endsWith("/services/aigc/video-generation/video-synthesis")) {
captured = {
url: stringUrl,
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
};
return jsonResponse({ output: { task_id: "bailian-task", task_status: "PENDING" } });
}
if (stringUrl.endsWith("/tasks/bailian-task")) {
return jsonResponse({
output: {
task_status: "SUCCEEDED",
video_url: "https://cdn.example.com/bailian-happyhorse.mp4",
},
});
}
throw new Error(`Unexpected URL: ${stringUrl}`);
};
try {
const result = await handleVideoGeneration({
body: {
model: "bailian-coding-plan/happyhorse-1.1-i2v",
image_url: "https://cdn.example.com/first-frame.png",
duration: 5,
},
credentials: {
apiKey: "bailian-plan-key",
providerSpecificData: { region: "global-sg" },
},
log: null,
});
assert.equal(
captured.url,
"https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"
);
assert.equal(captured.headers.Authorization, "Bearer bailian-plan-key");
assert.deepEqual(captured.body.input.media, [
{ type: "first_frame", url: "https://cdn.example.com/first-frame.png" },
]);
assert.equal(result.success, true);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
});
test("Bailian Coding Plan rejects models outside its own media allowlists", async () => {
const imageResult = await handleImageGeneration({
body: {
model: "bailian-coding-plan/qwen-image-3.0-pro",
prompt: "wrong catalog",
},
credentials: { apiKey: "bailian-plan-key" },
log: null,
});
const videoResult = await handleVideoGeneration({
body: {
model: "bailian-coding-plan/wan2.7-videoedit",
prompt: "wrong catalog",
},
credentials: { apiKey: "bailian-plan-key" },
log: null,
});
assert.equal(imageResult.status, 400);
assert.match(imageResult.error, /unsupported bailian-coding-plan image model/i);
assert.equal(videoResult.status, 400);
assert.match(videoResult.error, /unsupported bailian-coding-plan video model/i);
});

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
__resetBrowserPoolModOverrideForTesting,
__resetHttpBackedChatOverrideForTesting,
__setBrowserPoolModOverrideForTesting,
__setHttpBackedChatOverrideForTesting,
tryBackedChat,
} from "../../open-sse/services/browserBackedChat.ts";
// Regression test for the "optional @omniroute/browser-pool package absent"
// path in tryBackedChat(). Before the fix, when the upstream returned a
// challenge response (e.g. 403) and the optional browser-pool package was
// not installed (getMod() resolves to null — the same shape a real failed
// `import("@omniroute/browser-pool")` produces), tryBackedChat() silently
// returned the stale challenge result instead of surfacing a clear error —
// callers (claude-web, duckduckgo-web) would then treat the unsolved
// challenge as a definitive upstream response.
//
// __setBrowserPoolModOverrideForTesting(null) simulates the module-absent
// case deterministically (same as getMod() catching a failed dynamic
// import), without depending on a real failing dynamic import of a
// nonexistent package.
describe("tryBackedChat — optional @omniroute/browser-pool package absent", () => {
it("throws a descriptive error instead of silently returning the stale challenge response", async () => {
__setBrowserPoolModOverrideForTesting(null);
__setHttpBackedChatOverrideForTesting(async () => ({
status: 403,
contentType: "text/html",
body: Buffer.from("<html>Just a moment...</html>"),
isStealth: true,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: 0,
},
}));
try {
await assert.rejects(
() =>
tryBackedChat({
poolKey: "duckduckgo-web",
chatUrl: "https://duck.ai/duckchat/v1/chat",
userMessage: "hello",
}),
(err: unknown) => {
assert.ok(err instanceof Error);
// Must NOT resolve with the stale challenge body — must throw with
// enough context to diagnose (challenge status + missing package).
assert.match(err.message, /challenge/i);
assert.match(err.message, /@omniroute\/browser-pool/);
assert.match(err.message, /403/);
return true;
}
);
} finally {
__resetHttpBackedChatOverrideForTesting();
__resetBrowserPoolModOverrideForTesting();
}
});
it("returns the httpResult directly when it is not a challenge response (2xx path unaffected)", async () => {
__setHttpBackedChatOverrideForTesting(async () => ({
status: 200,
contentType: "application/json",
body: Buffer.from(JSON.stringify({ ok: true })),
isStealth: true,
timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: 0,
},
}));
try {
const result = await tryBackedChat({
poolKey: "duckduckgo-web",
chatUrl: "https://duck.ai/duckchat/v1/chat",
userMessage: "hello",
});
assert.equal(result.status, 200);
} finally {
__resetHttpBackedChatOverrideForTesting();
}
});
});

View File

@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
const {
admitChatRequest,
admitChatStructure,
ChatAdmissionController,
releaseChatAdmissionAfterHandler,
releaseChatAdmissionWhenDone,
@@ -32,6 +33,152 @@ test("small known body is admitted without consuming heavyweight capacity", asyn
if (result.admit) assert.equal(await result.request.text(), "{}");
});
test("a byte-light request above the message threshold acquires heavyweight capacity", async () => {
const controller = new ChatAdmissionController(1);
const result = admitChatStructure(
{ messages: [{ role: "user", content: "one" }, { role: "user", content: "two" }] },
null,
{ controller, maxMessages: 10, heavyMessages: 2, heavyTools: 10, heavyTokens: 10_000 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
assert.equal(controller.activeHeavy, 0);
});
test("a byte-light request above the tool threshold is rejected when heavy capacity is busy", async () => {
const controller = new ChatAdmissionController(1);
const occupied = controller.tryAcquireHeavy();
assert.ok(occupied);
const result = admitChatStructure(
{ messages: [], tools: [{ type: "function" }, { type: "function" }] },
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 2, heavyTokens: 10_000 }
);
assert.equal(result.admit, false);
if (result.admit) return;
assert.equal(result.response.status, 503);
assert.equal(result.response.headers.get("retry-after"), "1");
assert.equal((await result.response.json()).error.code, "chat_admission_busy");
occupied.release();
});
test("a request above the hard history cap returns structured compact-required 413", async () => {
const controller = new ChatAdmissionController(1);
const result = admitChatStructure(
{ messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })) },
null,
{ controller, maxMessages: 2, heavyMessages: 1, heavyTools: 10, heavyTokens: 10_000 }
);
assert.equal(result.admit, false);
if (result.admit) return;
assert.equal(result.response.status, 413);
const payload = await result.response.json();
assert.equal(payload.error.code, "chat_history_too_large");
assert.equal(payload.error.reason, "message_limit");
assert.equal(controller.activeHeavy, 0);
});
test("a conservative token estimate classifies string messages and tool schemas as heavy", () => {
const controller = new ChatAdmissionController(1);
const result = admitChatStructure(
{
messages: [{ role: "user", content: "abcdefgh" }],
tools: [{ type: "function", function: { name: "tool", description: "abcdefgh" } }],
},
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 4 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
});
test("exhausting the bounded structural inspection is conservatively heavyweight", () => {
const controller = new ChatAdmissionController(1);
const result = admitChatStructure(
{
messages: [
{
role: "user",
content: Array.from({ length: 10_001 }, () => ({ value: 0 })),
},
],
},
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 10_000 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
});
test("tool-schema property names contribute to the conservative token estimate", () => {
const controller = new ChatAdmissionController(1);
const properties = Object.fromEntries(
Array.from({ length: 5 }, (_, index) => [`${index}${"k".repeat(99)}`, {}])
);
const result = admitChatStructure(
{ messages: [], tools: [{ function: { parameters: { properties } } }] },
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
});
test("non-ASCII strings use a conservative UTF-8 token estimate", () => {
const controller = new ChatAdmissionController(1);
const result = admitChatStructure(
{ messages: [{ role: "user", content: "漢".repeat(100) }] },
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
});
test("wide objects exhaust bounded inspection without materializing all property values", () => {
const controller = new ChatAdmissionController(1);
const wide = Object.fromEntries(Array.from({ length: 10_001 }, (_, index) => [`k${index}`, 0]));
const result = admitChatStructure(
{ messages: [{ role: "user", content: wide }] },
null,
{ controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 10_000 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) result.lease?.release();
});
test("an existing byte-heavy lease is reused for structure-heavy admission", () => {
const controller = new ChatAdmissionController(1);
const lease = controller.tryAcquireHeavy();
assert.ok(lease);
const result = admitChatStructure(
{ messages: [{ role: "user", content: "one" }, { role: "user", content: "two" }] },
lease,
{ controller, maxMessages: 10, heavyMessages: 2, heavyTools: 10, heavyTokens: 10_000 }
);
assert.equal(result.admit, true);
assert.equal(controller.activeHeavy, 1);
if (result.admit) assert.equal(result.lease, lease);
lease.release();
});
test("heavyweight admission is atomic and returns retryable 503 at capacity", async () => {
const controller = new ChatAdmissionController(1);
const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] });

Some files were not shown because too many files have changed in this diff Show More